39 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
222 changed files with 22922 additions and 15835 deletions

View File

@@ -94,7 +94,7 @@ 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:
@@ -104,6 +104,7 @@ jobs:
NODE_ENV: "production" NODE_ENV: "production"
NDK_NUM_JOBS: "4" NDK_NUM_JOBS: "4"
CMAKE_BUILD_PARALLEL_LEVEL: "4" CMAKE_BUILD_PARALLEL_LEVEL: "4"
GRADLE_USER_HOME: /root/.gradle
permissions: permissions:
contents: read contents: read
steps: steps:
@@ -122,7 +123,28 @@ 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
@@ -292,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/

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

File diff suppressed because it is too large Load Diff

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

3116
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,977 +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;
const handleContainerPress = () => {
onPress();
};
const handleImagePress = (e: any) => {
e.stopPropagation();
onImagePress?.(post.images || [], 0);
};
const handleUserPress = (e: any) => {
e.stopPropagation();
onUserPress();
};
return (
<TouchableOpacity
style={[
styles.gridContainer,
!hasImage && styles.gridContainerNoImage,
isDesktop && styles.gridContainerDesktop
]}
onPress={handleContainerPress}
activeOpacity={0.9}
>
{/* 封面图 - 只有有图片时才渲染,无图时不显示占位区域 */}
{hasImage && (
<TouchableOpacity
activeOpacity={0.8}
onPress={handleImagePress}
>
<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={handleUserPress}>
<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]);
const handleContainerPress = () => {
onPress();
};
const handleUserPress = (e: any) => {
e.stopPropagation();
onUserPress();
};
const handleLikePress = (e: any) => {
e.stopPropagation();
onLike();
};
const handleCommentPress = (e: any) => {
e.stopPropagation();
onComment();
};
const handleSharePress = (e: any) => {
e.stopPropagation();
onShare();
};
const handleBookmarkPress = (e: any) => {
e.stopPropagation();
onBookmark();
};
const handleDeletePress = (e: any) => {
e.stopPropagation();
handleDelete();
};
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={handleContainerPress}
activeOpacity={0.9}
>
{/* 用户信息 */}
<View style={styles.userSection}>
<TouchableOpacity onPress={handleUserPress}>
<Avatar
source={author.avatar}
size={avatarSize}
name={author.nickname}
/>
</TouchableOpacity>
<View style={styles.userInfo}>
<View style={styles.userNameRow}>
<TouchableOpacity onPress={handleUserPress}>
<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={handleDeletePress}
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={handleLikePress}>
<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={handleCommentPress}>
<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={handleSharePress}>
<MaterialCommunityIcons
name="share-outline"
size={isDesktop ? 22 : 19}
color={colors.text.secondary}
/>
</TouchableOpacity>
<TouchableOpacity style={[styles.actionButton, isDesktop && styles.actionButtonWide]} onPress={handleBookmarkPress}>
<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,69 +18,8 @@ interface SearchBarProps {
autoFocus?: boolean; autoFocus?: boolean;
} }
const SearchBar: React.FC<SearchBarProps> = ({ function createSearchBarStyles(colors: AppColors) {
value, return StyleSheet.create({
onChangeText,
onSubmit,
placeholder = '搜索...',
onFocus,
onBlur,
autoFocus = false,
}) => {
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>
);
};
const styles = StyleSheet.create({
container: { container: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -89,8 +28,7 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.xs, paddingHorizontal: spacing.xs,
height: 46, height: 46,
borderWidth: 1, borderWidth: 1,
borderColor: '#E7E7E7', borderColor: colors.divider,
// 减少常态阴影,避免看起来像"黑框"
shadowColor: 'transparent', shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0, shadowOpacity: 0,
@@ -98,9 +36,7 @@ const styles = StyleSheet.create({
elevation: 0, elevation: 0,
}, },
containerFocused: { containerFocused: {
// 焦点时使用更柔和的边框颜色
borderColor: colors.primary.main, borderColor: colors.primary.main,
// 不添加额外的阴影,保持简洁
shadowColor: 'transparent', shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0, shadowOpacity: 0,
@@ -136,6 +72,66 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: '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; 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 ? (
<View style={styles.avatarWrapper}>
<Avatar <Avatar
source={groupAvatar || ''} source={groupAvatar || ''}
size={avatarSize} size={48}
name={extra_data?.group_name || '群聊'} name={extra_data?.group_name || '群聊'}
/> />
<View style={[styles.typeIconBadge, { backgroundColor: bgColor }]}>
<MaterialCommunityIcons name={icon} size={12} color={color} />
</View>
</View>
) : showOperatorAvatar ? ( ) : showOperatorAvatar ? (
<View style={styles.avatarWrapper}>
<Avatar <Avatar
source={operatorAvatar || ''} source={operatorAvatar || ''}
size={avatarSize} size={48}
name={operatorName} name={operatorName}
onPress={onAvatarPress} 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}>
<Text
variant="body"
style={[styles.title, ...(isUnread ? [styles.unreadTitle] : [])]}
numberOfLines={1}
>
{title} {title}
</Text> </Text>
<Text variant="caption" color={colors.text.secondary} style={styles.time}> {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,208 +28,8 @@ interface VoteCardProps {
compact?: boolean; compact?: boolean;
} }
const VoteCard: React.FC<VoteCardProps> = ({ function createVoteCardStyles(colors: AppColors) {
options, return StyleSheet.create({
totalVotes,
hasVoted,
votedOptionId,
onVote,
onUnvote,
isLoading = false,
compact = false,
}) => {
// 动画值
const progressAnim = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
Animated.timing(progressAnim, {
toValue: 1,
duration: 400,
useNativeDriver: false,
}).start();
}, [hasVoted, totalVotes]);
// 计算百分比
const calculatePercentage = useCallback((votes: number): number => {
if (totalVotes === 0) return 0;
return Math.round((votes / totalVotes) * 100);
}, [totalVotes]);
// 格式化票数
const formatVoteCount = useCallback((count: number): string => {
if (count >= 10000) {
return (count / 10000).toFixed(1) + '万';
}
if (count >= 1000) {
return (count / 1000).toFixed(1) + 'k';
}
return count.toString();
}, []);
// 处理投票
const handleVote = useCallback((optionId: string) => {
if (isLoading || hasVoted) return;
onVote(optionId);
}, [isLoading, hasVoted, onVote]);
// 处理取消投票
const handleUnvote = useCallback(() => {
if (isLoading || !hasVoted) return;
onUnvote();
}, [isLoading, hasVoted, onUnvote]);
// 渲染投票选项
const renderOption = useCallback((option: VoteOptionDTO, index: number) => {
const isVotedOption = votedOptionId === option.id;
const percentage = calculatePercentage(option.votes_count);
const showResults = hasVoted;
return (
<View key={option.id} style={styles.optionContainer}>
{/* 进度条背景 */}
{showResults && (
<Animated.View
style={[
styles.progressBar,
{
width: progressAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0%', `${percentage}%`],
}),
backgroundColor: isVotedOption
? colors.primary.light + '40'
: colors.background.disabled,
},
]}
/>
)}
{/* 选项按钮 */}
<TouchableOpacity
style={[
styles.optionButton,
isVotedOption && styles.optionButtonVoted,
]}
onPress={() => handleVote(option.id)}
disabled={isLoading || hasVoted}
activeOpacity={hasVoted ? 1 : 0.8}
>
{/* 选择指示器 */}
<View style={[
styles.optionIndicator,
isVotedOption && styles.optionIndicatorVoted,
]}>
{isVotedOption && (
<MaterialCommunityIcons
name="check"
size={12}
color={colors.primary.contrast}
/>
)}
</View>
{/* 选项内容 */}
<Text
variant={compact ? 'caption' : 'body'}
style={compact ? [styles.optionText, styles.optionTextCompact] : styles.optionText}
numberOfLines={compact ? 1 : 2}
>
{option.content}
</Text>
{/* 投票结果 */}
{showResults && (
<View style={styles.resultContainer}>
<Text
variant="caption"
style={isVotedOption ? [styles.percentage, styles.percentageVoted] : styles.percentage}
>
{percentage}%
</Text>
</View>
)}
</TouchableOpacity>
</View>
);
}, [hasVoted, votedOptionId, calculatePercentage, handleVote, isLoading, progressAnim, compact]);
// 排序后的选项(已投票的排在前面)
const sortedOptions = React.useMemo(() => {
if (!hasVoted) return options;
return [...options].sort((a, b) => {
if (a.id === votedOptionId) return -1;
if (b.id === votedOptionId) return 1;
return b.votes_count - a.votes_count;
});
}, [options, hasVoted, votedOptionId]);
return (
<View style={[styles.container, compact && styles.containerCompact]}>
{/* 投票图标和标题 */}
<View style={styles.header}>
<View style={styles.headerIcon}>
<MaterialCommunityIcons
name="vote"
size={compact ? 14 : 16}
color={colors.primary.main}
/>
</View>
<Text variant={compact ? 'caption' : 'body'} style={styles.headerTitle}>
</Text>
</View>
{/* 投票选项列表 */}
<View style={styles.optionsList}>
{sortedOptions.map((option, index) => renderOption(option, index))}
</View>
{/* 底部信息栏 */}
<View style={styles.footer}>
<View style={styles.footerLeft}>
<MaterialCommunityIcons
name="account-group-outline"
size={14}
color={colors.text.hint}
/>
<Text variant="caption" color={colors.text.hint} style={styles.footerText}>
{formatVoteCount(totalVotes)}
</Text>
</View>
{hasVoted && (
<TouchableOpacity
style={styles.unvoteButton}
onPress={handleUnvote}
disabled={isLoading}
>
<MaterialCommunityIcons
name="refresh"
size={14}
color={colors.text.hint}
/>
<Text variant="caption" color={colors.text.hint} style={styles.unvoteText}>
</Text>
</TouchableOpacity>
)}
</View>
{/* 加载遮罩 */}
{isLoading && (
<View style={styles.loadingOverlay}>
<MaterialCommunityIcons
name="loading"
size={24}
color={colors.primary.main}
/>
</View>
)}
</View>
);
};
const styles = StyleSheet.create({
container: { container: {
backgroundColor: colors.background.paper, backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg, borderRadius: borderRadius.lg,
@@ -365,6 +165,209 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
borderRadius: borderRadius.lg, borderRadius: borderRadius.lg,
}, },
}); });
}
const VoteCard: React.FC<VoteCardProps> = ({
options,
totalVotes,
hasVoted,
votedOptionId,
onVote,
onUnvote,
isLoading = false,
compact = false,
}) => {
const colors = useAppColors();
const styles = useMemo(() => createVoteCardStyles(colors), [colors]);
const progressAnim = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
Animated.timing(progressAnim, {
toValue: 1,
duration: 400,
useNativeDriver: false,
}).start();
}, [hasVoted, totalVotes]);
// 计算百分比
const calculatePercentage = useCallback((votes: number): number => {
if (totalVotes === 0) return 0;
return Math.round((votes / totalVotes) * 100);
}, [totalVotes]);
// 格式化票数
const formatVoteCount = useCallback((count: number): string => {
if (count >= 10000) {
return (count / 10000).toFixed(1) + '万';
}
if (count >= 1000) {
return (count / 1000).toFixed(1) + 'k';
}
return count.toString();
}, []);
// 处理投票
const handleVote = useCallback((optionId: string) => {
if (isLoading || hasVoted) return;
onVote(optionId);
}, [isLoading, hasVoted, onVote]);
// 处理取消投票
const handleUnvote = useCallback(() => {
if (isLoading || !hasVoted) return;
onUnvote();
}, [isLoading, hasVoted, onUnvote]);
// 渲染投票选项
const renderOption = useCallback((option: VoteOptionDTO, index: number) => {
const isVotedOption = votedOptionId === option.id;
const percentage = calculatePercentage(option.votes_count);
const showResults = hasVoted;
return (
<View key={option.id} style={styles.optionContainer}>
{/* 进度条背景 */}
{showResults && (
<Animated.View
style={[
styles.progressBar,
{
width: progressAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0%', `${percentage}%`],
}),
backgroundColor: isVotedOption
? colors.primary.light + '40'
: colors.background.disabled,
},
]}
/>
)}
{/* 选项按钮 */}
<TouchableOpacity
style={[
styles.optionButton,
isVotedOption && styles.optionButtonVoted,
]}
onPress={() => handleVote(option.id)}
disabled={isLoading || hasVoted}
activeOpacity={hasVoted ? 1 : 0.8}
>
{/* 选择指示器 */}
<View style={[
styles.optionIndicator,
isVotedOption && styles.optionIndicatorVoted,
]}>
{isVotedOption && (
<MaterialCommunityIcons
name="check"
size={12}
color={colors.primary.contrast}
/>
)}
</View>
{/* 选项内容 */}
<Text
variant={compact ? 'caption' : 'body'}
style={compact ? [styles.optionText, styles.optionTextCompact] : styles.optionText}
numberOfLines={compact ? 1 : 2}
>
{option.content}
</Text>
{/* 投票结果 */}
{showResults && (
<View style={styles.resultContainer}>
<Text
variant="caption"
style={isVotedOption ? [styles.percentage, styles.percentageVoted] : styles.percentage}
>
{percentage}%
</Text>
</View>
)}
</TouchableOpacity>
</View>
);
}, [hasVoted, votedOptionId, calculatePercentage, handleVote, isLoading, progressAnim, compact, colors, styles]);
// 排序后的选项(已投票的排在前面)
const sortedOptions = React.useMemo(() => {
if (!hasVoted) return options;
return [...options].sort((a, b) => {
if (a.id === votedOptionId) return -1;
if (b.id === votedOptionId) return 1;
return b.votes_count - a.votes_count;
});
}, [options, hasVoted, votedOptionId]);
return (
<View style={[styles.container, compact && styles.containerCompact]}>
{/* 投票图标和标题 */}
<View style={styles.header}>
<View style={styles.headerIcon}>
<MaterialCommunityIcons
name="vote"
size={compact ? 14 : 16}
color={colors.primary.main}
/>
</View>
<Text variant={compact ? 'caption' : 'body'} style={styles.headerTitle}>
</Text>
</View>
{/* 投票选项列表 */}
<View style={styles.optionsList}>
{sortedOptions.map((option, index) => renderOption(option, index))}
</View>
{/* 底部信息栏 */}
<View style={styles.footer}>
<View style={styles.footerLeft}>
<MaterialCommunityIcons
name="account-group-outline"
size={14}
color={colors.text.hint}
/>
<Text variant="caption" color={colors.text.hint} style={styles.footerText}>
{formatVoteCount(totalVotes)}
</Text>
</View>
{hasVoted && (
<TouchableOpacity
style={styles.unvoteButton}
onPress={handleUnvote}
disabled={isLoading}
>
<MaterialCommunityIcons
name="refresh"
size={14}
color={colors.text.hint}
/>
<Text variant="caption" color={colors.text.hint} style={styles.unvoteText}>
</Text>
</TouchableOpacity>
)}
</View>
{/* 加载遮罩 */}
{isLoading && (
<View style={styles.loadingOverlay}>
<MaterialCommunityIcons
name="loading"
size={24}
color={colors.primary.main}
/>
</View>
)}
</View>
);
};
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,
},
card: {
borderRadius: borderRadius.xl,
paddingVertical: spacing.md,
paddingHorizontal: spacing.md,
borderWidth: 1,
borderColor: `${colors.primary.main}22`,
flexDirection: 'row',
alignItems: 'center',
overflow: 'hidden',
...shadows.lg,
},
accentBar: {
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: 4,
},
iconWrap: {
width: 32,
height: 32,
borderRadius: borderRadius.full,
backgroundColor: `${colors.primary.main}12`,
alignItems: 'center',
justifyContent: 'center',
marginRight: spacing.md,
},
textWrap: {
flex: 1,
paddingRight: spacing.sm,
},
title: {
color: colors.text.primary,
fontWeight: '700',
fontSize: 14,
marginBottom: 2,
},
message: {
color: colors.text.primary,
fontSize: 13,
lineHeight: 18,
},
});
}
const iconColorMap: Record<PromptType, string> = { const AppPromptBar: React.FC = () => {
const insets = useSafeAreaInsets();
const colors = useAppColors();
const styles = useMemo(() => createPromptBarStyles(colors), [colors]);
const styleMap = useMemo<
Record<PromptType, { backgroundColor: string; icon: React.ComponentProps<typeof MaterialCommunityIcons>['name'] }>
>(
() => ({
info: { backgroundColor: colors.background.paper, icon: 'information-outline' },
success: { backgroundColor: colors.background.paper, icon: 'check-circle-outline' },
warning: { backgroundColor: colors.background.paper, icon: 'alert-outline' },
error: { backgroundColor: colors.background.paper, icon: 'alert-circle-outline' },
}),
[colors.background.paper],
);
const iconColorMap = useMemo(
() => ({
info: colors.primary.main, info: colors.primary.main,
success: colors.success.main, success: colors.success.main,
warning: colors.warning.dark, warning: colors.warning.dark,
error: colors.error.main, error: colors.error.main,
}; }),
[colors.primary.main, colors.success.main, colors.warning.dark, colors.error.main],
);
const accentColorMap: Record<PromptType, string> = { const accentColorMap = useMemo(
() => ({
info: colors.primary.main, info: colors.primary.main,
success: colors.success.main, success: colors.success.main,
warning: colors.warning.main, warning: colors.warning.main,
error: colors.error.main, error: colors.error.main,
}; }),
[colors.primary.main, colors.success.main, colors.warning.main, colors.error.main],
);
const AppPromptBar: React.FC = () => {
const insets = useSafeAreaInsets();
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,121 +20,8 @@ interface EmptyStateProps {
variant?: 'default' | 'modern' | 'compact'; variant?: 'default' | 'modern' | 'compact';
} }
const EmptyState: React.FC<EmptyStateProps> = ({ function createEmptyStateStyles(colors: AppColors) {
icon = 'folder-open-outline', return StyleSheet.create({
title,
description,
actionLabel,
onAction,
style,
variant = 'modern',
}) => {
// 现代风格空状态
if (variant === 'modern') {
return (
<View style={[styles.modernContainer, style]}>
<View style={styles.illustrationContainer}>
<View style={styles.iconBackground}>
<MaterialCommunityIcons
name={icon as any}
size={48}
color={colors.primary.main}
style={styles.modernIcon}
/>
</View>
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
</View>
<Text
variant="h3"
color={colors.text.primary}
style={styles.modernTitle}
>
{title}
</Text>
{description && (
<Text
variant="body"
color={colors.text.secondary}
style={styles.modernDescription}
>
{description}
</Text>
)}
{actionLabel && onAction && (
<Button
title={actionLabel}
onPress={onAction}
variant="primary"
size="md"
style={styles.modernButton}
/>
)}
</View>
);
}
// 紧凑风格
if (variant === 'compact') {
return (
<View style={[styles.compactContainer, style]}>
<MaterialCommunityIcons
name={icon as any}
size={40}
color={colors.text.disabled}
style={styles.compactIcon}
/>
<Text
variant="body"
color={colors.text.secondary}
style={styles.compactTitle}
>
{title}
</Text>
</View>
);
}
// 默认风格
return (
<View style={[styles.container, style]}>
<MaterialCommunityIcons
name={icon as any}
size={64}
color={colors.text.disabled}
style={styles.icon}
/>
<Text
variant="h3"
color={colors.text.secondary}
style={styles.title}
>
{title}
</Text>
{description && (
<Text
variant="body"
color={colors.text.secondary}
style={styles.description}
>
{description}
</Text>
)}
{actionLabel && onAction && (
<Button
title={actionLabel}
onPress={onAction}
variant="outline"
size="md"
style={styles.button}
/>
)}
</View>
);
};
const styles = StyleSheet.create({
// 默认风格
container: { container: {
flex: 1, flex: 1,
alignItems: 'center', alignItems: 'center',
@@ -155,8 +42,6 @@ const styles = StyleSheet.create({
button: { button: {
minWidth: 120, minWidth: 120,
}, },
// 现代风格
modernContainer: { modernContainer: {
flex: 1, flex: 1,
alignItems: 'center', alignItems: 'center',
@@ -223,8 +108,6 @@ const styles = StyleSheet.create({
minWidth: 140, minWidth: 140,
borderRadius: borderRadius.lg, borderRadius: borderRadius.lg,
}, },
// 紧凑风格
compactContainer: { compactContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
@@ -237,6 +120,100 @@ const styles = StyleSheet.create({
compactTitle: { compactTitle: {
fontSize: fontSizes.md, fontSize: fontSizes.md,
}, },
}); });
}
const EmptyState: React.FC<EmptyStateProps> = ({
icon = 'folder-open-outline',
title,
description,
actionLabel,
onAction,
style,
variant = 'modern',
}) => {
const colors = useAppColors();
const styles = useMemo(() => createEmptyStateStyles(colors), [colors]);
if (variant === 'modern') {
return (
<View style={[styles.modernContainer, style]}>
<View style={styles.illustrationContainer}>
<View style={styles.iconBackground}>
<MaterialCommunityIcons
name={icon as any}
size={48}
color={colors.primary.main}
style={styles.modernIcon}
/>
</View>
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
</View>
<Text variant="h3" color={colors.text.primary} style={styles.modernTitle}>
{title}
</Text>
{description && (
<Text variant="body" color={colors.text.secondary} style={styles.modernDescription}>
{description}
</Text>
)}
{actionLabel && onAction && (
<Button
title={actionLabel}
onPress={onAction}
variant="primary"
size="md"
style={styles.modernButton}
/>
)}
</View>
);
}
if (variant === 'compact') {
return (
<View style={[styles.compactContainer, style]}>
<MaterialCommunityIcons
name={icon as any}
size={40}
color={colors.text.disabled}
style={styles.compactIcon}
/>
<Text variant="body" color={colors.text.secondary} style={styles.compactTitle}>
{title}
</Text>
</View>
);
}
return (
<View style={[styles.container, style]}>
<MaterialCommunityIcons
name={icon as any}
size={64}
color={colors.text.disabled}
style={styles.icon}
/>
<Text variant="h3" color={colors.text.secondary} style={styles.title}>
{title}
</Text>
{description && (
<Text variant="body" color={colors.text.secondary} style={styles.description}>
{description}
</Text>
)}
{actionLabel && onAction && (
<Button
title={actionLabel}
onPress={onAction}
variant="outline"
size="md"
style={styles.button}
/>
)}
</View>
);
};
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,15 +203,27 @@ 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);
@@ -132,7 +231,7 @@ const SingleImageItem: React.FC<SingleImageItemProps> = ({
}, 3000); }, 3000);
Image.getSize( Image.getSize(
uri, probeUri,
(w, h) => { (w, h) => {
if (!cancelled) { if (!cancelled) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
@@ -150,7 +249,7 @@ const SingleImageItem: React.FC<SingleImageItemProps> = ({
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 字段
@@ -266,11 +372,6 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
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 (
<ImageGridStylesContext.Provider value={gridStyles}>
<View <View
style={[styles.container, style]} style={[gridStyles.container, style]}
testID={testID} testID={testID}
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)} onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}
> >
{renderContent()} {renderContent()}
</View> </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';

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

File diff suppressed because it is too large Load Diff

View File

@@ -72,12 +72,7 @@ export class ApiDataSource implements IApiDataSource {
async delete<T>(url: string, data?: any): Promise<T> { async delete<T>(url: string, data?: any): Promise<T> {
try { try {
const fullUrl = this.buildUrl(url); const fullUrl = this.buildUrl(url);
// 处理带 request body 的 DELETE 请求 const response = await api.delete<T>(fullUrl, data);
if (data) {
const response = await api.request<T>('DELETE', fullUrl, undefined, data);
return response.data;
}
const response = await api.delete<T>(fullUrl);
return response.data; return response.data;
} catch (error) { } catch (error) {
this.handleError(error, 'DELETE'); this.handleError(error, 'DELETE');

View File

@@ -20,6 +20,7 @@ export class LocalDataSource implements ILocalDataSource {
private db: SQLite.SQLiteDatabase | null = null; private db: SQLite.SQLiteDatabase | null = null;
private dbName: string; private dbName: string;
private initialized = false; private initialized = false;
private initializingPromise: Promise<void> | null = null;
constructor(config: LocalDataSourceConfig = {}) { constructor(config: LocalDataSourceConfig = {}) {
// 如果提供了userId使用用户专属数据库 // 如果提供了userId使用用户专属数据库
@@ -34,6 +35,21 @@ export class LocalDataSource implements ILocalDataSource {
return; 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 { try {
// 使用全局实例管理 // 使用全局实例管理
if (dbInstance && currentDbName === this.dbName) { if (dbInstance && currentDbName === this.dbName) {
@@ -132,6 +148,12 @@ export class LocalDataSource implements ILocalDataSource {
updatedAt TEXT NOT NULL, updatedAt TEXT NOT NULL,
PRIMARY KEY (groupId, userId) 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) { for (const sql of tables) {
@@ -183,7 +205,10 @@ export class LocalDataSource implements ILocalDataSource {
async query<T>(sql: string, params?: any[]): Promise<T[]> { async query<T>(sql: string, params?: any[]): Promise<T[]> {
try { try {
const db = this.ensureDb(); const db = this.ensureDb();
return await db.getAllAsync<T>(sql, params); if (params && params.length > 0) {
return await db.getAllAsync<T>(sql, params as any);
}
return await db.getAllAsync<T>(sql);
} catch (error) { } catch (error) {
this.handleError(error, 'QUERY'); this.handleError(error, 'QUERY');
} }
@@ -192,7 +217,10 @@ export class LocalDataSource implements ILocalDataSource {
async getFirst<T>(sql: string, params?: any[]): Promise<T | null> { async getFirst<T>(sql: string, params?: any[]): Promise<T | null> {
try { try {
const db = this.ensureDb(); const db = this.ensureDb();
return await db.getFirstAsync<T>(sql, params); if (params && params.length > 0) {
return await db.getFirstAsync<T>(sql, params as any);
}
return await db.getFirstAsync<T>(sql);
} catch (error) { } catch (error) {
this.handleError(error, 'GET_FIRST'); this.handleError(error, 'GET_FIRST');
} }
@@ -210,8 +238,22 @@ export class LocalDataSource implements ILocalDataSource {
async run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }> { async run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }> {
try { try {
const db = this.ensureDb(); const db = this.ensureDb();
return await db.runAsync(sql, params); if (params && params.length > 0) {
return await db.runAsync(sql, params as any);
}
return await db.runAsync(sql);
} catch (error) { } 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'); this.handleError(error, 'RUN');
} }
} }

View File

@@ -212,7 +212,7 @@ export class ConversationMapper {
id: String(user.id || ''), id: String(user.id || ''),
username: user.username || '', username: user.username || '',
nickname: user.nickname, nickname: user.nickname,
avatar: user.avatar, avatar: user.avatar ?? undefined,
}; };
} }
@@ -220,19 +220,31 @@ export class ConversationMapper {
* 映射群组 API 响应 * 映射群组 API 响应
*/ */
private static mapGroupFromApi(group: GroupResponse): GroupModel { private static mapGroupFromApi(group: GroupResponse): GroupModel {
// 将数字类型的 join_type 转换为字符串类型
// JoinType: 0 = 允许任何人, 1 = 需要审批, 2 = 不允许
const mapJoinType = (joinType: number): 'anyone' | 'approval' | 'invite' => {
switch (joinType) {
case 0: return 'anyone';
case 1: return 'approval';
case 2: return 'invite';
default: return 'approval';
}
};
return { return {
id: String(group.id || ''), id: String(group.id || ''),
name: group.name || '', name: group.name || '',
avatar: group.avatar, avatar: group.avatar,
description: group.description, description: group.description,
announcement: group.announcement, // GroupResponse 没有 announcement 和 updated_at 字段,使用默认值
announcement: undefined,
ownerId: String(group.owner_id || ''), ownerId: String(group.owner_id || ''),
memberCount: group.member_count || 0, memberCount: group.member_count || 0,
maxMemberCount: group.max_member_count || 500, maxMemberCount: group.max_members || 500,
joinType: group.join_type || 'approval', joinType: mapJoinType(group.join_type),
isMuted: group.mute_all || false, isMuted: group.mute_all || false,
createdAt: new Date(group.created_at || Date.now()), createdAt: new Date(group.created_at || Date.now()),
updatedAt: new Date(group.updated_at || Date.now()), updatedAt: new Date(group.created_at || Date.now()),
}; };
} }

View File

@@ -32,13 +32,16 @@ export class MessageMapper {
* 将 API 响应转换为应用模型 * 将 API 响应转换为应用模型
*/ */
static fromApiResponse(response: MessageResponse): MessageModel { static fromApiResponse(response: MessageResponse): MessageModel {
const textSegment = response.segments?.find(s => s.type === 'text');
const content = textSegment?.data?.text || textSegment?.data?.content || '';
return { return {
id: String(response.id || ''), id: String(response.id || ''),
conversationId: String(response.conversation_id || ''), conversationId: String(response.conversation_id || ''),
senderId: String(response.sender_id || ''), senderId: String(response.sender_id || ''),
content: response.content || '', content,
type: response.message_type || 'text', type: 'text',
isRead: response.is_read || false, isRead: false,
createdAt: new Date(response.created_at || Date.now()), createdAt: new Date(response.created_at || Date.now()),
seq: response.seq || 0, seq: response.seq || 0,
status: response.status || 'normal', status: response.status || 'normal',
@@ -169,15 +172,12 @@ export class MessageMapper {
} }
} }
/**
* 映射发送者信息
*/
private static mapSenderFromApi(sender: UserDTO): UserModel { private static mapSenderFromApi(sender: UserDTO): UserModel {
return { return {
id: String(sender.id || ''), id: String(sender.id || ''),
username: sender.username || '', username: sender.username || '',
nickname: sender.nickname, nickname: sender.nickname,
avatar: sender.avatar, avatar: sender.avatar || undefined,
}; };
} }
} }

View File

@@ -4,40 +4,46 @@
*/ */
import { PostModel, UserModel } from '../models'; import { PostModel, UserModel } from '../models';
import type { Post } from '../../types'; import type { PostDTO } from '../../types/dto';
export class PostMapper { export class PostMapper {
/** static fromApiResponse(response: PostDTO): PostModel {
* 将 API 响应转换为应用模型
*/
static fromApiResponse(response: Post): PostModel {
return { return {
id: String(response.id || ''), id: String(response.id || ''),
authorId: String(response.author_id || ''), authorId: String(response.user_id || ''),
author: response.author ? this.mapAuthorFromApi(response.author) : undefined, author: response.author ? this.mapAuthorFromApi(response.author) : undefined,
title: response.title || '', title: response.title || '',
content: response.content || '', content: response.content || '',
images: response.images || [], images: response.images?.map(img => img.url) || [],
likeCount: response.like_count || 0, likeCount: response.likes_count || 0,
commentCount: response.comment_count || 0, commentCount: response.comments_count || 0,
shareCount: response.share_count || 0, shareCount: response.shares_count || 0,
viewCount: response.view_count || 0, viewCount: response.views_count || 0,
favoriteCount: response.favorite_count || 0, favoriteCount: response.favorites_count || 0,
isLiked: response.is_liked || false, isLiked: response.is_liked || false,
isFavorited: response.is_favorited || false, isFavorited: response.is_favorited || false,
isTop: response.is_top || false, isTop: response.is_pinned || false,
status: response.status || 'published', status: (response.status || 'published') as 'published' | 'draft' | 'deleted',
communityId: response.community_id, channelId: response.channel_id,
tags: response.tags || [], channel:
response.channel && response.channel.id
? { id: String(response.channel.id), name: String(response.channel.name || '') }
: undefined,
tags: [],
createdAt: new Date(response.created_at || Date.now()), createdAt: new Date(response.created_at || Date.now()),
updatedAt: new Date(response.updated_at || Date.now()), // 空字符串/缺省时用 created_at避免误用 Date.now() 导致全部显示「刚修改」
updatedAt: new Date(
response.updated_at ||
response.created_at ||
Date.now()
),
}; };
} }
/** /**
* 将 API 响应数组转换为应用模型数组 * 将 API 响应数组转换为应用模型数组
*/ */
static fromApiResponseList(responses: Post[]): PostModel[] { static fromApiResponseList(responses: PostDTO[]): PostModel[] {
return responses.map(r => this.fromApiResponse(r)); return responses.map(r => this.fromApiResponse(r));
} }
@@ -56,8 +62,8 @@ export class PostMapper {
if (model.images !== undefined) { if (model.images !== undefined) {
request.images = model.images; request.images = model.images;
} }
if (model.communityId !== undefined) { if (model.channelId !== undefined) {
request.community_id = model.communityId; request.channel_id = model.channelId;
} }
if (model.tags !== undefined) { if (model.tags !== undefined) {
request.tags = model.tags; request.tags = model.tags;
@@ -73,7 +79,7 @@ export class PostMapper {
title: string, title: string,
content: string, content: string,
images?: string[], images?: string[],
communityId?: string channelId?: string
): Record<string, any> { ): Record<string, any> {
const request: Record<string, any> = { const request: Record<string, any> = {
title, title,
@@ -82,8 +88,8 @@ export class PostMapper {
if (images && images.length > 0) { if (images && images.length > 0) {
request.images = images; request.images = images;
} }
if (communityId) { if (channelId) {
request.community_id = communityId; request.channel_id = channelId;
} }
return request; return request;
} }

View File

@@ -4,7 +4,7 @@
*/ */
import { UserModel } from '../models'; import { UserModel } from '../models';
import type { UserDTO, User } from '../../types/dto'; import type { UserDTO } from '../../types/dto';
// 数据库用户记录类型 // 数据库用户记录类型
export interface UserDbRecord { export interface UserDbRecord {
@@ -14,9 +14,6 @@ export interface UserDbRecord {
} }
export class UserMapper { export class UserMapper {
/**
* 将 API DTO 转换为应用模型
*/
static fromDTO(dto: UserDTO): UserModel { static fromDTO(dto: UserDTO): UserModel {
return { return {
id: String(dto.id || ''), id: String(dto.id || ''),
@@ -32,39 +29,10 @@ export class UserMapper {
followingCount: dto.following_count, followingCount: dto.following_count,
postsCount: dto.posts_count, postsCount: dto.posts_count,
isFollowing: dto.is_following, isFollowing: dto.is_following,
isBlocked: dto.is_blocked,
createdAt: dto.created_at ? new Date(dto.created_at) : undefined, createdAt: dto.created_at ? new Date(dto.created_at) : undefined,
updatedAt: dto.updated_at ? new Date(dto.updated_at) : undefined,
}; };
} }
/**
* 将 User 类型转换为应用模型
*/
static fromUser(user: User): UserModel {
return {
id: String(user.id || ''),
username: user.username || '',
nickname: user.nickname,
avatar: user.avatar,
bio: user.bio,
website: user.website,
location: user.location,
email: user.email,
phone: user.phone,
followersCount: user.followers_count,
followingCount: user.following_count,
postsCount: user.posts_count,
isFollowing: user.is_following,
isBlocked: user.is_blocked,
createdAt: user.created_at ? new Date(user.created_at) : undefined,
updatedAt: user.updated_at ? new Date(user.updated_at) : undefined,
};
}
/**
* 将 DTO 数组转换为应用模型数组
*/
static fromDTOList(dtos: UserDTO[]): UserModel[] { static fromDTOList(dtos: UserDTO[]): UserModel[] {
return dtos.map(dto => this.fromDTO(dto)); return dtos.map(dto => this.fromDTO(dto));
} }
@@ -88,20 +56,19 @@ export class UserMapper {
const dto: UserDTO = { const dto: UserDTO = {
id: model.id, id: model.id,
username: model.username, username: model.username,
nickname: model.nickname, nickname: model.nickname || '',
avatar: model.avatar, avatar: model.avatar || '',
bio: model.bio, cover_url: '',
website: model.website, bio: model.bio || '',
location: model.location, website: model.website || '',
location: model.location || '',
email: model.email, email: model.email,
phone: model.phone, phone: model.phone,
followers_count: model.followersCount, followers_count: model.followersCount || 0,
following_count: model.followingCount, following_count: model.followingCount || 0,
posts_count: model.postsCount, posts_count: model.postsCount || 0,
is_following: model.isFollowing, is_following: model.isFollowing,
is_blocked: model.isBlocked, created_at: model.createdAt?.toISOString() || '',
created_at: model.createdAt?.toISOString(),
updated_at: model.updatedAt?.toISOString(),
}; };
return { return {
@@ -149,20 +116,19 @@ export class UserMapper {
return { return {
id: model.id, id: model.id,
username: model.username, username: model.username,
nickname: model.nickname, nickname: model.nickname || '',
avatar: model.avatar, avatar: model.avatar || '',
bio: model.bio, cover_url: '',
website: model.website, bio: model.bio || '',
location: model.location, website: model.website || '',
location: model.location || '',
email: model.email, email: model.email,
phone: model.phone, phone: model.phone,
followers_count: model.followersCount, followers_count: model.followersCount || 0,
following_count: model.followingCount, following_count: model.followingCount || 0,
posts_count: model.postsCount, posts_count: model.postsCount || 0,
is_following: model.isFollowing, is_following: model.isFollowing,
is_blocked: model.isBlocked, created_at: model.createdAt?.toISOString() || '',
created_at: model.createdAt?.toISOString(),
updated_at: model.updatedAt?.toISOString(),
}; };
} }
} }

View File

@@ -85,7 +85,9 @@ export interface PostModel {
isFavorited: boolean; isFavorited: boolean;
isTop: boolean; isTop: boolean;
status: 'published' | 'draft' | 'deleted'; status: 'published' | 'draft' | 'deleted';
communityId?: string; channelId?: string;
/** 频道展示用(与 PostDTO.channel 一致) */
channel?: { id: string; name: string } | null;
tags?: string[]; tags?: string[];
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;

View File

@@ -0,0 +1,579 @@
/**
* PostRepository - 帖子仓库实现
* 实现IPostRepository接口封装帖子相关的数据访问逻辑
*/
import { ApiDataSource, apiDataSource } from '../datasources/ApiDataSource';
import { LocalDataSource, localDataSource } from '../datasources/LocalDataSource';
import { PostMapper } from '../mappers/PostMapper';
import type { Post, PostImage, PostStatus } from '../../core/entities/Post';
import { createPost } from '../../core/entities/Post';
import type {
IPostRepository,
GetPostsParams,
CreatePostData,
UpdatePostData,
PostsResult,
SearchPostsParams,
} from './interfaces/IPostRepository';
// ==================== 类型定义 ====================
/**
* API帖子响应类型
*/
interface PostApiResponse {
id: number;
user_id: number;
title: string;
content: string;
images?: Array<{ url: string; width?: number; height?: number; thumbnail_url?: string }>;
likes_count: number;
comments_count: number;
shares_count: number;
views_count: number;
favorites_count: number;
is_liked: boolean;
is_favorited: boolean;
is_pinned: boolean;
status: string;
channel_id?: string;
tags?: string[];
created_at: string;
updated_at: string;
content_edited_at?: string;
channel?: { id: string; name: string } | null;
author?: {
id: number;
username: string;
nickname?: string;
avatar?: string;
is_following?: boolean;
is_following_me?: boolean;
};
}
/**
* API帖子列表响应类型
*/
interface PostsListApiResponse {
list: PostApiResponse[];
has_more?: boolean;
next_cursor?: string;
total?: number;
page?: number;
page_size?: number;
total_pages?: number;
}
/**
* 缓存的帖子数据
*/
interface CachedPost {
id: string;
data: string;
updatedAt: string;
}
// ==================== PostRepository 实现 ====================
export class PostRepository implements IPostRepository {
private api: ApiDataSource;
private localDb: LocalDataSource;
private memoryCache: Map<string, { post: Post; timestamp: number }> = new Map();
private readonly CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存过期时间
constructor(api: ApiDataSource, localDb: LocalDataSource) {
this.api = api;
this.localDb = localDb;
}
// ==================== 私有辅助方法 ====================
/**
* 将API响应转换为领域实体
* 同时设置 camelCase 和 snake_case 字段以保持向后兼容
*/
private mapToPost(response: PostApiResponse): Post {
const model = PostMapper.fromApiResponse(response as any);
return {
// camelCase 字段(新标准)
id: model.id,
authorId: model.authorId,
author: model.author ? {
id: model.author.id,
username: model.author.username,
nickname: model.author.nickname,
avatar: model.author.avatar,
// 关注关系直接透传 API 字段,供详情页关注按钮状态使用
is_following: response.author?.is_following,
is_following_me: response.author?.is_following_me,
} : undefined,
title: model.title,
content: model.content,
images: (model.images || []).map(url => ({
url,
width: undefined,
height: undefined,
})) as PostImage[],
likesCount: model.likeCount,
commentsCount: model.commentCount,
sharesCount: model.shareCount,
viewsCount: model.viewCount,
favoritesCount: model.favoriteCount,
isLiked: model.isLiked,
isFavorited: model.isFavorited,
isPinned: model.isTop,
status: model.status,
channelId: model.channelId,
channel: model.channel,
tags: model.tags || [],
createdAt: model.createdAt.toISOString(),
updatedAt: model.updatedAt.toISOString(),
contentEditedAt: response.content_edited_at,
// snake_case 字段(向后兼容)
likes_count: model.likeCount,
comments_count: model.commentCount,
favorites_count: model.favoriteCount,
shares_count: model.shareCount,
views_count: model.viewCount,
is_liked: model.isLiked,
is_favorited: model.isFavorited,
is_pinned: model.isTop,
user_id: model.authorId,
created_at: model.createdAt.toISOString(),
updated_at: model.updatedAt.toISOString(),
content_edited_at: response.content_edited_at,
channel_id: model.channelId,
};
}
/**
* 从内存缓存获取帖子
*/
private getFromMemoryCache(id: string): Post | null {
const cached = this.memoryCache.get(id);
if (!cached) return null;
// 检查缓存是否过期
if (Date.now() - cached.timestamp > this.CACHE_TTL) {
this.memoryCache.delete(id);
return null;
}
return cached.post;
}
/**
* 保存帖子到内存缓存
*/
private saveToMemoryCache(post: Post): void {
this.memoryCache.set(post.id, {
post,
timestamp: Date.now(),
});
}
/**
* 从本地数据库获取缓存的帖子
*/
private async getFromLocalCache(id: string): Promise<Post | null> {
try {
await this.localDb.initialize();
const result = await this.localDb.getFirst<CachedPost>(
'SELECT * FROM posts_cache WHERE id = ?',
[id]
);
if (!result) return null;
const post = JSON.parse(result.data) as Post;
return post;
} catch (error) {
console.error('[PostRepository] 获取本地缓存失败:', error);
return null;
}
}
/**
* 保存帖子到本地缓存
*/
private async saveToLocalCache(post: Post): Promise<void> {
try {
await this.localDb.initialize();
await this.localDb.enqueueWrite(async () => {
await this.localDb.run(
`INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[post.id, JSON.stringify(post), new Date().toISOString()]
);
});
} catch (error) {
console.error('[PostRepository] 保存本地缓存失败:', error);
}
}
/**
* 清除帖子的本地缓存
*/
private async clearLocalCache(id: string): Promise<void> {
try {
await this.localDb.initialize();
await this.localDb.enqueueWrite(async () => {
await this.localDb.run('DELETE FROM posts_cache WHERE id = ?', [id]);
});
} catch (error) {
console.error('[PostRepository] 清除本地缓存失败:', error);
}
}
/**
* 处理错误
*/
private handleError(error: unknown, operation: string): never {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error(`[PostRepository] ${operation} 失败:`, error);
throw new Error(`帖子${operation}失败: ${message}`);
}
// ==================== 帖子查询 ====================
/**
* 获取帖子列表
*/
async getPosts(params?: GetPostsParams): Promise<PostsResult> {
try {
const queryParams: Record<string, any> = {};
if (params?.page !== undefined) queryParams.page = params.page;
if (params?.pageSize) queryParams.page_size = params.pageSize;
// 支持 cursor 参数:空字符串也传递,用于启动 cursor 模式
if (params?.cursor !== undefined && params?.cursor !== null) {
queryParams.cursor = params.cursor;
}
if (params?.post_type) queryParams.tab = params.post_type;
if (params?.channelId) queryParams.channel_id = params.channelId;
if (params?.authorId) queryParams.author_id = params.authorId;
if (params?.tags && params.tags.length > 0) queryParams.tags = params.tags.join(',');
if (params?.status) queryParams.status = params.status;
if (params?.sortBy) queryParams.sort_by = params.sortBy;
if (params?.sortOrder) queryParams.sort_order = params.sortOrder;
if (params?.pinnedOnly) queryParams.pinned_only = true;
const response = await this.api.get<PostsListApiResponse>('/posts', queryParams);
const posts = (response.list || []).map(p => this.mapToPost(p));
// 缓存帖子
for (const post of posts) {
this.saveToMemoryCache(post);
// 不等待本地缓存写入完成,避免阻塞 UI
this.saveToLocalCache(post).catch(err => {
console.warn('[PostRepository] 缓存帖子失败:', post.id, err);
});
}
// 计算 hasMore优先使用 has_more否则通过 page/total_pages 计算
let hasMore = response.has_more;
if (hasMore === undefined && response.page !== undefined && response.total_pages !== undefined) {
hasMore = response.page < response.total_pages;
}
hasMore = hasMore ?? false;
return {
posts,
hasMore,
nextCursor: response.next_cursor,
total: response.total,
};
} catch (error) {
this.handleError(error, '获取帖子列表');
}
}
/**
* 获取单个帖子详情
*/
async getPostById(id: string): Promise<Post | null> {
try {
// 详情页优先取最新服务端数据,避免旧缓存导致关注态等关系字段过期
const response = await this.api.get<PostApiResponse>(`/posts/${id}`);
const post = this.mapToPost(response);
// 更新缓存
this.saveToMemoryCache(post);
await this.saveToLocalCache(post);
return post;
} catch (error) {
// API失败时再回退缓存保证离线/弱网可用
const memoryCached = this.getFromMemoryCache(id);
if (memoryCached) {
console.warn('[PostRepository] API获取失败使用内存缓存:', error);
return memoryCached;
}
const localCached = await this.getFromLocalCache(id);
if (localCached) {
console.warn('[PostRepository] API获取失败使用本地缓存:', error);
this.saveToMemoryCache(localCached);
return localCached;
}
this.handleError(error, '获取帖子详情');
}
}
/**
* 获取用户发布的帖子列表
*/
async getPostsByUser(userId: string, params?: GetPostsParams): Promise<PostsResult> {
try {
const queryParams: Record<string, any> = { author_id: userId };
if (params?.page) queryParams.page = params.page;
if (params?.pageSize) queryParams.page_size = params.pageSize;
if (params?.cursor) queryParams.cursor = params.cursor;
if (params?.status) queryParams.status = params.status;
if (params?.sortBy) queryParams.sort_by = params.sortBy;
if (params?.sortOrder) queryParams.sort_order = params.sortOrder;
const response = await this.api.get<PostsListApiResponse>(`/users/${userId}/posts`, queryParams);
const posts = (response.list || []).map(p => this.mapToPost(p));
// 缓存帖子
for (const post of posts) {
this.saveToMemoryCache(post);
this.saveToLocalCache(post).catch(err => {
console.warn('[PostRepository] 缓存帖子失败:', post.id, err);
});
}
return {
posts,
hasMore: response.has_more ?? false,
nextCursor: response.next_cursor,
total: response.total,
};
} catch (error) {
this.handleError(error, '获取用户帖子列表');
}
}
/**
* 搜索帖子
*/
async searchPosts(params: SearchPostsParams): Promise<PostsResult> {
try {
const queryParams: Record<string, any> = {
keyword: params.keyword,
};
if (params.page) queryParams.page = params.page;
if (params.pageSize) queryParams.page_size = params.pageSize;
if (params.scope) queryParams.scope = params.scope;
if (params.channelId) queryParams.channel_id = params.channelId;
const response = await this.api.get<PostsListApiResponse>('/posts/search', queryParams);
const posts = (response.list || []).map(p => this.mapToPost(p));
// 缓存帖子
for (const post of posts) {
this.saveToMemoryCache(post);
this.saveToLocalCache(post).catch(err => {
console.warn('[PostRepository] 缓存帖子失败:', post.id, err);
});
}
return {
posts,
hasMore: response.has_more ?? false,
nextCursor: response.next_cursor,
total: response.total,
};
} catch (error) {
this.handleError(error, '搜索帖子');
}
}
// ==================== 帖子操作 ====================
/**
* 创建帖子
*/
async createPost(data: CreatePostData): Promise<Post> {
try {
const requestData = PostMapper.createPostRequest(
data.title,
data.content,
data.images?.map(img => img.url),
data.channelId
);
if (data.tags && data.tags.length > 0) {
requestData.tags = data.tags;
}
if (data.status) {
requestData.status = data.status;
}
const response = await this.api.post<PostApiResponse>('/posts', requestData);
const post = this.mapToPost(response);
// 缓存新创建的帖子
this.saveToMemoryCache(post);
await this.saveToLocalCache(post);
return post;
} catch (error) {
this.handleError(error, '创建帖子');
}
}
/**
* 更新帖子
*/
async updatePost(id: string, data: UpdatePostData): Promise<Post> {
try {
const requestData: Record<string, any> = {};
if (data.title !== undefined) requestData.title = data.title;
if (data.content !== undefined) requestData.content = data.content;
if (data.images !== undefined) requestData.images = data.images.map(img => img.url);
if (data.tags !== undefined) requestData.tags = data.tags;
if (data.status !== undefined) requestData.status = data.status;
if (data.isPinned !== undefined) requestData.is_pinned = data.isPinned;
const response = await this.api.put<PostApiResponse>(`/posts/${id}`, requestData);
const post = this.mapToPost(response);
// 更新缓存
this.saveToMemoryCache(post);
await this.saveToLocalCache(post);
return post;
} catch (error) {
this.handleError(error, '更新帖子');
}
}
/**
* 删除帖子
*/
async deletePost(id: string): Promise<void> {
try {
await this.api.delete(`/posts/${id}`);
// 清除缓存
this.memoryCache.delete(id);
await this.clearLocalCache(id);
} catch (error) {
this.handleError(error, '删除帖子');
}
}
// ==================== 互动操作 ====================
/**
* 点赞帖子
*/
async likePost(id: string): Promise<Post> {
try {
const response = await this.api.post<PostApiResponse>(`/posts/${id}/like`);
const post = this.mapToPost(response);
// 更新缓存
this.saveToMemoryCache(post);
await this.saveToLocalCache(post);
return post;
} catch (error) {
this.handleError(error, '点赞帖子');
}
}
/**
* 取消点赞
*/
async unlikePost(id: string): Promise<Post> {
try {
const response = await this.api.delete<PostApiResponse>(`/posts/${id}/like`);
const post = this.mapToPost(response);
// 更新缓存
this.saveToMemoryCache(post);
await this.saveToLocalCache(post);
return post;
} catch (error) {
this.handleError(error, '取消点赞');
}
}
/**
* 收藏帖子
*/
async favoritePost(id: string): Promise<Post> {
try {
const response = await this.api.post<PostApiResponse>(`/posts/${id}/favorite`);
const post = this.mapToPost(response);
// 更新缓存
this.saveToMemoryCache(post);
await this.saveToLocalCache(post);
return post;
} catch (error) {
this.handleError(error, '收藏帖子');
}
}
/**
* 取消收藏
*/
async unfavoritePost(id: string): Promise<Post> {
try {
const response = await this.api.delete<PostApiResponse>(`/posts/${id}/favorite`);
const post = this.mapToPost(response);
// 更新缓存
this.saveToMemoryCache(post);
await this.saveToLocalCache(post);
return post;
} catch (error) {
this.handleError(error, '取消收藏');
}
}
// ==================== 缓存管理 ====================
/**
* 清除所有缓存
*/
async clearAllCache(): Promise<void> {
this.memoryCache.clear();
try {
await this.localDb.initialize();
await this.localDb.enqueueWrite(async () => {
await this.localDb.run('DELETE FROM posts_cache');
});
} catch (error) {
console.error('[PostRepository] 清除所有缓存失败:', error);
}
}
/**
* 使指定帖子的缓存失效
*/
async invalidateCache(id: string): Promise<void> {
this.memoryCache.delete(id);
await this.clearLocalCache(id);
}
}
// ==================== 单例导出 ====================
export const postRepository = new PostRepository(apiDataSource, localDataSource);
export default postRepository;

View File

@@ -3,7 +3,7 @@
* 定义消息数据访问的抽象 * 定义消息数据访问的抽象
*/ */
import type { Message, Conversation, ConversationType } from '../../types/dto'; import type { Message, Conversation } from '../../../core/entities/Message';
export interface IMessageRepository { export interface IMessageRepository {
// ==================== 消息操作 ==================== // ==================== 消息操作 ====================

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