57 Commits

Author SHA1 Message Date
2014be28c4 Merge pull request 'dev' (#5) from dev into master
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 39s
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled
Reviewed-on: #5
2026-03-25 20:54:09 +08:00
lafay
619f08275c fix(PostImages, CreatePostScreen): adjust image gap for improved layout consistency
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 9m17s
Frontend CI / ota-android (push) Successful in 11m48s
Frontend CI / build-android-apk (push) Successful in 1h12m38s
Frontend CI / ota-android (pull_request) Has been skipped
Frontend CI / build-and-push-web (pull_request) Failing after 37s
Frontend CI / build-android-apk (pull_request) Has been cancelled
- Reduced image gap in PostImages and CreatePostScreen components from 12 to 4 (desktop) and 8 to 2 (mobile) for better spacing.
- Updated ImageGrid component to modify grid item widths for enhanced layout.
- Refactored PostRepository to improve local caching logic, ensuring non-blocking UI updates during cache writes.
- Introduced saveConversationsWithRelatedCache function to streamline conversation, user, and group data saving in the database.
2026-03-25 17:08:11 +08:00
lafay
f875b417c8 feat(Dependencies): add new libraries for enhanced functionality and update existing ones
Some checks failed
Frontend CI / ota-android (push) Successful in 11m11s
Frontend CI / build-and-push-web (push) Successful in 11m41s
Frontend CI / build-android-apk (push) Has been cancelled
- Added `katex`, `markdown-it`, and `prismjs` for improved rendering of mathematical expressions and markdown content.
- Included `react-native-webview` to support web content within the app.
- Updated package versions in `package-lock.json` and `package.json` for better compatibility and performance.
- Refactored notification handling in `sseService` and `systemNotificationService` to improve user experience with vibration feedback.
- Introduced buffering for SSE messages in `messageManager` to prevent race conditions during initialization.
2026-03-25 16:09:43 +08:00
lafay
4ee3079b9f feat(Theme): enhance theming and UI consistency across components
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 8m15s
Frontend CI / ota-android (push) Successful in 10m56s
Frontend CI / build-android-apk (push) Successful in 1h3m22s
- Updated app.json to set userInterfaceStyle to automatic for improved theme adaptability.
- Refactored layout components to utilize useAppColors for dynamic theming, ensuring consistent color usage.
- Introduced SystemChrome component to manage system UI background color based on theme.
- Enhanced TabsLayout, ProfileStackLayout, and other components to leverage new theming structure.
- Improved QRCodeScanner, SearchBar, and CommentItem styles to align with the updated theme system.
- Consolidated styles in SystemMessageItem and TabBar for better maintainability and visual coherence.
2026-03-25 05:16:54 +08:00
lafay
90d834695f refactor(ConversationList): streamline conversation list handling and enhance unread count management
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m49s
Frontend CI / ota-android (push) Successful in 15m30s
Frontend CI / build-android-apk (push) Successful in 59m14s
- Removed debug display for unread count in ConversationListRow to clean up UI.
- Updated updateConversationCacheUnreadCount to optionally handle myLastReadSeq for better synchronization of read status.
- Refactored message service to unify offset and cursor pagination methods, improving data fetching consistency.
- Introduced a new method to normalize unread counts based on read cursor, ensuring accurate display of unread messages.
- Consolidated conversation list source implementations to simplify remote data fetching logic.
2026-03-25 04:07:48 +08:00
lafay
dc8f9061ab feat(ChatScreen): enhance message input and attachment handling
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 8m3s
Frontend CI / ota-android (push) Successful in 11m1s
Frontend CI / build-android-apk (push) Has been cancelled
- Updated ChatInput to manage pending attachments, allowing users to upload multiple images before sending.
- Introduced functionality to remove pending attachments and display their thumbnails in the input area.
- Enhanced ChatScreen to reflect changes in attachment handling, including dynamic messaging during uploads.
- Refactored message segment rendering to support inline text and grouped images, improving visual organization.
- Added constants for maximum pending images to enforce limits on user uploads.
2026-03-25 03:44:16 +08:00
lafay
583ac64dfd feat(Notification): implement notification preferences management and enhance notification handling
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 8m28s
Frontend CI / ota-android (push) Successful in 11m6s
Frontend CI / build-android-apk (push) Successful in 1h3m35s
- Introduced a new service for managing notification preferences, including push notifications, sound, and vibration settings.
- Updated the notification handling logic to respect user preferences, ensuring notifications are displayed according to user settings.
- Refactored the App and various screens to integrate the new notification preferences, improving user experience and consistency.
- Enhanced the HomeScreen and NotificationSettingsScreen to load and update notification settings seamlessly.
- Implemented a mechanism to hide the bottom tab bar based on scroll events, improving navigation usability.
2026-03-25 01:30:00 +08:00
lafay
cedb8284ba feat(Apps): introduce Apps tab and related screens for enhanced navigation
- Added a new "Apps" tab in the TabsLayout, providing users with access to various applications.
- Created AppsScreen to display app entries, including a schedule feature with a calendar icon.
- Implemented routing for the schedule and course screens under the new Apps tab structure.
- Updated navigation hrefs to reflect the new Apps section, improving overall user experience.
- Refactored HomeScreen to manage bottom tab visibility based on scroll events, enhancing usability.
2026-03-25 01:29:41 +08:00
lafay
c12b98e293 feat(TabsLayout): add schedule tab with calendar icon
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 3m1s
Frontend CI / ota-android (push) Successful in 11m8s
Frontend CI / build-android-apk (push) Successful in 1h16m26s
- Introduced a new "schedule" tab in the TabsLayout component, enhancing navigation options for users.
- Configured the tab with a title and a calendar icon for improved visual representation.
- Removed the previous implementation of the schedule tab to streamline the codebase.
2026-03-24 23:08:17 +08:00
lafay
9e41871b91 refactor(HomeScreen): simplify capsule visibility logic and improve list rendering
Some checks failed
Frontend CI / ota-android (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
- Removed unnecessary state and logic related to capsule visibility based on scroll position, streamlining the component's functionality.
- Adjusted FlatList properties to enhance rendering performance and layout consistency.
- Updated styles for the list header to ensure full-width display, improving the overall UI experience.
2026-03-24 23:02:24 +08:00
lafay
126e204592 refactor(Post, PostMapper, PostRepository, CreatePostScreen, HomeScreen): update communityId to channelId for improved clarity
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 2m48s
Frontend CI / ota-android (push) Successful in 11m3s
Frontend CI / build-android-apk (push) Has been cancelled
- Renamed communityId to channelId across Post entity, PostMapper, and PostRepository for consistency and clarity.
- Updated CreatePostScreen to include channel selection functionality, enhancing user experience when creating posts.
- Adjusted HomeScreen to support filtering posts by channel, improving content organization.
- Refactored related interfaces and services to align with the new channelId terminology, ensuring a cohesive codebase.
2026-03-24 22:27:33 +08:00
lafay
b49cc0f3bd refactor(TabsLayout, ImageGallery, ChatScreen, GroupInfoScreen, PrivateChatInfoScreen): enhance UI components and improve layout structure
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 3m4s
Frontend CI / ota-android (push) Successful in 12m24s
Frontend CI / build-android-apk (push) Successful in 1h17m36s
- Adjusted tab bar dimensions and margins in TabsLayout for better visual consistency.
- Streamlined ImageGallery by removing loading states and optimizing image transition handling.
- Updated ChatScreen to utilize SafeAreaView for improved layout on different devices.
- Enhanced GroupInfoScreen and PrivateChatInfoScreen with a consistent page header layout, including back navigation.
- Refactored ChatHeader to simplify structure and improve maintainability.
2026-03-24 15:39:28 +08:00
lafay
2ddb9cadd8 refactor(App, navigation): migrate to Expo Router and clean up navigation structure
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 4m27s
Frontend CI / ota-android (push) Successful in 11m6s
Frontend CI / build-android-apk (push) Successful in 1h16m45s
- Updated App entry point to utilize Expo Router, simplifying the navigation setup.
- Removed legacy navigation components and services to streamline the codebase.
- Adjusted package.json to reflect the new entry point and updated dependencies for compatibility.
- Enhanced overall application structure by consolidating navigation logic and improving maintainability.
2026-03-24 14:21:31 +08:00
lafay
b91e78c921 refactor(PostCard, PostCardGrid, HomeScreen): enhance PostCard component and remove PostCardGrid
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m44s
Frontend CI / ota-android (push) Successful in 11m56s
Frontend CI / build-android-apk (push) Successful in 49m3s
- Introduced a new PostCardRelativeTime component for dynamic time display, improving user experience with real-time updates.
- Refactored PostCard to utilize memoization for performance optimization and prevent unnecessary re-renders.
- Removed the PostCardGrid component to streamline the codebase, consolidating functionality within the PostCard component.
- Updated HomeScreen to leverage the new PostCard features, ensuring consistent post rendering and interaction handling.
2026-03-24 05:52:24 +08:00
lafay
f9f4e73747 feat(ProcessPostUseCase, HomeScreen, PostDetailScreen, PostService): implement share count synchronization after post sharing
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 7m11s
Frontend CI / ota-android (push) Successful in 12m55s
Frontend CI / build-android-apk (push) Has been cancelled
- Added applyShareCountUpdate method in ProcessPostUseCase to synchronize share counts across post lists and details.
- Updated sharePost method in PostService to return the latest shares_count from the server.
- Modified share handling in HomeScreen and PostDetailScreen to utilize the new share count synchronization logic after a successful share operation.
- Enhanced error handling for share operations to improve user feedback.
2026-03-24 05:21:46 +08:00
lafay
48339384d2 refactor(ImageGallery, HomeScreen, PostService, UserStore): streamline post type handling and improve tab navigation
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 2m37s
Frontend CI / ota-android (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
- Updated ImageGallery to manage loading states more effectively and prevent unnecessary updates.
- Refactored HomeScreen to remove the 'recommend' post type, simplifying the post type options.
- Adjusted PostService and UserStore to eliminate references to 'recommend', ensuring consistency across the application.
- Enhanced tab navigation in SimpleMobileTabNavigator to optimize rendering and manage mounted tabs more efficiently.
- Improved error handling and loading states in various components for better user experience.
2026-03-24 05:18:22 +08:00
lafay
357c1d4995 refactor(PostCard, PostCard.legacy): remove legacy PostCard component and update exports
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 8m37s
Frontend CI / ota-android (push) Successful in 10m53s
Frontend CI / build-android-apk (push) Successful in 51m16s
- Deleted the legacy PostCard component to streamline the codebase and improve maintainability.
- Updated exports in PostCard and index files to remove references to the legacy component.
- Adjusted PostCardProps to eliminate legacy properties, ensuring only the new API is supported.
- Enhanced the PostCard component to focus on modern implementation and features.
2026-03-24 04:23:13 +08:00
lan
82e99d24d8 Remove unnecessary console logs and replace them with void expressions for better performance and cleaner code. Update error logging to use console.error for consistency. This change enhances code readability and reduces console noise during execution.
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 2m18s
Frontend CI / ota-android (push) Failing after 6m15s
Frontend CI / build-android-apk (push) Failing after 29m51s
2026-03-24 03:02:54 +08:00
lafay
5c9cb6acea refactor(ProcessPostUseCase, useCursorPagination, useDifferentialPosts): enhance post merging and loading states
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 4m57s
Frontend CI / ota-android (push) Successful in 10m48s
Frontend CI / build-android-apk (push) Successful in 52m58s
- Introduced new methods for merging posts and comments efficiently, improving performance during data updates.
- Updated loading state management in hooks to include initial loading and loading more states for better user feedback.
- Refactored HomeScreen and PostDetailScreen to utilize the new loading states and merging logic, enhancing user experience during data fetching.
- Improved pagination handling in useCursorPagination and useDifferentialPosts to ensure consistent data management across components.
2026-03-24 01:19:09 +08:00
lafay
aaf53d1c3b refactor(GroupMembersScreen): enhance member list management with remote data source
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m45s
Frontend CI / ota-android (push) Successful in 13m15s
Frontend CI / build-android-apk (push) Successful in 1h1m10s
- Introduced a new remote data source for managing group members using cursor pagination.
- Updated member list loading logic to utilize the new data source, improving data synchronization and loading states.
- Enhanced member enrichment process by integrating user profiles into the member list.
- Refactored related imports and hooks for better organization and clarity.
2026-03-23 23:38:53 +08:00
lafay
c98f1917f7 refactor(LocalDataSource, PostRepository, ChatScreen): enhance database handling and message loading
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 2m41s
Frontend CI / ota-android (push) Successful in 12m45s
Frontend CI / build-android-apk (push) Has been cancelled
- Introduced a promise-based initialization mechanism in LocalDataSource to prevent concurrent initialization issues.
- Updated PostRepository to utilize an enqueueWrite method for database operations, ensuring thread-safe writes.
- Refactored ChatScreen to improve message loading logic, including preloading history and managing scroll behavior more effectively.
- Enhanced message rendering logic to maintain correct indices in inverted lists and optimize user experience during scrolling.
2026-03-23 23:06:19 +08:00
lafay
7305254e11 refactor(PostCard, ImageGrid, SmartImage): enhance post editing display and image handling
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 4m39s
Frontend CI / ota-android (push) Successful in 10m59s
Frontend CI / build-android-apk (push) Successful in 59m29s
- Introduced a new method to determine the last edited time for posts, prioritizing content_edited_at for better accuracy in display.
- Updated PostCard and PostDetailScreen components to utilize the new editing display logic.
- Enhanced ImageGrid and SmartImage components to support additional preview URLs and improve image loading behavior, including lazy loading optimizations for web.
- Modified Post and PostDTO interfaces to include contentEditedAt for better tracking of post edits.
2026-03-23 14:21:22 +08:00
lafay
d97df0b2d4 refactor(MessageManager): enhance conversation list handling and integrate local source fallback
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 4m34s
Frontend CI / ota-android (push) Successful in 10m57s
Frontend CI / build-android-apk (push) Has been cancelled
- Update MessageManager to support both remote and local conversation list sources, improving data retrieval and offline capabilities.
- Refactor hooks and components to utilize the new MessageManager structure, ensuring seamless integration of cursor pagination and local caching.
- Introduce new types and methods for better management of conversation data and loading states.
- Modify MessageListScreen to leverage the updated hooks for fetching conversations, enhancing user experience with improved loading and pagination handling.
2026-03-23 13:40:48 +08:00
lafay
26ae288470 refactor: standardize pagination response structure and enhance cursor handling
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 5m8s
Frontend CI / ota-android (push) Successful in 11m22s
Frontend CI / build-android-apk (push) Successful in 45m46s
- Update various services and hooks to replace 'items' with 'list' for consistency in pagination responses.
- Modify PostRepository, commentService, groupService, messageService, and postService to align with the new response structure.
- Enhance cursor pagination logic in hooks and components to ensure proper handling of pagination states.
- Refactor HomeScreen, PostDetailScreen, SearchScreen, and other components to utilize the updated pagination structure.
2026-03-23 03:58:26 +08:00
lafay
88510aa6ae feat(api): implement token auto-refresh mechanism and enhance token management
Some checks failed
Frontend CI / ota-android (push) Successful in 10m55s
Frontend CI / build-and-push-web (push) Failing after 13m25s
Frontend CI / build-android-apk (push) Successful in 56m26s
- Add support for proactive token refresh when nearing expiration to improve user experience.
- Introduce a refresh lock to prevent concurrent token refresh requests.
- Update token handling in API client to ensure valid tokens are used for requests.
- Modify authService to handle refresh token requests with improved error handling and response management.
2026-03-23 00:34:26 +08:00
lafay
cee120a6f8 refactor(PostRepository): update PostsListApiResponse structure and handle response mapping
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 5m21s
Frontend CI / ota-android (push) Successful in 10m57s
Frontend CI / build-android-apk (push) Has been cancelled
- Change 'posts' to 'list' in PostsListApiResponse for consistency with API response.
- Introduce optional fields: 'page', 'page_size', and 'total_pages' for enhanced pagination support.
- Update response handling in PostRepository methods to accommodate new structure and ensure default values for 'hasMore'.
2026-03-23 00:16:10 +08:00
lafay
69e21471fe fix(groupService): add missing closing brace
Some checks failed
Frontend CI / ota-android (push) Successful in 10m53s
Frontend CI / build-and-push-web (push) Failing after 16m53s
Frontend CI / build-android-apk (push) Successful in 1h16m19s
2026-03-21 21:13:34 +08:00
lafay
d273569911 refactor: migrate post operations to use case architecture with differential updates
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 3m11s
Frontend CI / ota-android (push) Failing after 16m3s
Frontend CI / build-android-apk (push) Has been cancelled
Migrate post-related operations from direct service/store calls to a new use case architecture featuring differential updates. Key changes include: (1) introduce ProcessPostUseCase to handle like, unlike, favorite, unfavorite, delete, and fetch operations with proper state management, (2) add useDifferentialPosts hook for efficient list rendering with minimal re-renders, (3) add PostDiffCalculator and PostUpdateBatcher in infrastructure layer for incremental updates, (4) deprecate NotificationItem component in favor of SystemMessageItem, (5) remove deprecated interactive methods from userStore, (6) add posts_cache table to local datasource, (7) clean up legacy MessageDTO and ConversationDTO types, (8) remove deprecated hook useBreakpointCheck, (9) refactor MessageBubble to use measureInWindow directly for better React Native compatibility
2026-03-21 20:55:36 +08:00
lafay
25071d2303 ci: 添加Gradle缓存、node_modules缓存和Docker构建缓存
Some checks failed
Frontend CI / ota-android (push) Successful in 14m5s
Frontend CI / build-android-apk (push) Successful in 51m52s
Frontend CI / build-and-push-web (push) Failing after 9m50s
2026-03-21 14:15:41 +08:00
lafay
37ddfa8ed6 feat(ui): redesign system message item with card-style layout and add title validation
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m39s
Frontend CI / ota-android (push) Successful in 12m4s
Frontend CI / build-android-apk (push) Successful in 44m0s
- Refactor SystemMessageItem with modern card design, gradient colors, and improved visual hierarchy
- Add unread indicator, status badges, and enhanced avatar badges for message types
- Update CreatePostScreen to require title input and improve placeholder text
- Add Android back button handling for notification screens in embedded mode
- Replace manual shadow styles with theme shadow utilities across message screens
2026-03-21 12:08:45 +08:00
lafay
a8c78f0c3f feat: 同步dev分支并保留当前修改
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 1m35s
Frontend CI / ota-android (push) Successful in 13m3s
Frontend CI / build-android-apk (push) Successful in 39m23s
2026-03-21 03:22:28 +08:00
lafay
2c65330837 Merge remote-tracking branch 'origin/dev' into release/v1.0.11
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 3m35s
Frontend CI / ota-android (push) Successful in 10m58s
Frontend CI / build-android-apk (push) Has been cancelled
2026-03-21 01:44:58 +08:00
lafay
97c7762f2b feat(navigation): add profile stack navigator and fix API endpoints
- Add ProfileStackNavigatorComponent with full navigation stack (Profile, Settings, EditProfile, AccountSecurity, MyPosts, Bookmarks, NotificationSettings, BlockedUsers)
- Add null checks for response data in authService login, register, and refreshToken methods
- Fix postService API routes to use correct endpoints (/posts instead of /posts/cursor)
- Remove completed cursor pagination design document
2026-03-21 01:36:40 +08:00
001140c7e1 Merge pull request 'release/v1.0.11' (#4) from release/v1.0.11 into dev
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 4m32s
Frontend CI / ota-android (push) Successful in 18m36s
Frontend CI / build-android-apk (push) Successful in 46m8s
Reviewed-on: #4
2026-03-20 23:04:50 +08:00
lafay
8a0aea1c59 feat(pagination): implement cursor-based pagination across the app
Add useCursorPagination hook and update multiple screens and services
to use cursor-based pagination for better performance and consistency.

- Add useCursorPagination hook with deduplication and caching support
- Add cursor pagination types to infrastructure layer
- Refactor HomeScreen, PostDetailScreen, SearchScreen for posts/comments
- Refactor GroupMembersScreen, JoinGroupScreen for groups/members
- Refactor MessageListScreen, NotificationsScreen for messages
- Update post, message, group, comment, notification services with cursor endpoints
- Add CursorPaginationRequest/Response DTOs
- Remove deprecated OPTIMIZATION_DESIGN.md documentation
2026-03-20 23:00:27 +08:00
lafay
a005fb0a15 feat: add QR code login and enhance chat experience
- Add QR code login flow with scanner and confirmation screens
- Implement swipe-to-reply in chat with SwipeableMessageBubble
- Replace FlatList with FlashList for chat performance optimization
- Add Schedule stack navigator with CourseDetail screen support
- Configure deep linking for QR code login (carrotbbs://qrcode/login/:sessionId)
- Update branding from 胡萝卜 to 萝卜社区
- Display dynamic app version in settings
2026-03-20 19:28:42 +08:00
lafay
59877e6ae3 chore: bump version to 1.0.11 2026-03-20 12:23:26 +08:00
lafay
7d9670e973 ci: use android-builder runner for APK build
Some checks failed
Frontend CI / ota-android (push) Successful in 13m14s
Frontend CI / build-and-push-web (push) Failing after 35m39s
Frontend CI / build-android-apk (push) Successful in 1h3m35s
2026-03-19 10:57:24 +08:00
lafay
de94447844 fix(frontend): 修复 TypeScript 类型错误,与后端 API 响应结构对齐
Some checks failed
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled
- 修复 UserDTO 类型定义,字段改为非 nullable 类型
- 修复 PostMapper 使用正确的 snake_case 字段名
- 修复 UserMapper 移除不存在的 is_blocked 和 updated_at 字段
- 修复 MessageMapper 使用 segments 替代已移除的字段
- 修复 MessageSyncService 正确处理 API 响应类型
- 修复 LocalDataSource SQLite 参数类型问题
- 修复导航相关类型错误
- 修复 PostDetailScreen 和 EditProfileScreen 的 null/undefined 类型不匹配
2026-03-19 10:53:09 +08:00
cf466a3959 Merge pull request 'dev' (#3) from dev into master
All checks were successful
Frontend CI / ota-android (push) Successful in 13m19s
Frontend CI / build-android-apk (push) Successful in 56m46s
Frontend CI / build-and-push-web (push) Successful in 5m46s
Reviewed-on: #3
2026-03-19 01:00:14 +08:00
lan
a91637466c refactor(messaging): replace WebSocket with SSE for real-time message handling
Some checks failed
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled
Frontend CI / ota-android (pull_request) Has been skipped
Frontend CI / build-and-push-web (pull_request) Has been cancelled
Frontend CI / build-android-apk (pull_request) Has been cancelled
Replace WebSocket-based real-time communication with Server-Sent Events (SSE) across the messaging infrastructure. This includes:

- Create new SSEClient datasource to manage SSE connections
- Create new SSEMessageHandler to process SSE events
- Update ProcessMessageUseCase to use SSEClient instead of WebSocketClient
- Update MessageManager and MessageStateManager to work with SSE handlers
- Rename connection state variables from `isWebSocketConnected` to `isSSEConnected`
- Update type definitions in dto.ts (WSEventType → SSEEventType, etc.)
- Delete obsolete WebSocketClient and WebSocketMessageHandler files
- Update comments and documentation to reflect SSE terminology

This refactoring aligns with the backend's SSE implementation for better compatibility with React Native's networking capabilities.
2026-03-19 00:56:41 +08:00
lafay
62b55aec31 feat(infrastructure): add pagination, cache, and sync infrastructure
All checks were successful
Frontend CI / ota-android (push) Successful in 14m38s
Frontend CI / build-android-apk (push) Successful in 1h0m2s
Frontend CI / build-and-push-web (push) Successful in 10m29s
- Add pagination infrastructure with usePagination and usePaginationManager hooks
- Add connection state management hook for network status tracking
- Add media caching infrastructure with useMediaCache hook
- Add differential message synchronization infrastructure
- Export new hooks from central index file
- Remove backup file MainNavigator.tsx.bak
2026-03-18 23:05:28 +08:00
lafay
a9c514f664 fix(post): prevent duplicate submission and fix navigation after posting
All checks were successful
Frontend CI / ota-android (push) Successful in 13m26s
Frontend CI / build-android-apk (push) Successful in 54m22s
Frontend CI / build-and-push-web (push) Successful in 10m44s
- add guard clause to prevent duplicate clicks when posting is in progress
- replace navigation.goBack() with navigation.reset to Main screen for cleaner navigation stack after post creation, update, and voting post submission
2026-03-18 21:09:55 +08:00
lafay
b11e6548d5 ci(build): optimize Android build performance with parallel execution and increased memory limits
Some checks failed
Frontend CI / ota-android (push) Failing after 7m8s
Frontend CI / build-android-apk (push) Successful in 1h11m44s
Frontend CI / build-and-push-web (push) Successful in 19m12s
Increase Gradle heap size from 2GB to 8GB, enable parallel builds with 4 workers, and update JVM options for faster CI pipeline execution. This reduces build times by utilizing more available resources in the build container.
2026-03-18 15:46:50 +08:00
lafay
2321c2dc06 refactor(navigation): replace direct navigation with navigation service
Some checks failed
Frontend CI / ota-android (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
Replace direct `navigation.navigate()` and `navigation.getParent()?.navigate()` calls with centralized `navigationService.navigate()` across multiple screens. Convert screen component functions from JSX elements to array-returning functions in RootNavigator to fix React key warnings and improve render consistency.

- HomeScreen: use navigationService for post and user navigation
- PrivateChatInfoScreen: use navigationService for profile navigation
- ChatScreen: use navigationService for avatar press and user profile navigation
- RootNavigator: convert PublicScreens and AuthenticatedScreens to getPublicScreens() and getAuthenticatedScreens() functions returning arrays with proper keys
2026-03-18 14:52:58 +08:00
lafay
a8f7e907b6 chore: update package-lock.json for expo-linking
Some checks failed
Frontend CI / ota-android (push) Successful in 11m14s
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
2026-03-18 14:28:18 +08:00
lafay
937e8112f4 fix: 修复安卓端帖子卡片点击事件冲突问题
Some checks failed
Frontend CI / ota-android (push) Failing after 4m51s
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
- 为嵌套TouchableOpacity添加stopPropagation防止事件冒泡
- 修复grid模式下图片和用户头像点击事件
- 修复列表模式下所有交互按钮点击事件
- 优化手势配置添加shouldCancelWhenOutside
2026-03-18 14:21:24 +08:00
lafay
a6cdb97e24 refactor: 架构重构 - 解耦过度耦合模块
主要改动:
1. 创建乐观更新工具函数 (optimisticUpdate.ts)
   - 消除 userStore.ts 中的重复代码

2. 拆分 useResponsive.ts (485行 -> 12个专注模块)
   - useBreakpoint: 断点检测
   - useOrientation: 方向检测
   - usePlatform: 平台检测
   - useScreenSize: 屏幕尺寸
   - useResponsiveValue: 响应式值
   - useResponsiveStyle: 响应式样式
   - useMediaQuery: 媒体查询
   - useColumnCount: 列数计算
   - useResponsiveSpacing: 响应式间距

3. 整理数据层 (Repository 层)
   - ApiDataSource: API数据源
   - LocalDataSource: 本地数据源
   - CacheDataSource: 缓存数据源
   - MessageRepository: 消息仓库

4. 重构 messageManager.ts (2194行 -> 4个模块)
   - MessageStateManager: 状态管理
   - WebSocketMessageHandler: WebSocket处理
   - MessageSyncService: 消息同步
   - ReadReceiptManager: 已读管理

5. 导航解耦 (MainNavigator.tsx: 1118行 -> 100行)
   - 创建 NavigationService 解耦层
   - 拆分多个 Navigator 组件

架构改进:
- 单一职责原则: 每个模块职责明确
- 依赖倒置: 通过接口解耦
- 代码复用: 工具函数可被多处使用
- 可测试性: 各模块可独立测试
2026-03-18 12:11:49 +08:00
lafay
1ffbb63753 fix: 统一修复所有屏幕的安全区域问题
Some checks failed
Frontend CI / ota-android (push) Successful in 10m56s
Frontend CI / build-android-apk (push) Failing after 33m45s
Frontend CI / build-and-push-web (push) Failing after 46s
- 有 header 的屏幕使用 edges={['bottom']}(React Navigation 自动处理顶部)
- 没有 header 的屏幕使用 edges={['top', 'bottom']}(需要自己处理顶部)
- 修复了以下屏幕:
  - HomeScreen: edges={['bottom']} → ['top', 'bottom']
  - MessageListScreen: edges={['top']} → ['top', 'bottom']
  - ScheduleScreen: edges={['top']} → ['top', 'bottom']
  - ProfileScreen: edges={['top', 'bottom']} ✓ 已正确
  - PostDetailScreen: 空状态添加 edges={['bottom']}
  - UserScreen: 空状态添加 edges={['bottom']}
  - NotificationsScreen: edges={[]} → ['bottom']
  - SearchScreen: edges={['left', 'right']} → ['bottom']
  - BlockedUsersScreen: 加载状态添加 edges={['bottom']}
  - GroupInfoScreen: edges={['bottom']} ✓ 已正确
  - PrivateChatInfoScreen: 加载状态添加 edges={['bottom']}
2026-03-18 10:58:41 +08:00
lafay
e230e35ca2 fix: 修复安卓端发帖界面未避让状态栏的问题
Some checks failed
Frontend CI / ota-android (push) Successful in 10m53s
Frontend CI / build-android-apk (push) Failing after 10m15s
Frontend CI / build-and-push-web (push) Failing after 3m28s
2026-03-18 10:22:41 +08:00
lafay
23931df1da Merge branch 'feature/adap-pc' into dev
Some checks failed
Frontend CI / ota-android (push) Successful in 11m5s
Frontend CI / build-and-push-web (push) Has been cancelled
Frontend CI / build-android-apk (push) Has been cancelled
2026-03-18 10:00:00 +08:00
lafay
01796ac695 fix(ci): optimize memory settings for CI stability
Some checks failed
Frontend CI / ota-android (push) Successful in 11m31s
Frontend CI / build-android-apk (push) Failing after 42m46s
Frontend CI / build-and-push-web (push) Failing after 1m47s
Reduce JVM heap and metaspace allocation to fit runner limits.
Add NODE_OPTIONS and restrict NDK/CMake parallelism to prevent
out-of-memory errors during build.
2026-03-18 01:54:14 +08:00
lan
6c032cefbf fix(ci): 移除 dependencyResolutionManagement 让项目使用 build.gradle 仓库配置
Some checks failed
Frontend CI / ota-android (push) Successful in 11m5s
Frontend CI / build-android-apk (push) Failing after 43m29s
Frontend CI / build-and-push-web (push) Failing after 1m37s
2026-03-18 00:51:58 +08:00
71b4f8a679 修复了课程表和发帖按钮 2026-03-18 00:25:46 +08:00
lan
9fb78bb5ee fix(ci): 修复 Gradle 配置,使用完整的 settings.gradle 和 build.gradle 文件替换
Some checks failed
Frontend CI / ota-android (push) Successful in 11m19s
Frontend CI / build-android-apk (push) Failing after 19m34s
Frontend CI / build-and-push-web (push) Failing after 1m12s
2026-03-18 00:19:22 +08:00
lan
8a95dbcb59 fix(ci): 修复 YAML 格式问题,使用 heredoc 正确配置 Maven 镜像
Some checks failed
Frontend CI / ota-android (push) Successful in 12m14s
Frontend CI / build-android-apk (push) Failing after 7m26s
Frontend CI / build-and-push-web (push) Failing after 1m39s
2026-03-17 23:48:57 +08:00
lan
6ba24c59aa fix(ci): 添加阿里云 Maven 镜像解决网络不可达问题 2026-03-17 23:44:54 +08:00
264 changed files with 33298 additions and 14742 deletions

View File

@@ -5,6 +5,6 @@ dist
dist-web dist-web
dist-android dist-android
dist-android-update.zip dist-android-update.zip
android android
screenshots screenshots
*.log *.log

View File

@@ -94,13 +94,17 @@ jobs:
test "${REMOTE}" = "${LOCAL}" test "${REMOTE}" = "${LOCAL}"
build-android-apk: build-android-apk:
runs-on: ubuntu-latest runs-on: android-builder
container: container:
image: reactnativecommunity/react-native-android:latest image: reactnativecommunity/react-native-android:latest
env: env:
GRADLE_OPTS: "-Dorg.gradle.daemon=false -Dorg.gradle.parallel=false -Dorg.gradle.workers.max=1 -Xmx4g -XX:MaxMetaspaceSize=1g" 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: "-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC" _JAVA_OPTIONS: "-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m"
NODE_OPTIONS: "--max-old-space-size=8192"
NODE_ENV: "production" NODE_ENV: "production"
NDK_NUM_JOBS: "4"
CMAKE_BUILD_PARALLEL_LEVEL: "4"
GRADLE_USER_HOME: /root/.gradle
permissions: permissions:
contents: read contents: read
steps: steps:
@@ -119,36 +123,148 @@ jobs:
node-version: '25.6.1' node-version: '25.6.1'
cache: 'npm' 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 - name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci run: npm ci
- name: Generate Android native project - name: Generate Android native project
run: npx expo prebuild --platform android --non-interactive run: npx expo prebuild --platform android
- name: Configure Gradle - name: Configure Gradle with Aliyun Maven mirror
run: | run: |
cd android cd android
cat > gradle.properties << EOF
# Update settings.gradle with Aliyun Maven mirror
cat > settings.gradle << 'SETTINGS_EOF'
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
maven { url 'https://maven.aliyun.com/repository/public' }
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
}
def reactNativeGradlePlugin = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })")
}.standardOutput.asText.get().trim()
).getParentFile().absolutePath
includeBuild(reactNativeGradlePlugin)
def expoPluginsPath = new File(
providers.exec {
workingDir(rootDir)
commandLine("node", "--print", "require.resolve('expo-modules-autolinking/package.json', { paths: [require.resolve('expo/package.json')] })")
}.standardOutput.asText.get().trim(),
"../android/expo-gradle-plugin"
).absolutePath
includeBuild(expoPluginsPath)
}
plugins {
id("com.facebook.react.settings")
id("expo-autolinking-settings")
}
extensions.configure(com.facebook.react.ReactSettingsExtension) { ex ->
if (System.getenv('EXPO_USE_COMMUNITY_AUTOLINKING') == '1') {
ex.autolinkLibrariesFromCommand()
} else {
ex.autolinkLibrariesFromCommand(expoAutolinking.rnConfigCommand)
}
}
expoAutolinking.useExpoModules()
rootProject.name = '萝卜社区'
expoAutolinking.useExpoVersionCatalog()
include ':app'
includeBuild(expoAutolinking.reactNativeGradlePlugin)
SETTINGS_EOF
# Update build.gradle with Aliyun Maven mirror
cat > build.gradle << 'BUILD_EOF'
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
repositories {
google()
mavenCentral()
maven { url 'https://maven.aliyun.com/repository/public' }
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/central' }
maven { url 'https://maven.aliyun.com/repository/gradle-plugin' }
}
dependencies {
classpath('com.android.tools.build:gradle')
classpath('com.facebook.react:react-native-gradle-plugin')
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
}
}
allprojects {
repositories {
google()
mavenCentral()
maven { url 'https://www.jitpack.io' }
maven { url 'https://maven.aliyun.com/repository/public' }
maven { url 'https://maven.aliyun.com/repository/google' }
maven { url 'https://maven.aliyun.com/repository/central' }
}
}
apply plugin: "expo-root-project"
apply plugin: "com.facebook.react.rootproject"
BUILD_EOF
# Update gradle.properties
cat > gradle.properties << 'PROPS_EOF'
org.gradle.daemon=false org.gradle.daemon=false
org.gradle.parallel=false org.gradle.parallel=true
org.gradle.configureondemand=false org.gradle.configureondemand=true
org.gradle.workers.max=1 org.gradle.workers.max=4
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8g -XX:MaxMetaspaceSize=1g -XX:+UseG1GC -XX:SoftRefLRUPolicyMSPerMB=0 -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.enableJetifier=false android.enableJetifier=false
android.useAndroidX=true android.useAndroidX=true
hermesEnabled=true hermesEnabled=true
reactNativeArchitectures=arm64-v8a reactNativeArchitectures=arm64-v8a
EOF newArchEnabled=true
edgeToEdgeEnabled=true
# Fix hermesEnabled in build.gradle expo.gif.enabled=true
sed -i 's/def hermesEnabled = findProperty("hermesEnabled")/def hermesEnabled = findProperty("hermesEnabled") ?: "true"/' app/build.gradle || true expo.webp.enabled=true
grep -n "hermesEnabled" app/build.gradle || echo "hermesEnabled not found in build.gradle" expo.webp.animated=false
expo.useLegacyPackaging=false
PROPS_EOF
- name: Build Android release APK (arm64 only) - name: Build Android release APK (arm64 only)
run: | run: |
cd android cd android
chmod +x gradlew 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 - name: Upload APK artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
@@ -198,6 +314,8 @@ jobs:
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64 platforms: linux/amd64
provenance: false 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 - name: Show image tags
run: | run: |

3
.gitignore vendored
View File

@@ -51,3 +51,6 @@ dist-android-update.zip
# Backend # Backend
backend/data/ backend/data/
backend/logs/ backend/logs/
doc/
docs/

2
.idea/.gitignore generated vendored
View File

@@ -6,3 +6,5 @@
# Datasource local storage ignored files # Datasource local storage ignored files
/dataSources/ /dataSources/
/dataSources.local.xml /dataSources.local.xml
# Screenshots
screenshots/

View 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
View File

@@ -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() { export default function App() {
const appState = useRef<AppStateStatus>(AppState.currentState); return null;
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>
);
} }

View File

@@ -2,10 +2,11 @@
"expo": { "expo": {
"name": "萝卜社区", "name": "萝卜社区",
"slug": "qojo", "slug": "qojo",
"version": "1.0.10", "version": "1.0.11",
"orientation": "default", "orientation": "default",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "light", "userInterfaceStyle": "automatic",
"scheme": "carrotbbs",
"splash": { "splash": {
"image": "./assets/splash-icon.png", "image": "./assets/splash-icon.png",
"resizeMode": "contain", "resizeMode": "contain",
@@ -33,7 +34,7 @@
}, },
"predictiveBackGestureEnabled": false, "predictiveBackGestureEnabled": false,
"package": "skin.carrot.bbs", "package": "skin.carrot.bbs",
"versionCode": 5, "versionCode": 6,
"permissions": [ "permissions": [
"VIBRATE", "VIBRATE",
"RECEIVE_BOOT_COMPLETED", "RECEIVE_BOOT_COMPLETED",
@@ -68,6 +69,12 @@
} }
], ],
"expo-sqlite", "expo-sqlite",
[
"expo-camera",
{
"cameraPermission": "允许萝卜社区访问您的相机以扫描二维码"
}
],
[ [
"expo-notifications", "expo-notifications",
{ {

View 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>
);
}

View File

@@ -0,0 +1,5 @@
import { Stack } from 'expo-router';
export default function AppsStackLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -0,0 +1,5 @@
import { AppsScreen } from '../../../../src/screens/apps';
export default function AppsRoute() {
return <AppsScreen />;
}

View 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>
);
}

View File

@@ -0,0 +1,5 @@
import { CourseDetailScreen } from '../../../../../src/screens/schedule';
export default function CourseDetailRoute() {
return <CourseDetailScreen />;
}

View File

@@ -0,0 +1,5 @@
import { ScheduleScreen } from '../../../../../src/screens/schedule';
export default function ScheduleRoute() {
return <ScheduleScreen />;
}

View File

@@ -0,0 +1,5 @@
import { Stack } from 'expo-router';
export default function HomeStackLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -0,0 +1,5 @@
import { HomeScreen } from '../../../../src/screens/home';
export default function HomeRoute() {
return <HomeScreen />;
}

View File

@@ -0,0 +1,5 @@
import { SearchScreen } from '../../../../src/screens/home';
export default function HomeSearchRoute() {
return <SearchScreen />;
}

View File

@@ -0,0 +1,5 @@
import { Stack } from 'expo-router';
export default function MessagesStackLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -0,0 +1,5 @@
import { MessageListScreen } from '../../../../src/screens/message';
export default function MessagesRoute() {
return <MessageListScreen />;
}

View File

@@ -0,0 +1,5 @@
import { NotificationsScreen } from '../../../../src/screens/message';
export default function NotificationsRoute() {
return <NotificationsScreen />;
}

View 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>
);
}

View File

@@ -0,0 +1,5 @@
import { AccountSecurityScreen } from '../../../../src/screens/profile';
export default function AccountSecurityRoute() {
return <AccountSecurityScreen />;
}

View File

@@ -0,0 +1,5 @@
import { BlockedUsersScreen } from '../../../../src/screens/profile';
export default function BlockedUsersRoute() {
return <BlockedUsersScreen />;
}

View File

@@ -0,0 +1,5 @@
import { ProfileScreen } from '../../../../src/screens/profile';
export default function BookmarksRoute() {
return <ProfileScreen />;
}

View File

@@ -0,0 +1,5 @@
import { EditProfileScreen } from '../../../../src/screens/profile';
export default function EditProfileRoute() {
return <EditProfileScreen />;
}

View File

@@ -0,0 +1,5 @@
import { ProfileScreen } from '../../../../src/screens/profile';
export default function ProfileRoute() {
return <ProfileScreen />;
}

View File

@@ -0,0 +1,5 @@
import { ProfileScreen } from '../../../../src/screens/profile';
export default function MyPostsRoute() {
return <ProfileScreen />;
}

View File

@@ -0,0 +1,5 @@
import { NotificationSettingsScreen } from '../../../../src/screens/profile';
export default function NotificationSettingsRoute() {
return <NotificationSettingsScreen />;
}

View File

@@ -0,0 +1,5 @@
import { SettingsScreen } from '../../../../src/screens/profile';
export default function SettingsRoute() {
return <SettingsScreen />;
}

19
app/(app)/_layout.tsx Normal file
View 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
View 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 />;
}

View File

@@ -0,0 +1,5 @@
import { ChatScreen } from '../../../src/screens/message';
export default function ChatRoute() {
return <ChatScreen />;
}

View File

@@ -0,0 +1,5 @@
import { PrivateChatInfoScreen } from '../../../src/screens/message';
export default function PrivateChatInfoRoute() {
return <PrivateChatInfoScreen />;
}

View File

@@ -0,0 +1,5 @@
import { GroupInfoScreen } from '../../../../src/screens/message';
export default function GroupInfoRoute() {
return <GroupInfoScreen />;
}

View File

@@ -0,0 +1,5 @@
import { GroupMembersScreen } from '../../../../src/screens/message';
export default function GroupMembersRoute() {
return <GroupMembersScreen />;
}

View File

@@ -0,0 +1,5 @@
import { CreateGroupScreen } from '../../../src/screens/message';
export default function CreateGroupRoute() {
return <CreateGroupScreen />;
}

View 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
View File

@@ -0,0 +1,5 @@
import { JoinGroupScreen } from '../../../src/screens/message';
export default function JoinGroupRoute() {
return <JoinGroupScreen />;
}

View File

@@ -0,0 +1,5 @@
import GroupRequestDetailScreen from '../../../src/screens/message/GroupRequestDetailScreen';
export default function GroupRequestRoute() {
return <GroupRequestDetailScreen />;
}

View File

@@ -0,0 +1,5 @@
import { CreatePostScreen } from '../../../src/screens/create';
export default function CreatePostRoute() {
return <CreatePostScreen />;
}

View File

@@ -0,0 +1,5 @@
import { QRCodeConfirmScreen } from '../../../../src/screens/auth/QRCodeConfirmScreen';
export default function QrLoginConfirmRoute() {
return <QRCodeConfirmScreen />;
}

View 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
View File

@@ -0,0 +1,5 @@
import { Stack } from 'expo-router';
export default function AuthLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -0,0 +1,5 @@
import { ForgotPasswordScreen } from '../../src/screens/auth';
export default function ForgotPasswordRoute() {
return <ForgotPasswordScreen />;
}

5
app/(auth)/login.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { LoginScreen } from '../../src/screens/auth';
export default function LoginRoute() {
return <LoginScreen />;
}

5
app/(auth)/register.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { RegisterScreen } from '../../src/screens/auth';
export default function RegisterRoute() {
return <RegisterScreen />;
}

205
app/_layout.tsx Normal file
View 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 的 SafeAreaViewbackground.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
View 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
View File

@@ -0,0 +1,5 @@
import { PostDetailScreen } from '../../src/screens/home';
export default function PostDetailRoute() {
return <PostDetailScreen />;
}

5
app/user/[userId].tsx Normal file
View File

@@ -0,0 +1,5 @@
import { UserScreen } from '../../src/screens/profile';
export default function UserProfileRoute() {
return <UserScreen />;
}

View File

@@ -1,8 +1 @@
import { registerRootComponent } from 'expo'; import 'expo-router/entry';
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);

3118
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{ {
"name": "carrot_bbs", "name": "carrot_bbs",
"version": "1.0.0", "version": "1.0.1",
"main": "index.ts", "main": "expo-router/entry",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
"android": "expo run:android", "android": "expo run:android",
@@ -25,7 +25,9 @@
"axios": "^1.13.6", "axios": "^1.13.6",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"expo": "~55.0.4", "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-constants": "~55.0.7",
"expo-dev-client": "~55.0.10", "expo-dev-client": "~55.0.10",
"expo-file-system": "~55.0.10", "expo-file-system": "~55.0.10",
@@ -43,6 +45,9 @@
"expo-task-manager": "~55.0.9", "expo-task-manager": "~55.0.9",
"expo-updates": "^55.0.12", "expo-updates": "^55.0.12",
"expo-video": "^55.0.10", "expo-video": "^55.0.10",
"katex": "^0.16.42",
"markdown-it": "^14.1.1",
"prismjs": "^1.30.0",
"react": "19.2.0", "react": "19.2.0",
"react-dom": "19.2.0", "react-dom": "19.2.0",
"react-native": "0.83.2", "react-native": "0.83.2",
@@ -54,6 +59,7 @@
"react-native-screens": "~4.23.0", "react-native-screens": "~4.23.0",
"react-native-sse": "^1.2.1", "react-native-sse": "^1.2.1",
"react-native-web": "^0.21.0", "react-native-web": "^0.21.0",
"react-native-webview": "13.16.0",
"react-native-worklets": "0.7.2", "react-native-worklets": "0.7.2",
"zod": "^4.3.6", "zod": "^4.3.6",
"zustand": "^5.0.11" "zustand": "^5.0.11"

View 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

View 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>
);
}

View File

@@ -0,0 +1,8 @@
import { Stack } from 'expo-router';
/**
* 已登录区内栈:由 app/(app) 下文件系统自动注册子路由
*/
export function AppRouteStack() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -1,21 +1,19 @@
/** /**
* CommentItem 评论项组件 - QQ频道风格 * CommentItem 评论项组件 - 小红书 / 贴吧式平铺列表
* 支持嵌套回复显示、楼层号、身份标识、删除评论、图片显示 * 支持嵌套回复显示、楼层号、身份标识、删除评论、图片显示
*/ */
import React, { useState } from 'react'; import React, { useMemo, useState } from 'react';
import { View, TouchableOpacity, StyleSheet, Alert, Dimensions } from 'react-native'; import { View, TouchableOpacity, StyleSheet, Alert } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale'; 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 { Comment, CommentImage } from '../../types';
import Text from '../common/Text'; import Text from '../common/Text';
import Avatar from '../common/Avatar'; import Avatar from '../common/Avatar';
import { CompactImageGrid, ImageGridItem } from '../common'; import { CompactImageGrid, ImageGridItem } from '../common';
const { width: screenWidth } = Dimensions.get('window');
interface CommentItemProps { interface CommentItemProps {
comment: Comment; comment: Comment;
onUserPress: () => void; onUserPress: () => void;
@@ -33,6 +31,174 @@ interface CommentItemProps {
currentUserId?: string; // 当前用户ID用于判断子评论作者 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> = ({ const CommentItem: React.FC<CommentItemProps> = ({
comment, comment,
onUserPress, onUserPress,
@@ -49,6 +215,8 @@ const CommentItem: React.FC<CommentItemProps> = ({
onImagePress, onImagePress,
currentUserId, currentUserId,
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createCommentItemStyles(colors), [colors]);
const [isDeleting, setIsDeleting] = useState(false); const [isDeleting, setIsDeleting] = useState(false);
// 格式化时间 // 格式化时间
const formatTime = (dateString: string): string => { const formatTime = (dateString: string): string => {
@@ -181,11 +349,9 @@ const CommentItem: React.FC<CommentItemProps> = ({
}; };
return ( return (
<View style={styles.floorTag}> <Text variant="caption" color={colors.text.hint} style={styles.floorPlain}>
<Text variant="caption" color={colors.text.hint} style={styles.floorText}> {getFloorText(floorNumber)}
{getFloorText(floorNumber)} </Text>
</Text>
</View>
); );
}; };
@@ -276,8 +442,9 @@ const CommentItem: React.FC<CommentItemProps> = ({
return ( return (
<View style={styles.subRepliesContainer}> <View style={styles.subRepliesContainer}>
{comment.replies.map((reply) => { {comment.replies.map((reply, replyIndex) => {
const replyAuthorId = reply.author?.id || ''; const replyAuthorId = reply.author?.id || '';
const isLastReply = replyIndex === comment.replies!.length - 1;
// 根据 target_id 获取被回复的用户昵称 // 根据 target_id 获取被回复的用户昵称
const targetId = reply.target_id; const targetId = reply.target_id;
const targetNickname = targetId ? getTargetUserNickname(targetId) : null; const targetNickname = targetId ? getTargetUserNickname(targetId) : null;
@@ -287,7 +454,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
return ( return (
<TouchableOpacity <TouchableOpacity
key={reply.id} key={reply.id}
style={styles.subReplyItem} style={[styles.subReplyItem, isLastReply && styles.subReplyItemLast]}
onPress={() => onReplyPress?.(reply)} onPress={() => onReplyPress?.(reply)}
activeOpacity={0.7} activeOpacity={0.7}
> >
@@ -320,7 +487,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
</View> </View>
{/* 显示回复内容(如果有文字) */} {/* 显示回复内容(如果有文字) */}
{reply.content ? ( {reply.content ? (
<Text variant="caption" color={colors.text.secondary} numberOfLines={2}> <Text variant="body" color={colors.text.primary} style={styles.subReplyBody}>
{reply.content} {reply.content}
</Text> </Text>
) : null} ) : null}
@@ -329,18 +496,18 @@ const CommentItem: React.FC<CommentItemProps> = ({
{/* 子评论操作按钮 */} {/* 子评论操作按钮 */}
<View style={styles.subReplyActions}> <View style={styles.subReplyActions}>
<TouchableOpacity <TouchableOpacity
style={styles.actionButton} style={styles.subActionButton}
onPress={() => onReplyPress?.(reply)} onPress={() => onReplyPress?.(reply)}
> >
<MaterialCommunityIcons name="reply" size={12} color={colors.text.hint} /> <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> </Text>
</TouchableOpacity> </TouchableOpacity>
{/* 删除按钮 - 子评论作者可见 */} {/* 删除按钮 - 子评论作者可见 */}
{isSubReplyAuthor && ( {isSubReplyAuthor && (
<TouchableOpacity <TouchableOpacity
style={styles.actionButton} style={styles.subActionButton}
onPress={() => handleSubReplyDelete(reply)} onPress={() => handleSubReplyDelete(reply)}
disabled={isDeleting} disabled={isDeleting}
> >
@@ -349,7 +516,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
size={12} size={12}
color={colors.text.hint} 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 ? '删除中' : '删除'} {isDeleting ? '删除中' : '删除'}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
@@ -375,11 +542,11 @@ const CommentItem: React.FC<CommentItemProps> = ({
return ( return (
<View style={styles.container}> <View style={styles.container}>
{/* 用户头像 */} {/* 用户头像 - 略大便于点击,与正文左对齐 */}
<TouchableOpacity onPress={onUserPress}> <TouchableOpacity onPress={onUserPress} activeOpacity={0.7}>
<Avatar <Avatar
source={comment.author?.avatar} source={comment.author?.avatar}
size={36} size={40}
name={comment.author?.nickname} name={comment.author?.nickname}
/> />
</TouchableOpacity> </TouchableOpacity>
@@ -389,12 +556,13 @@ const CommentItem: React.FC<CommentItemProps> = ({
{/* 用户信息行 - QQ频道风格 */} {/* 用户信息行 - QQ频道风格 */}
<View style={styles.header}> <View style={styles.header}>
<View style={styles.userInfo}> <View style={styles.userInfo}>
<TouchableOpacity onPress={onUserPress}> <TouchableOpacity onPress={onUserPress} activeOpacity={0.7}>
<Text variant="body" style={styles.username}> <Text variant="body" style={styles.username} numberOfLines={1}>
{comment.author?.nickname} {comment.author?.nickname || '用户'}
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
{renderBadges()} {renderBadges()}
<Text style={styles.metaDot}>·</Text>
<Text variant="caption" color={colors.text.hint} style={styles.timeText}> <Text variant="caption" color={colors.text.hint} style={styles.timeText}>
{formatTime(comment.created_at || '')} {formatTime(comment.created_at || '')}
</Text> </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; export default CommentItem;

View File

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

View File

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

View 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;

View 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;

View File

@@ -0,0 +1,5 @@
/**
* PostCard 子组件导出(仅导出仓库内已存在的实现)
*/
export { default as PostImages } from './PostImages';

View File

@@ -0,0 +1,5 @@
/**
* PostCard Hooks预留入口实现文件尚未加入仓库时勿导出避免 TS 无法解析)
*/
export {};

View 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';

View 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;
}

View 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;

View File

@@ -3,10 +3,10 @@
* 用于搜索内容 * 用于搜索内容
*/ */
import React, { useState } from 'react'; import React, { useMemo, useState } from 'react';
import { View, TextInput, TouchableOpacity, StyleSheet } from 'react-native'; import { View, TextInput, TouchableOpacity, StyleSheet } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme'; import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
interface SearchBarProps { interface SearchBarProps {
value: string; value: string;
@@ -18,6 +18,63 @@ interface SearchBarProps {
autoFocus?: boolean; 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> = ({ const SearchBar: React.FC<SearchBarProps> = ({
value, value,
onChangeText, onChangeText,
@@ -27,6 +84,8 @@ const SearchBar: React.FC<SearchBarProps> = ({
onBlur, onBlur,
autoFocus = false, autoFocus = false,
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createSearchBarStyles(colors), [colors]);
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const handleFocus = () => { const handleFocus = () => {
@@ -59,7 +118,6 @@ const SearchBar: React.FC<SearchBarProps> = ({
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
autoFocus={autoFocus} autoFocus={autoFocus}
// 确保光标可见
cursorColor={colors.primary.main} cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`} selectionColor={`${colors.primary.main}40`}
/> />
@@ -69,73 +127,11 @@ const SearchBar: React.FC<SearchBarProps> = ({
style={styles.clearButton} style={styles.clearButton}
activeOpacity={0.7} activeOpacity={0.7}
> >
<MaterialCommunityIcons <MaterialCommunityIcons name="close" size={14} color={colors.text.secondary} />
name="close"
size={14}
color={colors.text.secondary}
/>
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </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; export default SearchBar;

View File

@@ -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 { View, TouchableOpacity, StyleSheet } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { formatDistanceToNow } from 'date-fns'; import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale'; 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 { SystemMessageResponse, SystemMessageType } from '../../types/dto';
import Text from '../common/Text'; import Text from '../common/Text';
import Avatar from '../common/Avatar'; import Avatar from '../common/Avatar';
@@ -17,15 +17,16 @@ import Avatar from '../common/Avatar';
interface SystemMessageItemProps { interface SystemMessageItemProps {
message: SystemMessageResponse; message: SystemMessageResponse;
onPress?: () => void; onPress?: () => void;
onAvatarPress?: () => void; // 头像点击回调 onAvatarPress?: () => void;
onRequestAction?: (approve: boolean) => void; onRequestAction?: (approve: boolean) => void;
requestActionLoading?: boolean; requestActionLoading?: boolean;
index?: number; // 用于交错动画
} }
// 系统消息类型到图标和颜色的映射
const getSystemMessageIcon = ( const getSystemMessageIcon = (
systemType: SystemMessageType systemType: SystemMessageType,
): { icon: keyof typeof MaterialCommunityIcons.glyphMap; color: string; bgColor: string } => { colors: AppColors
): { icon: keyof typeof MaterialCommunityIcons.glyphMap; color: string; bgColor: string; gradient: string[] } => {
switch (systemType) { switch (systemType) {
case 'like_post': case 'like_post':
case 'like_comment': case 'like_comment':
@@ -33,83 +34,243 @@ const getSystemMessageIcon = (
case 'favorite_post': case 'favorite_post':
return { return {
icon: 'heart', icon: 'heart',
color: colors.error.main, color: '#FF4757',
bgColor: colors.error.light + '20', bgColor: '#FFE4E6',
gradient: ['#FF4757', '#FF6B7A'],
}; };
case 'comment': case 'comment':
return { return {
icon: 'comment', icon: 'comment-text',
color: colors.info.main, color: '#2196F3',
bgColor: colors.info.light + '20', bgColor: '#E3F2FD',
gradient: ['#2196F3', '#64B5F6'],
}; };
case 'reply': case 'reply':
return { return {
icon: 'reply', icon: 'reply',
color: colors.success.main, color: '#4CAF50',
bgColor: colors.success.light + '20', bgColor: '#E8F5E9',
gradient: ['#4CAF50', '#81C784'],
}; };
case 'follow': case 'follow':
return { return {
icon: 'account-plus', icon: 'account-plus',
color: '#9C27B0', // 紫色 color: '#9C27B0',
bgColor: '#9C27B020', bgColor: '#F3E5F5',
gradient: ['#9C27B0', '#BA68C8'],
}; };
case 'mention': case 'mention':
return { return {
icon: 'at', icon: 'at',
color: colors.warning.main, color: '#FF9800',
bgColor: colors.warning.light + '20', bgColor: '#FFF3E0',
gradient: ['#FF9800', '#FFB74D'],
}; };
case 'system': case 'system':
return { return {
icon: 'cog', icon: 'cog',
color: colors.text.secondary, color: '#607D8B',
bgColor: colors.background.disabled, bgColor: '#ECEFF1',
gradient: ['#607D8B', '#90A4AE'],
}; };
case 'announcement': case 'announcement':
case 'announce': case 'announce':
return { return {
icon: 'bullhorn', icon: 'bullhorn',
color: colors.primary.main, color: colors.primary.main,
bgColor: colors.primary.light + '20', bgColor: colors.primary.light + '30',
gradient: [colors.primary.main, colors.primary.light],
}; };
case 'group_invite': case 'group_invite':
return { return {
icon: 'account-multiple-plus', icon: 'account-multiple-plus',
color: colors.primary.main, color: '#00BCD4',
bgColor: colors.primary.light + '20', bgColor: '#E0F7FA',
gradient: ['#00BCD4', '#4DD0E1'],
}; };
case 'group_join_apply': case 'group_join_apply':
return { return {
icon: 'account-clock', icon: 'account-clock',
color: colors.warning.main, color: '#FF9800',
bgColor: colors.warning.light + '20', bgColor: '#FFF3E0',
gradient: ['#FF9800', '#FFB74D'],
}; };
case 'group_join_approved': case 'group_join_approved':
return { return {
icon: 'check-circle', icon: 'check-circle',
color: colors.success.main, color: '#4CAF50',
bgColor: colors.success.light + '20', bgColor: '#E8F5E9',
gradient: ['#4CAF50', '#81C784'],
}; };
case 'group_join_rejected': case 'group_join_rejected':
return { return {
icon: 'close-circle', icon: 'close-circle',
color: colors.error.main, color: '#F44336',
bgColor: colors.error.light + '20', bgColor: '#FFEBEE',
gradient: ['#F44336', '#E57373'],
}; };
default: default:
return { return {
icon: 'bell', icon: 'bell',
color: colors.text.secondary, color: '#757575',
bgColor: colors.background.disabled, 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 getSystemMessageTitle = (message: SystemMessageResponse): string => {
const { system_type, extra_data } = message; const { system_type, extra_data } = message;
// 兼容后端返回的 actor_name 和 operator_name
const operatorName = extra_data?.actor_name || extra_data?.operator_name; const operatorName = extra_data?.actor_name || extra_data?.operator_name;
const groupName = extra_data?.group_name || extra_data?.target_title; const groupName = extra_data?.group_name || extra_data?.target_title;
@@ -163,7 +324,11 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
onAvatarPress, onAvatarPress,
onRequestAction, onRequestAction,
requestActionLoading = false, requestActionLoading = false,
index = 0,
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createSystemMessageStyles(colors), [colors]);
// 格式化时间 // 格式化时间
const formatTime = (dateString: string): string => { const formatTime = (dateString: string): string => {
try { 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 title = getSystemMessageTitle(message);
const { extra_data } = message; const { extra_data } = message;
// 兼容后端返回的 actor_name 和 operator_name
const operatorName = extra_data?.actor_name || extra_data?.operator_name; const operatorName = extra_data?.actor_name || extra_data?.operator_name;
const operatorAvatar = extra_data?.avatar_url || extra_data?.operator_avatar; const operatorAvatar = extra_data?.avatar_url || extra_data?.operator_avatar;
const groupAvatar = extra_data?.group_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') && (message.system_type === 'group_invite' || message.system_type === 'group_join_apply') &&
requestStatus === 'pending' && requestStatus === 'pending' &&
!!onRequestAction; !!onRequestAction;
const isUnread = message.is_read !== true;
// 使用固定的响应式值兼容移动端和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 showOperatorAvatar = ['follow', 'like_post', 'like_comment', 'like_reply', 'favorite_post', 'comment', 'reply', 'mention', 'group_join_apply'].includes( 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 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 ( return (
<TouchableOpacity <TouchableOpacity
style={styles.container} style={[
styles.container,
isUnread && styles.unreadContainer,
]}
onPress={onPress} onPress={onPress}
activeOpacity={onPress ? 0.7 : 1} activeOpacity={onPress ? 0.85 : 1}
disabled={!onPress} disabled={!onPress}
> >
{/* 左侧未读指示器 */}
{isUnread && <View style={styles.unreadIndicator} />}
{/* 图标/头像区域 */} {/* 图标/头像区域 */}
<View style={styles.iconContainer}> <View style={styles.iconContainer}>
{showGroupAvatar ? ( {showGroupAvatar ? (
<Avatar <View style={styles.avatarWrapper}>
source={groupAvatar || ''} <Avatar
size={avatarSize} source={groupAvatar || ''}
name={extra_data?.group_name || '群聊'} size={48}
/> name={extra_data?.group_name || '群聊'}
/>
<View style={[styles.typeIconBadge, { backgroundColor: bgColor }]}>
<MaterialCommunityIcons name={icon} size={12} color={color} />
</View>
</View>
) : showOperatorAvatar ? ( ) : showOperatorAvatar ? (
<Avatar <View style={styles.avatarWrapper}>
source={operatorAvatar || ''} <Avatar
size={avatarSize} source={operatorAvatar || ''}
name={operatorName} size={48}
onPress={onAvatarPress} 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 }]}> <View style={[styles.iconWrapper, { backgroundColor: bgColor }]}>
<MaterialCommunityIcons name={icon} size={iconSize} color={color} /> <MaterialCommunityIcons name={icon} size={24} color={color} />
</View> </View>
)} )}
</View> </View>
@@ -321,36 +430,49 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
<View style={styles.content}> <View style={styles.content}>
{/* 标题行 */} {/* 标题行 */}
<View style={styles.titleRow}> <View style={styles.titleRow}>
<Text variant="body" style={styles.title} numberOfLines={1}> <View style={styles.titleLeft}>
{title} <Text
</Text> variant="body"
<Text variant="caption" color={colors.text.secondary} style={styles.time}> 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)} {formatTime(message.created_at)}
</Text> </Text>
</View> </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} {message.content}
</Text> </Text>
{/* 附加信息 - 优先显示 target_title图标根据 target_type 区分 */} {/* 附加信息 */}
{(extra_data?.target_title || extra_data?.post_title || extra_data?.comment_preview) && {(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) && !['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 isCommentType = ['comment', 'reply'].includes(extra_data?.target_type ?? '');
const previewText = extra_data?.target_title || extra_data?.post_title || extra_data?.comment_preview; const previewText = extra_data?.target_title || extra_data?.post_title || extra_data?.comment_preview;
const iconName = isCommentType ? 'comment-outline' : 'file-document-outline'; const iconName = isCommentType ? 'comment-text-outline' : 'file-document-outline';
return ( return (
<View style={styles.extraInfo}> <View style={styles.extraInfo}>
<MaterialCommunityIcons name={iconName} size={12} color={colors.text.hint} /> <MaterialCommunityIcons name={iconName} size={12} color={colors.primary.main} />
<Text variant="caption" color={colors.text.hint} numberOfLines={1} style={styles.extraText}> <Text variant="caption" color={colors.primary.main} numberOfLines={1} style={styles.extraText}>
{previewText} {previewText}
</Text> </Text>
</View> </View>
); );
})()} })()}
{/* 操作按钮 */}
{isActionable && ( {isActionable && (
<View style={styles.actionsRow}> <View style={styles.actionsRow}>
<TouchableOpacity <TouchableOpacity
@@ -358,20 +480,22 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
onPress={() => onRequestAction?.(false)} onPress={() => onRequestAction?.(false)}
disabled={requestActionLoading} 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>
<TouchableOpacity <TouchableOpacity
style={[styles.actionBtn, styles.approveBtn]} style={[styles.actionBtn, styles.approveBtn]}
onPress={() => onRequestAction?.(true)} onPress={() => onRequestAction?.(true)}
disabled={requestActionLoading} 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> </TouchableOpacity>
</View> </View>
)} )}
</View> </View>
{/* 右侧箭头(如果有跳转) */} {/* 右侧箭头 */}
{onPress && ( {onPress && (
<View style={styles.arrowContainer}> <View style={styles.arrowContainer}>
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} /> <MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />

View File

@@ -4,10 +4,10 @@
* 新增胶囊式、分段式等现代设计风格 * 新增胶囊式、分段式等现代设计风格
*/ */
import React, { ReactNode } from 'react'; import React, { ReactNode, useMemo } from 'react';
import { View, TouchableOpacity, StyleSheet, ScrollView, Animated } from 'react-native'; import { View, TouchableOpacity, StyleSheet, ScrollView, ViewStyle, StyleProp } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; 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'; import Text from '../common/Text';
type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern'; type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern';
@@ -20,6 +20,166 @@ interface TabBarProps {
rightContent?: ReactNode; rightContent?: ReactNode;
variant?: TabBarVariant; variant?: TabBarVariant;
icons?: string[]; 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> = ({ const TabBar: React.FC<TabBarProps> = ({
@@ -30,7 +190,11 @@ const TabBar: React.FC<TabBarProps> = ({
rightContent, rightContent,
variant = 'default', variant = 'default',
icons, icons,
style,
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createTabBarStyles(colors), [colors]);
const renderTabs = () => { const renderTabs = () => {
return tabs.map((tab, index) => { return tabs.map((tab, index) => {
const isActive = index === activeIndex; const isActive = index === activeIndex;
@@ -109,11 +273,7 @@ const TabBar: React.FC<TabBarProps> = ({
style={styles.segmentedTabIcon} style={styles.segmentedTabIcon}
/> />
)} )}
<Text <Text variant="body" color={isActive ? colors.primary.main : colors.text.secondary} style={styles.segmentedTabText}>
variant="body"
color={isActive ? colors.primary.main : colors.text.secondary}
style={styles.segmentedTabText}
>
{tab} {tab}
</Text> </Text>
</View> </View>
@@ -121,7 +281,6 @@ const TabBar: React.FC<TabBarProps> = ({
); );
} }
// default variant
return ( return (
<TouchableOpacity <TouchableOpacity
key={index} key={index}
@@ -157,12 +316,8 @@ const TabBar: React.FC<TabBarProps> = ({
if (scrollable) { if (scrollable) {
return ( return (
<View style={getContainerStyle()}> <View style={[getContainerStyle(), style]}>
<ScrollView <ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.scrollableContainer}>
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.scrollableContainer}
>
{renderTabs()} {renderTabs()}
</ScrollView> </ScrollView>
{rightContent && <View style={styles.rightContent}>{rightContent}</View>} {rightContent && <View style={styles.rightContent}>{rightContent}</View>}
@@ -171,177 +326,11 @@ const TabBar: React.FC<TabBarProps> = ({
} }
return ( return (
<View style={getContainerStyle()}> <View style={[getContainerStyle(), style]}>
{renderTabs()} {renderTabs()}
{rightContent && <View style={styles.rightContent}>{rightContent}</View>} {rightContent && <View style={styles.rightContent}>{rightContent}</View>}
</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; export default TabBar;

View File

@@ -6,7 +6,7 @@
* 在宽屏下显示更大的头像和封面 * 在宽屏下显示更大的头像和封面
*/ */
import React from 'react'; import React, { useMemo } from 'react';
import { import {
View, View,
Image, Image,
@@ -15,7 +15,7 @@ import {
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; 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 { User } from '../../types';
import Text from '../common/Text'; import Text from '../common/Text';
import Button from '../common/Button'; import Button from '../common/Button';
@@ -49,7 +49,8 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
onFollowersPress, onFollowersPress,
onAvatarPress, onAvatarPress,
}) => { }) => {
// 响应式布局 const colors = useAppColors();
const styles = useMemo(() => createUserProfileHeaderStyles(colors), [colors]);
const { isWideScreen, isDesktop, width } = useResponsive(); const { isWideScreen, isDesktop, width } = useResponsive();
// 格式化数字 // 格式化数字
@@ -236,22 +237,22 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
{/* 个人信息标签 */} {/* 个人信息标签 */}
<View style={styles.metaInfo}> <View style={styles.metaInfo}>
{user.location && ( {user.location?.trim() ? (
<View style={styles.metaTag}> <View style={styles.metaTag}>
<MaterialCommunityIcons name="map-marker-outline" size={12} color={colors.primary.main} /> <MaterialCommunityIcons name="map-marker-outline" size={12} color={colors.primary.main} />
<Text variant="caption" color={colors.primary.main} style={styles.metaTagText}> <Text variant="caption" color={colors.primary.main} style={styles.metaTagText}>
{user.location} {user.location}
</Text> </Text>
</View> </View>
)} ) : null}
{user.website && ( {user.website?.trim() ? (
<View style={styles.metaTag}> <View style={styles.metaTag}>
<MaterialCommunityIcons name="link-variant" size={12} color={colors.info.main} /> <MaterialCommunityIcons name="link-variant" size={12} color={colors.info.main} />
<Text variant="caption" color={colors.info.main} style={styles.metaTagText}> <Text variant="caption" color={colors.info.main} style={styles.metaTagText}>
{user.website.replace(/^https?:\/\//, '')} {user.website.replace(/^https?:\/\//, '')}
</Text> </Text>
</View> </View>
)} ) : null}
<View style={styles.metaTag}> <View style={styles.metaTag}>
<MaterialCommunityIcons name="calendar-outline" size={12} color={colors.text.secondary} /> <MaterialCommunityIcons name="calendar-outline" size={12} color={colors.text.secondary} />
<Text variant="caption" color={colors.text.secondary} style={styles.metaTagText}> <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: { container: {
backgroundColor: colors.background.default, backgroundColor: colors.background.default,
}, },
@@ -533,7 +535,8 @@ const styles = StyleSheet.create({
alignSelf: 'center', alignSelf: 'center',
padding: spacing.sm, padding: spacing.sm,
}, },
}); });
}
// 使用 React.memo 避免不必要的重新渲染 // 使用 React.memo 避免不必要的重新渲染
const MemoizedUserProfileHeader = React.memo(UserProfileHeader); const MemoizedUserProfileHeader = React.memo(UserProfileHeader);

View File

@@ -4,7 +4,7 @@
* 风格与现代整体UI保持一致 * 风格与现代整体UI保持一致
*/ */
import React, { useCallback } from 'react'; import React, { useCallback, useMemo } from 'react';
import { import {
View, View,
TouchableOpacity, TouchableOpacity,
@@ -12,7 +12,7 @@ import {
Animated, Animated,
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; 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 { VoteOptionDTO } from '../../types';
import Text from '../common/Text'; import Text from '../common/Text';
@@ -28,6 +28,146 @@ interface VoteCardProps {
compact?: boolean; 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> = ({ const VoteCard: React.FC<VoteCardProps> = ({
options, options,
totalVotes, totalVotes,
@@ -38,7 +178,8 @@ const VoteCard: React.FC<VoteCardProps> = ({
isLoading = false, isLoading = false,
compact = false, compact = false,
}) => { }) => {
// 动画值 const colors = useAppColors();
const styles = useMemo(() => createVoteCardStyles(colors), [colors]);
const progressAnim = React.useRef(new Animated.Value(0)).current; const progressAnim = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => { React.useEffect(() => {
@@ -151,7 +292,7 @@ const VoteCard: React.FC<VoteCardProps> = ({
</TouchableOpacity> </TouchableOpacity>
</View> </View>
); );
}, [hasVoted, votedOptionId, calculatePercentage, handleVote, isLoading, progressAnim, compact]); }, [hasVoted, votedOptionId, calculatePercentage, handleVote, isLoading, progressAnim, compact, colors, styles]);
// 排序后的选项(已投票的排在前面) // 排序后的选项(已投票的排在前面)
const sortedOptions = React.useMemo(() => { 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; export default VoteCard;

View File

@@ -3,7 +3,7 @@
* 用于创建帖子时编辑投票选项 * 用于创建帖子时编辑投票选项
*/ */
import React, { useCallback } from 'react'; import React, { useCallback, useMemo } from 'react';
import { import {
View, View,
TouchableOpacity, TouchableOpacity,
@@ -11,7 +11,7 @@ import {
TextInput, TextInput,
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; 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'; import Text from '../common/Text';
interface VoteEditorProps { interface VoteEditorProps {
@@ -24,6 +24,86 @@ interface VoteEditorProps {
maxLength?: number; 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> = ({ const VoteEditor: React.FC<VoteEditorProps> = ({
options, options,
onAddOption, onAddOption,
@@ -33,6 +113,8 @@ const VoteEditor: React.FC<VoteEditorProps> = ({
minOptions = 2, minOptions = 2,
maxLength = 50, maxLength = 50,
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createVoteEditorStyles(colors), [colors]);
const validOptionsCount = options.filter(opt => opt.trim() !== '').length; const validOptionsCount = options.filter(opt => opt.trim() !== '').length;
const canAddOption = options.length < maxOptions; const canAddOption = options.length < maxOptions;
const canRemoveOption = options.length > minOptions; 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; export default VoteEditor;

View File

@@ -3,14 +3,14 @@
* 用于帖子列表中显示投票预览,类似微博风格 * 用于帖子列表中显示投票预览,类似微博风格
*/ */
import React from 'react'; import React, { useMemo } from 'react';
import { import {
View, View,
TouchableOpacity, TouchableOpacity,
StyleSheet, StyleSheet,
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; 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'; import Text from '../common/Text';
interface VotePreviewProps { interface VotePreviewProps {
@@ -19,11 +19,51 @@ interface VotePreviewProps {
onPress?: () => void; 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> = ({ const VotePreview: React.FC<VotePreviewProps> = ({
totalVotes = 0, totalVotes = 0,
optionsCount = 0, optionsCount = 0,
onPress, onPress,
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createVotePreviewStyles(colors), [colors]);
// 格式化票数 // 格式化票数
const formatVoteCount = (count: number): string => { const formatVoteCount = (count: number): string => {
if (count >= 10000) { 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; export default VotePreview;

View File

@@ -2,10 +2,20 @@
* 业务组件导出 * 业务组件导出
*/ */
// PostCard 新架构(从 PostCard 目录导出)
export { default as PostCard } from './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 CommentItem } from './CommentItem';
export { default as UserProfileHeader } from './UserProfileHeader'; export { default as UserProfileHeader } from './UserProfileHeader';
export { default as NotificationItem } from './NotificationItem';
export { default as SystemMessageItem } from './SystemMessageItem'; export { default as SystemMessageItem } from './SystemMessageItem';
export { default as SearchBar } from './SearchBar'; export { default as SearchBar } from './SearchBar';
export { default as TabBar } from './TabBar'; export { default as TabBar } from './TabBar';

View 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;

View File

@@ -5,10 +5,98 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { LinearGradient } from 'expo-linear-gradient';
import { bindDialogListener, DialogPayload } from '../../services/dialogService'; 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 AppDialogHost: React.FC = () => {
const [dialog, setDialog] = useState<DialogPayload | null>(null); const [dialog, setDialog] = useState<DialogPayload | null>(null);
const colors = useAppColors();
const styles = useMemo(() => createDialogHostStyles(colors), [colors]);
useEffect(() => { useEffect(() => {
const unbind = bindDialogListener((payload) => { const unbind = bindDialogListener((payload) => {
@@ -19,7 +107,7 @@ const AppDialogHost: React.FC = () => {
const actions = useMemo<AlertButton[]>(() => { const actions = useMemo<AlertButton[]>(() => {
if (!dialog?.actions?.length) return [{ text: '确定' }]; if (!dialog?.actions?.length) return [{ text: '确定' }];
return dialog.actions.slice(0, 3); return dialog.actions;
}, [dialog]); }, [dialog]);
const onClose = () => { 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; export default AppDialogHost;

View File

@@ -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 { Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { bindPromptListener, PromptPayload, PromptType } from '../../services/promptService'; 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 { interface PromptState extends PromptPayload {
id: number; id: number;
@@ -12,29 +12,96 @@ interface PromptState extends PromptPayload {
const DEFAULT_DURATION = 2200; const DEFAULT_DURATION = 2200;
const styleMap: Record<PromptType, { backgroundColor: string; icon: React.ComponentProps<typeof MaterialCommunityIcons>['name'] }> = { function createPromptBarStyles(colors: AppColors) {
info: { backgroundColor: '#FFFFFF', icon: 'information-outline' }, return StyleSheet.create({
success: { backgroundColor: '#FFFFFF', icon: 'check-circle-outline' }, wrapper: {
warning: { backgroundColor: '#FFFFFF', icon: 'alert-outline' }, position: 'absolute',
error: { backgroundColor: '#FFFFFF', icon: 'alert-circle-outline' }, left: spacing.md,
}; right: spacing.md,
zIndex: 9999,
const iconColorMap: Record<PromptType, string> = { },
info: colors.primary.main, card: {
success: colors.success.main, borderRadius: borderRadius.xl,
warning: colors.warning.dark, paddingVertical: spacing.md,
error: colors.error.main, paddingHorizontal: spacing.md,
}; borderWidth: 1,
borderColor: `${colors.primary.main}22`,
const accentColorMap: Record<PromptType, string> = { flexDirection: 'row',
info: colors.primary.main, alignItems: 'center',
success: colors.success.main, overflow: 'hidden',
warning: colors.warning.main, ...shadows.lg,
error: colors.error.main, },
}; 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 AppPromptBar: React.FC = () => {
const insets = useSafeAreaInsets(); 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 [prompt, setPrompt] = useState<PromptState | null>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const animation = useRef(new Animated.Value(0)).current; 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; export default AppPromptBar;

View File

@@ -4,7 +4,7 @@
* 使用 expo-image 实现内存+磁盘双级缓存,头像秒加载 * 使用 expo-image 实现内存+磁盘双级缓存,头像秒加载
*/ */
import React from 'react'; import React, { useMemo } from 'react';
import { import {
View, View,
TouchableOpacity, TouchableOpacity,
@@ -12,7 +12,7 @@ import {
ViewStyle, ViewStyle,
} from 'react-native'; } from 'react-native';
import { Image as ExpoImage } from 'expo-image'; import { Image as ExpoImage } from 'expo-image';
import { colors, borderRadius } from '../../theme'; import { borderRadius, useAppColors, type AppColors } from '../../theme';
import Text from './Text'; import Text from './Text';
type AvatarSource = string | { uri: string } | number | null; type AvatarSource = string | { uri: string } | number | null;
@@ -27,29 +27,50 @@ interface AvatarProps {
style?: ViewStyle; 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> = ({ const Avatar: React.FC<AvatarProps> = ({
source, source,
size = 40, size = 40,
name, name,
onPress, onPress,
showBadge = false, showBadge = false,
badgeColor = colors.success.main, badgeColor,
style, style,
}) => { }) => {
// 获取首字母 const colors = useAppColors();
const styles = useMemo(() => createAvatarStyles(colors), [colors]);
const resolvedBadgeColor = badgeColor ?? colors.success.main;
const getInitial = (): string => { const getInitial = (): string => {
if (!name) return '?'; if (!name) return '?';
const firstChar = name.charAt(0).toUpperCase(); const firstChar = name.charAt(0).toUpperCase();
// 中文字符
if (/[\u4e00-\u9fa5]/.test(firstChar)) { if (/[\u4e00-\u9fa5]/.test(firstChar)) {
return firstChar; return firstChar;
} }
return firstChar; return firstChar;
}; };
// 渲染头像内容
const renderAvatarContent = () => { const renderAvatarContent = () => {
// 如果有图片源
if (source) { if (source) {
const imageSource = const imageSource =
typeof source === 'string' ? { uri: source } : source; typeof source === 'string' ? { uri: source } : source;
@@ -72,7 +93,6 @@ const Avatar: React.FC<AvatarProps> = ({
); );
} }
// 显示首字母
const fontSize = size / 2; const fontSize = size / 2;
return ( return (
<View <View
@@ -100,7 +120,7 @@ const Avatar: React.FC<AvatarProps> = ({
style={[ style={[
styles.badge, styles.badge,
{ {
backgroundColor: badgeColor, backgroundColor: resolvedBadgeColor,
width: size * 0.3, width: size * 0.3,
height: size * 0.3, height: size * 0.3,
borderRadius: (size * 0.3) / 2, borderRadius: (size * 0.3) / 2,
@@ -124,23 +144,4 @@ const Avatar: React.FC<AvatarProps> = ({
return avatarContainer; 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; export default Avatar;

View File

@@ -3,7 +3,7 @@
* 支持多种变体、尺寸、加载状态和图标 * 支持多种变体、尺寸、加载状态和图标
*/ */
import React from 'react'; import React, { useMemo } from 'react';
import { import {
TouchableOpacity, TouchableOpacity,
ActivityIndicator, ActivityIndicator,
@@ -13,7 +13,7 @@ import {
TextStyle, TextStyle,
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; 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'; import Text from './Text';
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'text' | 'danger'; type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'text' | 'danger';
@@ -27,12 +27,86 @@ interface ButtonProps {
size?: ButtonSize; size?: ButtonSize;
disabled?: boolean; disabled?: boolean;
loading?: boolean; loading?: boolean;
icon?: string; // MaterialCommunityIcons name icon?: string;
iconPosition?: IconPosition; iconPosition?: IconPosition;
fullWidth?: boolean; fullWidth?: boolean;
style?: ViewStyle; 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> = ({ const Button: React.FC<ButtonProps> = ({
title, title,
onPress, onPress,
@@ -45,11 +119,12 @@ const Button: React.FC<ButtonProps> = ({
fullWidth = false, fullWidth = false,
style, style,
}) => { }) => {
// 获取按钮样式 const colors = useAppColors();
const styles = useMemo(() => createButtonStyles(colors), [colors]);
const getButtonStyle = (): ViewStyle[] => { const getButtonStyle = (): ViewStyle[] => {
const baseStyle: ViewStyle[] = [styles.base, styles[`size_${size}`]]; const baseStyle: ViewStyle[] = [styles.base, styles[`size_${size}`]];
// 变体样式
switch (variant) { switch (variant) {
case 'primary': case 'primary':
baseStyle.push(styles.primary); baseStyle.push(styles.primary);
@@ -68,12 +143,10 @@ const Button: React.FC<ButtonProps> = ({
break; break;
} }
// 禁用状态
if (disabled || loading) { if (disabled || loading) {
baseStyle.push(styles.disabled); baseStyle.push(styles.disabled);
} }
// 全宽度
if (fullWidth) { if (fullWidth) {
baseStyle.push(styles.fullWidth); baseStyle.push(styles.fullWidth);
} }
@@ -81,7 +154,6 @@ const Button: React.FC<ButtonProps> = ({
return baseStyle; return baseStyle;
}; };
// 获取文本样式
const getTextStyle = (): TextStyle => { const getTextStyle = (): TextStyle => {
const baseStyle: TextStyle = { const baseStyle: TextStyle = {
...styles.textBase, ...styles.textBase,
@@ -109,7 +181,6 @@ const Button: React.FC<ButtonProps> = ({
return baseStyle; return baseStyle;
}; };
// 获取图标大小
const getIconSize = (): number => { const getIconSize = (): number => {
switch (size) { switch (size) {
case 'sm': case 'sm':
@@ -121,7 +192,6 @@ const Button: React.FC<ButtonProps> = ({
} }
}; };
// 获取图标颜色
const getIconColor = (): string => { const getIconColor = (): string => {
if (disabled || loading) { if (disabled || loading) {
return colors.text.disabled; return colors.text.disabled;
@@ -150,9 +220,9 @@ const Button: React.FC<ButtonProps> = ({
{loading ? ( {loading ? (
<ActivityIndicator <ActivityIndicator
size="small" size="small"
color={variant === 'outline' || variant === 'text' color={
? colors.primary.main variant === 'outline' || variant === 'text' ? colors.primary.main : colors.text.inverse
: colors.text.inverse} }
/> />
) : ( ) : (
<View style={styles.content}> <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; export default Button;

View File

@@ -3,9 +3,9 @@
* 白色背景、圆角、阴影,支持点击 * 白色背景、圆角、阴影,支持点击
*/ */
import React from 'react'; import React, { useMemo } from 'react';
import { View, TouchableOpacity, StyleSheet, ViewStyle } from 'react-native'; 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'; type ShadowSize = 'sm' | 'md' | 'lg';
@@ -13,10 +13,20 @@ interface CardProps {
children: React.ReactNode; children: React.ReactNode;
onPress?: () => void; onPress?: () => void;
padding?: number; padding?: number;
shadow?: ShadowSize; shadow?: ShadowSize | 'none';
style?: ViewStyle; style?: ViewStyle;
} }
function createCardStyles(colors: AppColors) {
return StyleSheet.create({
card: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
overflow: 'hidden',
},
});
}
const Card: React.FC<CardProps> = ({ const Card: React.FC<CardProps> = ({
children, children,
onPress, onPress,
@@ -24,13 +34,11 @@ const Card: React.FC<CardProps> = ({
shadow = 'none', shadow = 'none',
style, style,
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createCardStyles(colors), [colors]);
const shadowStyle = shadow !== 'none' ? shadows[shadow as keyof typeof shadows] : undefined; const shadowStyle = shadow !== 'none' ? shadows[shadow as keyof typeof shadows] : undefined;
const cardStyle = [ const cardStyle = [styles.card, { padding }, shadowStyle].filter(Boolean);
styles.card,
{ padding },
shadowStyle,
].filter(Boolean);
if (onPress) { if (onPress) {
return ( return (
@@ -47,12 +55,4 @@ const Card: React.FC<CardProps> = ({
return <View style={[...cardStyle, style]}>{children}</View>; return <View style={[...cardStyle, style]}>{children}</View>;
}; };
const styles = StyleSheet.create({
card: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
overflow: 'hidden',
},
});
export default Card; export default Card;

View File

@@ -5,7 +5,7 @@
import React from 'react'; import React from 'react';
import { View, StyleSheet, ViewStyle } from 'react-native'; import { View, StyleSheet, ViewStyle } from 'react-native';
import { colors, spacing } from '../../theme'; import { spacing, useAppColors } from '../../theme';
interface DividerProps { interface DividerProps {
margin?: number; margin?: number;
@@ -13,16 +13,14 @@ interface DividerProps {
style?: ViewStyle; style?: ViewStyle;
} }
const Divider: React.FC<DividerProps> = ({ const Divider: React.FC<DividerProps> = ({ margin = spacing.lg, color, style }) => {
margin = spacing.lg, const colors = useAppColors();
color = colors.divider, const lineColor = color ?? colors.divider;
style,
}) => {
return ( return (
<View <View
style={[ style={[
styles.divider, styles.divider,
{ marginVertical: margin, backgroundColor: color }, { marginVertical: margin, backgroundColor: lineColor },
style, style,
]} ]}
/> />

View File

@@ -3,10 +3,10 @@
* 显示空数据时的占位界面,采用现代插图风格设计 * 显示空数据时的占位界面,采用现代插图风格设计
*/ */
import React from 'react'; import React, { useMemo } from 'react';
import { View, StyleSheet, ViewStyle, Dimensions } from 'react-native'; import { View, StyleSheet, ViewStyle } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; 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 Text from './Text';
import Button from './Button'; import Button from './Button';
@@ -20,6 +20,109 @@ interface EmptyStateProps {
variant?: 'default' | 'modern' | 'compact'; 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> = ({ const EmptyState: React.FC<EmptyStateProps> = ({
icon = 'folder-open-outline', icon = 'folder-open-outline',
title, title,
@@ -29,7 +132,9 @@ const EmptyState: React.FC<EmptyStateProps> = ({
style, style,
variant = 'modern', variant = 'modern',
}) => { }) => {
// 现代风格空状态 const colors = useAppColors();
const styles = useMemo(() => createEmptyStateStyles(colors), [colors]);
if (variant === 'modern') { if (variant === 'modern') {
return ( return (
<View style={[styles.modernContainer, style]}> <View style={[styles.modernContainer, style]}>
@@ -45,19 +150,11 @@ const EmptyState: React.FC<EmptyStateProps> = ({
<View style={styles.decorativeCircle1} /> <View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} /> <View style={styles.decorativeCircle2} />
</View> </View>
<Text <Text variant="h3" color={colors.text.primary} style={styles.modernTitle}>
variant="h3"
color={colors.text.primary}
style={styles.modernTitle}
>
{title} {title}
</Text> </Text>
{description && ( {description && (
<Text <Text variant="body" color={colors.text.secondary} style={styles.modernDescription}>
variant="body"
color={colors.text.secondary}
style={styles.modernDescription}
>
{description} {description}
</Text> </Text>
)} )}
@@ -74,7 +171,6 @@ const EmptyState: React.FC<EmptyStateProps> = ({
); );
} }
// 紧凑风格
if (variant === 'compact') { if (variant === 'compact') {
return ( return (
<View style={[styles.compactContainer, style]}> <View style={[styles.compactContainer, style]}>
@@ -84,18 +180,13 @@ const EmptyState: React.FC<EmptyStateProps> = ({
color={colors.text.disabled} color={colors.text.disabled}
style={styles.compactIcon} style={styles.compactIcon}
/> />
<Text <Text variant="body" color={colors.text.secondary} style={styles.compactTitle}>
variant="body"
color={colors.text.secondary}
style={styles.compactTitle}
>
{title} {title}
</Text> </Text>
</View> </View>
); );
} }
// 默认风格
return ( return (
<View style={[styles.container, style]}> <View style={[styles.container, style]}>
<MaterialCommunityIcons <MaterialCommunityIcons
@@ -104,19 +195,11 @@ const EmptyState: React.FC<EmptyStateProps> = ({
color={colors.text.disabled} color={colors.text.disabled}
style={styles.icon} style={styles.icon}
/> />
<Text <Text variant="h3" color={colors.text.secondary} style={styles.title}>
variant="h3"
color={colors.text.secondary}
style={styles.title}
>
{title} {title}
</Text> </Text>
{description && ( {description && (
<Text <Text variant="body" color={colors.text.secondary} style={styles.description}>
variant="body"
color={colors.text.secondary}
style={styles.description}
>
{description} {description}
</Text> </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; export default EmptyState;

View File

@@ -4,7 +4,7 @@
* 使用 expo-image原生支持 GIF/WebP 动图 * 使用 expo-image原生支持 GIF/WebP 动图
*/ */
import React, { useState, useCallback, useEffect, useMemo } from 'react'; import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import { import {
Modal, Modal,
View, View,
@@ -13,7 +13,6 @@ import {
TouchableOpacity, TouchableOpacity,
Text, Text,
StatusBar, StatusBar,
ActivityIndicator,
Alert, Alert,
} from 'react-native'; } from 'react-native';
import { Image as ExpoImage } from 'expo-image'; import { Image as ExpoImage } from 'expo-image';
@@ -25,16 +24,14 @@ import {
import Animated, { import Animated, {
useSharedValue, useSharedValue,
useAnimatedStyle, useAnimatedStyle,
useDerivedValue,
runOnJS, runOnJS,
withTiming, withTiming,
withSpring,
} from 'react-native-reanimated'; } from 'react-native-reanimated';
import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as MediaLibrary from 'expo-media-library'; import * as MediaLibrary from 'expo-media-library';
import { File, Paths } from 'expo-file-system'; 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'); const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
@@ -84,9 +81,13 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
onSave, onSave,
backgroundOpacity = 1, 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); const [currentIndex, setCurrentIndex] = useState(initialIndex);
currentIndexRef.current = currentIndex;
const [showControls, setShowControls] = useState(true); const [showControls, setShowControls] = useState(true);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false); const [error, setError] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveToast, setSaveToast] = useState<'success' | 'error' | null>(null); const [saveToast, setSaveToast] = useState<'success' | 'error' | null>(null);
@@ -127,7 +128,6 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
if (visible) { if (visible) {
setCurrentIndex(initialIndex); setCurrentIndex(initialIndex);
setShowControls(true); setShowControls(true);
setLoading(true);
setError(false); setError(false);
resetZoom(); resetZoom();
StatusBar.setHidden(true, 'fade'); StatusBar.setHidden(true, 'fade');
@@ -136,37 +136,61 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
} }
}, [visible, initialIndex, resetZoom]); }, [visible, initialIndex, resetZoom]);
// 图片变化时重置加载状态和缩放
useEffect(() => { useEffect(() => {
setLoading(true); if (visible) {
setError(false); isClosingRef.current = false;
}
}, [visible]);
useEffect(() => {
return () => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
}
};
}, []);
// 图片变化时重置缩放(加载态在 updateIndex / 打开弹窗时同步设置,避免晚一帧仍显示上一张)
useEffect(() => {
resetZoom(); resetZoom();
}, [currentImage?.id, resetZoom]); }, [currentImage?.id, resetZoom]);
const updateIndex = useCallback( const updateIndex = useCallback(
(newIndex: number) => { (newIndex: number) => {
const clampedIndex = Math.max(0, Math.min(validImages.length - 1, newIndex)); const clampedIndex = Math.max(0, Math.min(validImages.length - 1, newIndex));
if (clampedIndex === currentIndexRef.current) {
return;
}
setError(false);
setCurrentIndex(clampedIndex); setCurrentIndex(clampedIndex);
onIndexChange?.(clampedIndex); onIndexChange?.(clampedIndex);
}, },
[validImages.length, onIndexChange] [validImages.length, onIndexChange]
); );
const updateIndexFromSwipe = useCallback(
(newIndex: number, direction: -1 | 1) => {
pendingSwipeDirectionRef.current = direction;
updateIndex(newIndex);
},
[updateIndex]
);
const toggleControls = useCallback(() => { const toggleControls = useCallback(() => {
setShowControls(prev => !prev); setShowControls(prev => !prev);
}, []); }, []);
const goToPrev = useCallback(() => { const requestClose = useCallback(() => {
if (currentIndex > 0) { if (isClosingRef.current) {
updateIndex(currentIndex - 1); return;
} }
}, [currentIndex, updateIndex]);
const goToNext = useCallback(() => { isClosingRef.current = true;
if (currentIndex < validImages.length - 1) { // 延迟一个短时间窗口,避免关闭同一触摸触发到底层组件
updateIndex(currentIndex + 1); closeTimerRef.current = setTimeout(() => {
} onClose();
}, [currentIndex, validImages.length, updateIndex]); }, 120);
}, [onClose]);
// 显示短暂提示 // 显示短暂提示
const showToast = useCallback((type: 'success' | 'error') => { const showToast = useCallback((type: 'success' | 'error') => {
@@ -236,6 +260,30 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
// 滑动切换图片相关状态 // 滑动切换图片相关状态
const swipeTranslateX = useSharedValue(0); 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() const panGesture = Gesture.Pan()
.activeOffsetX([-10, 10]) // 水平方向需要移动10pt才激活避免与点击冲突 .activeOffsetX([-10, 10]) // 水平方向需要移动10pt才激活避免与点击冲突
@@ -248,16 +296,6 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
// 放大状态下:拖动图片 // 放大状态下:拖动图片
translateX.value = savedTranslateX.value + e.translationX; translateX.value = savedTranslateX.value + e.translationX;
translateY.value = savedTranslateY.value + e.translationY; 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) => { .onEnd((e) => {
@@ -275,19 +313,10 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
if (shouldGoNext && currentIndex < validImages.length - 1) { if (shouldGoNext && currentIndex < validImages.length - 1) {
// 向左滑动,显示下一张 // 向左滑动,显示下一张
swipeTranslateX.value = withTiming(-SCREEN_WIDTH, { duration: 200 }, () => { runOnJS(updateIndexFromSwipe)(currentIndex + 1, -1);
runOnJS(updateIndex)(currentIndex + 1);
swipeTranslateX.value = 0;
});
} else if (shouldGoPrev && currentIndex > 0) { } else if (shouldGoPrev && currentIndex > 0) {
// 向右滑动,显示上一张 // 向右滑动,显示上一张
swipeTranslateX.value = withTiming(SCREEN_WIDTH, { duration: 200 }, () => { runOnJS(updateIndexFromSwipe)(currentIndex - 1, 1);
runOnJS(updateIndex)(currentIndex - 1);
swipeTranslateX.value = 0;
});
} else {
// 回弹到原位
swipeTranslateX.value = withTiming(0, { duration: 200 });
} }
} }
}); });
@@ -297,7 +326,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
.numberOfTaps(1) .numberOfTaps(1)
.maxDistance(10) .maxDistance(10)
.onEnd(() => { .onEnd(() => {
runOnJS(onClose)(); runOnJS(requestClose)();
}); });
// 组合手势: // 组合手势:
@@ -326,7 +355,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
visible={visible} visible={visible}
transparent transparent
animationType="fade" animationType="fade"
onRequestClose={onClose} onRequestClose={requestClose}
statusBarTranslucent statusBarTranslucent
> >
<GestureHandlerRootView style={styles.root}> <GestureHandlerRootView style={styles.root}>
@@ -334,7 +363,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
{/* 顶部控制栏 */} {/* 顶部控制栏 */}
{showControls && ( {showControls && (
<View style={[styles.header, { paddingTop: insets.top + spacing.md }]}> <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" /> <MaterialCommunityIcons name="close" size={24} color="#FFF" />
</TouchableOpacity> </TouchableOpacity>
@@ -351,7 +380,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
disabled={saving} disabled={saving}
> >
{saving ? ( {saving ? (
<ActivityIndicator size="small" color="#FFF" /> <MaterialCommunityIcons name="loading" size={24} color="#FFF" />
) : ( ) : (
<MaterialCommunityIcons name="download" size={24} color="#FFF" /> <MaterialCommunityIcons name="download" size={24} color="#FFF" />
)} )}
@@ -363,12 +392,6 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
{/* 图片显示区域 */} {/* 图片显示区域 */}
<GestureDetector gesture={composedGesture}> <GestureDetector gesture={composedGesture}>
<View style={styles.imageContainer}> <View style={styles.imageContainer}>
{loading && (
<View style={styles.loadingContainer}>
<ActivityIndicator size="large" color="#FFF" />
</View>
)}
{error && ( {error && (
<View style={styles.errorContainer}> <View style={styles.errorContainer}>
<MaterialCommunityIcons name="image-off" size={48} color="#999" /> <MaterialCommunityIcons name="image-off" size={48} color="#999" />
@@ -384,15 +407,13 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
contentFit="contain" contentFit="contain"
cachePolicy="disk" cachePolicy="disk"
priority="high" priority="high"
recyclingKey={currentImage.id} recyclingKey={currentImage.url}
transition={null}
allowDownscaling allowDownscaling
onLoadStart={() => setLoading(true)}
onLoad={() => { onLoad={() => {
setLoading(false);
setError(false); setError(false);
}} }}
onError={() => { onError={() => {
setLoading(false);
setError(true); setError(true);
}} }}
/> />
@@ -523,16 +544,11 @@ const styles = StyleSheet.create({
width: SCREEN_WIDTH, width: SCREEN_WIDTH,
height: SCREEN_HEIGHT * 0.8, height: SCREEN_HEIGHT * 0.8,
}, },
loadingContainer: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
zIndex: 5,
},
errorContainer: { errorContainer: {
...StyleSheet.absoluteFillObject, ...StyleSheet.absoluteFillObject,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
backgroundColor: '#000',
}, },
errorText: { errorText: {
color: '#999', color: '#999',

View File

@@ -5,7 +5,7 @@
* 支持预览图优化 * 支持预览图优化
*/ */
import React, { useMemo, useCallback, useState, useEffect } from 'react'; import React, { useMemo, useCallback, useState, useEffect, createContext, useContext } from 'react';
import { import {
View, View,
StyleSheet, StyleSheet,
@@ -15,8 +15,8 @@ import {
Text, Text,
Image, Image,
} from 'react-native'; } from 'react-native';
import { SmartImage, ImageSource } from './SmartImage'; import { SmartImage } from './SmartImage';
import { colors, spacing, borderRadius } from '../../theme'; import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper'; import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper';
const { width: SCREEN_WIDTH } = Dimensions.get('window'); const { width: SCREEN_WIDTH } = Dimensions.get('window');
@@ -40,6 +40,8 @@ export interface ImageGridItem {
url?: string; url?: string;
thumbnailUrl?: string; thumbnailUrl?: string;
thumbnail_url?: string; thumbnail_url?: string;
preview_url?: string;
preview_url_large?: string;
width?: number; width?: number;
height?: 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 管理异步加载到的实际尺寸 // 独立成组件,方便用 useState 管理异步加载到的实际尺寸
@@ -108,6 +193,8 @@ interface SingleImageItemProps {
maxHeight: number; maxHeight: number;
borderRadiusValue: number; borderRadiusValue: number;
onPress: () => void; onPress: () => void;
usePreview: boolean;
displayMode: ImageDisplayMode;
} }
const SingleImageItem: React.FC<SingleImageItemProps> = ({ const SingleImageItem: React.FC<SingleImageItemProps> = ({
@@ -116,23 +203,35 @@ const SingleImageItem: React.FC<SingleImageItemProps> = ({
maxHeight, maxHeight,
borderRadiusValue, borderRadiusValue,
onPress, onPress,
usePreview,
displayMode,
}) => { }) => {
const styles = useImageGridStyles();
const [aspectRatio, setAspectRatio] = useState<number | null>(null); 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(() => { 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; let cancelled = false;
// 添加超时处理,防止高分辨率图片加载卡住
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
if (!cancelled && aspectRatio === null) { if (!cancelled && aspectRatio === null) {
setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO); setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO);
} }
}, 3000); }, 3000);
Image.getSize( Image.getSize(
uri, probeUri,
(w, h) => { (w, h) => {
if (!cancelled) { if (!cancelled) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
@@ -146,11 +245,11 @@ const SingleImageItem: React.FC<SingleImageItemProps> = ({
} }
}, },
); );
return () => { return () => {
cancelled = true; cancelled = true;
clearTimeout(timeoutId); clearTimeout(timeoutId);
}; };
}, [uri]); }, [image.width, image.height, thumbnailUri, mainUri]);
const effectiveContainerWidth = containerWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING; 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); 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 ( return (
<Pressable <Pressable
onPress={onPress} onPress={onPress}
style={[styles.singleContainer, { width, height, borderRadius: borderRadiusValue }]} style={[styles.singleContainer, { width, height, borderRadius: borderRadiusValue }]}
> >
<SmartImage <SmartImage
source={{ uri }} source={{ uri: mainUri }}
previewUrl={useSmartPreview ? previewForSmart : undefined}
usePreview={useSmartPreview}
style={styles.fullSize} style={styles.fullSize}
resizeMode="cover" resizeMode="cover"
borderRadius={borderRadiusValue} borderRadius={borderRadiusValue}
@@ -224,7 +329,8 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
displayMode = 'list', displayMode = 'list',
usePreview = true, usePreview = true,
}) => { }) => {
// 通过 onLayout 拿到容器实际宽度 const colors = useAppColors();
const gridStyles = useMemo(() => createImageGridStyles(colors), [colors]);
const [containerWidth, setContainerWidth] = useState(0); const [containerWidth, setContainerWidth] = useState(0);
// 过滤有效图片 - 支持 uri 或 url 字段 // 过滤有效图片 - 支持 uri 或 url 字段
@@ -265,12 +371,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
const renderSingleImage = () => { const renderSingleImage = () => {
const image = displayImages[0]; const image = displayImages[0];
if (!image) return null; if (!image) return null;
// 获取预览图 URL
const previewUrl = usePreview && displayMode
? getPreviewImageUrl(image as any, displayMode)
: (image.uri || image.url || '');
return ( return (
<SingleImageItem <SingleImageItem
image={image} image={image}
@@ -278,6 +379,8 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
maxHeight={singleImageMaxHeight} maxHeight={singleImageMaxHeight}
borderRadiusValue={borderRadiusValue} borderRadiusValue={borderRadiusValue}
onPress={() => handleImagePress(0)} onPress={() => handleImagePress(0)}
usePreview={usePreview}
displayMode={displayMode}
/> />
); );
}; };
@@ -285,7 +388,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
// 渲染横向双图 // 渲染横向双图
const renderHorizontal = () => { const renderHorizontal = () => {
return ( return (
<View style={[styles.horizontalContainer, { gap }]}> <View style={[gridStyles.horizontalContainer, { gap }]}>
{displayImages.map((image, index) => { {displayImages.map((image, index) => {
const previewUrl = usePreview && displayMode const previewUrl = usePreview && displayMode
? getPreviewImageUrl(image as any, displayMode) ? getPreviewImageUrl(image as any, displayMode)
@@ -296,7 +399,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
key={image.id || index} key={image.id || index}
onPress={() => handleImagePress(index)} onPress={() => handleImagePress(index)}
style={[ style={[
styles.horizontalItem, gridStyles.horizontalItem,
{ {
flex: 1, flex: 1,
aspectRatio: 1, aspectRatio: 1,
@@ -306,7 +409,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
> >
<SmartImage <SmartImage
source={{ uri: previewUrl }} source={{ uri: previewUrl }}
style={styles.fullSize} style={gridStyles.fullSize}
resizeMode="cover" resizeMode="cover"
borderRadius={borderRadiusValue} borderRadius={borderRadiusValue}
/> />
@@ -320,7 +423,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
// 渲染网格布局 // 渲染网格布局
const renderGrid = () => { const renderGrid = () => {
return ( return (
<View style={[styles.gridContainer, { gap }]}> <View style={[gridStyles.gridContainer, { gap }]}>
{displayImages.map((image, index) => { {displayImages.map((image, index) => {
const isLastVisible = index === displayImages.length - 1; const isLastVisible = index === displayImages.length - 1;
const showOverlay = isLastVisible && remainingCount > 0 && showMoreOverlay; const showOverlay = isLastVisible && remainingCount > 0 && showMoreOverlay;
@@ -334,8 +437,8 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
key={image.id || index} key={image.id || index}
onPress={() => handleImagePress(index)} onPress={() => handleImagePress(index)}
style={[ style={[
styles.gridItem, gridStyles.gridItem,
gridColumns === 3 ? styles.gridItem3 : styles.gridItem2, gridColumns === 3 ? gridStyles.gridItem3 : gridStyles.gridItem2,
{ {
borderRadius: borderRadiusValue, borderRadius: borderRadiusValue,
}, },
@@ -343,13 +446,13 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
> >
<SmartImage <SmartImage
source={{ uri: previewUrl }} source={{ uri: previewUrl }}
style={styles.fullSize} style={gridStyles.fullSize}
resizeMode="cover" resizeMode="cover"
borderRadius={borderRadiusValue} borderRadius={borderRadiusValue}
/> />
{showOverlay && ( {showOverlay && (
<View style={styles.moreOverlay}> <View style={gridStyles.moreOverlay}>
<Text style={styles.moreText}>+{remainingCount}</Text> <Text style={gridStyles.moreText}>+{remainingCount}</Text>
</View> </View>
)} )}
</Pressable> </Pressable>
@@ -379,7 +482,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
const renderColumn = (columnImages: ImageGridItem[], columnIndex: number) => { const renderColumn = (columnImages: ImageGridItem[], columnIndex: number) => {
return ( return (
<View style={[styles.masonryColumn, { gap }]}> <View style={[gridStyles.masonryColumn, { gap }]}>
{columnImages.map((image, index) => { {columnImages.map((image, index) => {
const actualIndex = columnIndex + index * 2; const actualIndex = columnIndex + index * 2;
const aspectRatio = image.width && image.height const aspectRatio = image.width && image.height
@@ -396,7 +499,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
key={image.id || actualIndex} key={image.id || actualIndex}
onPress={() => handleImagePress(actualIndex)} onPress={() => handleImagePress(actualIndex)}
style={[ style={[
styles.masonryItem, gridStyles.masonryItem,
{ {
width: itemWidth, width: itemWidth,
height: Math.max(height, itemWidth * 0.7), height: Math.max(height, itemWidth * 0.7),
@@ -406,7 +509,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
> >
<SmartImage <SmartImage
source={{ uri: previewUrl }} source={{ uri: previewUrl }}
style={styles.fullSize} style={gridStyles.fullSize}
resizeMode="cover" resizeMode="cover"
borderRadius={borderRadiusValue} borderRadius={borderRadiusValue}
/> />
@@ -418,7 +521,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
}; };
return ( return (
<View style={[styles.masonryContainer, { gap }]}> <View style={[gridStyles.masonryContainer, { gap }]}>
{renderColumn(leftColumn, 0)} {renderColumn(leftColumn, 0)}
{renderColumn(rightColumn, 1)} {renderColumn(rightColumn, 1)}
</View> </View>
@@ -446,13 +549,15 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
} }
return ( return (
<View <ImageGridStylesContext.Provider value={gridStyles}>
style={[styles.container, style]} <View
testID={testID} style={[gridStyles.container, style]}
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)} testID={testID}
> onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}
{renderContent()} >
</View> {renderContent()}
</View>
</ImageGridStylesContext.Provider>
); );
}; };
@@ -472,6 +577,8 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
borderRadius: borderRadiusValue = borderRadius.sm, borderRadius: borderRadiusValue = borderRadius.sm,
...props ...props
}) => { }) => {
const colors = useAppColors();
const gridStyles = useMemo(() => createImageGridStyles(colors), [colors]);
const containerWidth = maxWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING - 36 - spacing.sm; // 36是头像宽度 const containerWidth = maxWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING - 36 - spacing.sm; // 36是头像宽度
const renderCompactGrid = () => { const renderCompactGrid = () => {
@@ -488,7 +595,7 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
<Pressable <Pressable
onPress={() => props.onImagePress?.(images, 0)} onPress={() => props.onImagePress?.(images, 0)}
style={[ style={[
styles.compactItem, gridStyles.compactItem,
{ {
width: size, width: size,
height: size, height: size,
@@ -498,7 +605,7 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
> >
<SmartImage <SmartImage
source={{ uri: image.uri || image.url, width: image.width, height: image.height }} source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
style={styles.fullSize} style={gridStyles.fullSize}
resizeMode="cover" resizeMode="cover"
borderRadius={borderRadiusValue} borderRadius={borderRadiusValue}
/> />
@@ -511,13 +618,13 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
const { itemSize } = calculateGridDimensions(count, containerWidth, gap, columns); const { itemSize } = calculateGridDimensions(count, containerWidth, gap, columns);
return ( return (
<View style={[styles.compactGrid, { gap }]}> <View style={[gridStyles.compactGrid, { gap }]}>
{images.slice(0, 6).map((image, index) => ( {images.slice(0, 6).map((image, index) => (
<Pressable <Pressable
key={image.id || index} key={image.id || index}
onPress={() => props.onImagePress?.(images, index)} onPress={() => props.onImagePress?.(images, index)}
style={[ style={[
styles.compactItem, gridStyles.compactItem,
{ {
width: itemSize, width: itemSize,
height: itemSize, height: itemSize,
@@ -527,13 +634,13 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
> >
<SmartImage <SmartImage
source={{ uri: image.uri || image.url, width: image.width, height: image.height }} source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
style={styles.fullSize} style={gridStyles.fullSize}
resizeMode="cover" resizeMode="cover"
borderRadius={borderRadiusValue} borderRadius={borderRadiusValue}
/> />
{index === 5 && images.length > 6 && ( {index === 5 && images.length > 6 && (
<View style={styles.moreOverlay}> <View style={gridStyles.moreOverlay}>
<Text style={styles.moreText}>+{images.length - 6}</Text> <Text style={gridStyles.moreText}>+{images.length - 6}</Text>
</View> </View>
)} )}
</Pressable> </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; export default ImageGrid;

View File

@@ -3,7 +3,7 @@
* 支持标签、错误提示、图标、多行输入等 * 支持标签、错误提示、图标、多行输入等
*/ */
import React, { useState } from 'react'; import React, { useMemo, useState } from 'react';
import { import {
View, View,
TextInput, TextInput,
@@ -13,7 +13,7 @@ import {
TextStyle, TextStyle,
} from 'react-native'; } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; 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'; import Text from './Text';
interface InputProps { interface InputProps {
@@ -36,6 +36,46 @@ interface InputProps {
autoCorrect?: boolean; 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> = ({ const Input: React.FC<InputProps> = ({
value, value,
onChangeText, onChangeText,
@@ -55,6 +95,8 @@ const Input: React.FC<InputProps> = ({
keyboardType = 'default', keyboardType = 'default',
autoCorrect = true, autoCorrect = true,
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createInputStyles(colors), [colors]);
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const getBorderColor = () => { const getBorderColor = () => {
@@ -87,11 +129,7 @@ const Input: React.FC<InputProps> = ({
/> />
)} )}
<TextInput <TextInput
style={[ style={[styles.input, multiline && styles.multilineInput, inputStyle]}
styles.input,
multiline && styles.multilineInput,
inputStyle,
]}
value={value} value={value}
onChangeText={onChangeText} onChangeText={onChangeText}
placeholder={placeholder} placeholder={placeholder}
@@ -105,15 +143,11 @@ const Input: React.FC<InputProps> = ({
autoCapitalize={autoCapitalize} autoCapitalize={autoCapitalize}
keyboardType={keyboardType} keyboardType={keyboardType}
autoCorrect={autoCorrect} autoCorrect={autoCorrect}
// 确保光标可见
cursorColor={colors.primary.main} cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`} selectionColor={`${colors.primary.main}40`}
/> />
{rightIcon && ( {rightIcon && (
<TouchableOpacity <TouchableOpacity onPress={onRightIconPress} disabled={!onRightIconPress}>
onPress={onRightIconPress}
disabled={!onRightIconPress}
>
<MaterialCommunityIcons <MaterialCommunityIcons
name={rightIcon as any} name={rightIcon as any}
size={20} 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; export default Input;

View File

@@ -3,9 +3,9 @@
* 支持不同尺寸、全屏模式 * 支持不同尺寸、全屏模式
*/ */
import React from 'react'; import React, { useMemo } from 'react';
import { View, ActivityIndicator, StyleSheet, ViewStyle } from 'react-native'; import { View, ActivityIndicator, StyleSheet, ViewStyle } from 'react-native';
import { colors } from '../../theme'; import { useAppColors, type AppColors } from '../../theme';
type LoadingSize = 'sm' | 'md' | 'lg'; type LoadingSize = 'sm' | 'md' | 'lg';
@@ -16,12 +16,32 @@ interface LoadingProps {
style?: ViewStyle; 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> = ({ const Loading: React.FC<LoadingProps> = ({
size = 'md', size = 'md',
color = colors.primary.main, color,
fullScreen = false, fullScreen = false,
style, style,
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createLoadingStyles(colors), [colors]);
const indicatorColor = color ?? colors.primary.main;
const getSize = (): 'small' | 'large' | undefined => { const getSize = (): 'small' | 'large' | undefined => {
switch (size) { switch (size) {
case 'sm': case 'sm':
@@ -36,30 +56,16 @@ const Loading: React.FC<LoadingProps> = ({
if (fullScreen) { if (fullScreen) {
return ( return (
<View style={[styles.fullScreen, style]}> <View style={[styles.fullScreen, style]}>
<ActivityIndicator size={getSize()} color={color} /> <ActivityIndicator size={getSize()} color={indicatorColor} />
</View> </View>
); );
} }
return ( return (
<View style={[styles.container, style]}> <View style={[styles.container, style]}>
<ActivityIndicator size={getSize()} color={color} /> <ActivityIndicator size={getSize()} color={indicatorColor} />
</View> </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; export default Loading;

View 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;

View File

@@ -4,7 +4,7 @@
* 基于 expo-image 封装,原生支持 GIF/WebP 动图 * 基于 expo-image 封装,原生支持 GIF/WebP 动图
*/ */
import React, { useState, useCallback, useRef, useEffect } from 'react'; import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { import {
View, View,
StyleSheet, StyleSheet,
@@ -13,10 +13,40 @@ import {
ActivityIndicator, ActivityIndicator,
Pressable, Pressable,
StyleProp, StyleProp,
Platform,
} from 'react-native'; } from 'react-native';
import { Image as ExpoImage } from 'expo-image'; import { Image as ExpoImage } from 'expo-image';
import { MaterialCommunityIcons } from '@expo/vector-icons'; 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'; export type ImageLoadState = 'loading' | 'success' | 'error';
@@ -61,6 +91,8 @@ export interface SmartImageProps {
usePreview?: boolean; usePreview?: boolean;
/** 是否懒加载 */ /** 是否懒加载 */
lazyLoad?: boolean; lazyLoad?: boolean;
/** Web 端 IntersectionObserver rootMargin略提前加载进入视口附近的图 */
lazyRootMargin?: string;
/** 缓存策略 */ /** 缓存策略 */
cachePolicy?: 'memory' | 'disk' | 'memory-disk' | 'none'; cachePolicy?: 'memory' | 'disk' | 'memory-disk' | 'none';
} }
@@ -85,11 +117,15 @@ export const SmartImage: React.FC<SmartImageProps> = ({
previewUrl, previewUrl,
usePreview = false, usePreview = false,
lazyLoad = false, lazyLoad = false,
lazyRootMargin = '160px',
cachePolicy = 'memory-disk', cachePolicy = 'memory-disk',
}) => { }) => {
const colors = useAppColors();
const styles = useMemo(() => createSmartImageStyles(colors), [colors]);
const [loadState, setLoadState] = useState<ImageLoadState>('loading'); 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 [shouldLoadOriginal, setShouldLoadOriginal] = useState(false);
const lazyTargetRef = useRef<View>(null);
// 解析图片源 - 支持 uri 或 url 字段 // 解析图片源 - 支持 uri 或 url 字段
const imageUri = typeof source === 'string' const imageUri = typeof source === 'string'
@@ -99,6 +135,39 @@ export const SmartImage: React.FC<SmartImageProps> = ({
// 解析预览图 URL // 解析预览图 URL
const previewUri = typeof previewUrl === 'string' ? previewUrl : ''; 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 // 智能选择图片 URL
const getImageUrl = useCallback((): string => { const getImageUrl = useCallback((): string => {
// 如果应该加载原图(预览图已加载完成或没有预览图) // 如果应该加载原图(预览图已加载完成或没有预览图)
@@ -200,11 +269,13 @@ export const SmartImage: React.FC<SmartImageProps> = ({
); );
} }
// 如果是懒加载且不可见,显示占位 // 如果是懒加载且不可见,显示占位Web 上由 IntersectionObserver 驱动进入视口后再加载)
if (lazyLoad && !isVisible) { if (lazyLoad && !isVisible) {
return ( return (
<View <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} testID={testID}
/> />
); );
@@ -288,36 +359,15 @@ export const VariantImage: React.FC<ImageVariantProps> = ({
<SmartImage <SmartImage
{...props} {...props}
style={finalStyle} style={finalStyle}
imageStyle={[styles.variantImage, imageStyle]} imageStyle={[variantStyles.variantImage, imageStyle]}
/> />
); );
}; };
const styles = StyleSheet.create({ const variantStyles = StyleSheet.create({
image: {
flex: 1,
width: '100%',
height: '100%',
},
variantImage: { variantImage: {
flex: 1, 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; export default SmartImage;

View File

@@ -5,7 +5,7 @@
import React from 'react'; import React from 'react';
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle } from 'react-native'; 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'; type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
@@ -60,6 +60,7 @@ const Text: React.FC<CustomTextProps> = ({
style, style,
...props ...props
}) => { }) => {
const colors = useAppColors();
const textStyle = [ const textStyle = [
styles.base, styles.base,
variantStyles[variant], variantStyles[variant],

View File

@@ -16,6 +16,7 @@ export { default as ResponsiveContainer } from './ResponsiveContainer';
export { default as ResponsiveGrid } from './ResponsiveGrid'; export { default as ResponsiveGrid } from './ResponsiveGrid';
export { default as ResponsiveStack, HStack, VStack } from './ResponsiveStack'; export { default as ResponsiveStack, HStack, VStack } from './ResponsiveStack';
export { default as AdaptiveLayout, SidebarLayout } from './AdaptiveLayout'; export { default as AdaptiveLayout, SidebarLayout } from './AdaptiveLayout';
export { default as AppBackButton } from './AppBackButton';
// 图片相关组件 // 图片相关组件
export { default as SmartImage } from './SmartImage'; export { default as SmartImage } from './SmartImage';

View 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
View 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);
};

View 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;

File diff suppressed because it is too large Load Diff

View 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();

View 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();

View 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();

View 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;

View File

@@ -0,0 +1,9 @@
/**
* 数据源导出
* 统一导出所有数据源实现
*/
export * from './interfaces';
export * from './ApiDataSource';
export * from './LocalDataSource';
export * from './CacheDataSource';

Some files were not shown because too many files have changed in this diff Show More