39 Commits

Author SHA1 Message Date
lafay
9bbed8cf5e feat(auth): upload proof materials image before verification submission
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 3m0s
Frontend CI / ota-android (push) Successful in 10m30s
Frontend CI / build-android-apk (push) Successful in 50m44s
Add image upload functionality to the verification form that uploads local proof material images (file://, content://, blob: URIs) to the server and uses the returned URL for verification submission. Also add proper upload state tracking to disable form inputs during upload.
2026-04-10 01:10:55 +08:00
lafay
be8f6de8cf <think>Let me analyze the staged changes to generate a proper conventional commit message.
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 3m16s
Frontend CI / ota-android (push) Successful in 11m42s
Frontend CI / build-android-apk (push) Successful in 59m5s
Looking at the changes:

1. **New files**:
   - `app/(app)/(tabs)/profile/account-deletion.tsx` - account deletion screen
   - `app/(app)/(tabs)/profile/privacy-settings.tsx` - privacy settings screen
   - `src/screens/profile/AccountDeletionScreen.tsx` - account deletion implementation
   - `src/screens/profile/PrivacySettingsScreen.tsx` - privacy settings implementation

2. **Modified files**:
   - Database changes: `LocalDataSource.ts`, `DatabaseManager.ts`, `DatabaseConfig.ts`, `MigrationManager.ts` - refactoring database layer (removing LockManager, simplified initialization, added retry logic)
   - Auth store: `authStore.ts` - changed `initDatabase` to `switchDatabase`
   - Auth service: `authService.ts` - added privacy settings and account deletion methods
   - DTOs: `dto.ts` - added PrivacySettingsDTO, DeletionStatusDTO, VoteOptionDTO, VoteResultDTO types
   - Navigation: `hrefs.ts` - added new href functions for privacy settings and account deletion
   - Profile screens: `SettingsScreen.tsx`, `index.ts`, `_layout.tsx` - added new menu items and routes
   - Various message screens - added keyboard handling props
   - `app/_layout.tsx` - added CSS for mobile touch handling
   - `CreatePostScreen.tsx` - fixed vote_options format
   - `PostDetailScreen.tsx` - added type annotations for vote handling
   - `MediaCacheManager.ts` - updated to use new expo-file-system API
   - `VerificationSettingsScreen.tsx` - fixed type safety
   - `AccountSecurityScreen.tsx` - styling refactoring

This is a multi-faceted change involving:

Database refactoring, new privacy/account deletion features, bug fixes, and UI improvements. The database changes appear to be refactoring without new functionality. The most significant changes are the privacy settings and account deletion features, plus bug fixes like the vote options format and mobile touch handling.
</think>

feat(profile): add privacy settings and account deletion screens

Add privacy settings and account deletion functionality with new screens and APIs. Refactor database initialization to use switchDatabase for user switching. Fix vote options format in post creation and add type safety to vote handling. Improve mobile touch handling in layout and message screens. Update media cache to use new expo-file-system API.

BREAKING CHANGE: Database initialization now uses switchDatabase instead of initDatabase for user switching
2026-04-08 16:48:19 +08:00
lafay
96a5207cf8 feat(home): integrate PagerView for tab navigation and add verification redirect
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 9m20s
Frontend CI / ota-android (push) Successful in 14m32s
Frontend CI / build-android-apk (push) Successful in 1h17m23s
Replace custom gesture handling with PagerView component for smoother tab
switching on home screen. Add verification required redirects in API and
WebSocket services that automatically navigate users to the verification
guide when receiving VERIFICATION_REQUIRED error codes.

BREAKING CHANGE: API and WS services now require router to be set via
setExpoRouter/setRouter for navigation to work properly
2026-04-08 02:38:48 +08:00
lafay
25194313ae feat(profile): add data storage settings screen with cache management
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 15m38s
Frontend CI / ota-android (push) Successful in 16m54s
Frontend CI / build-android-apk (push) Failing after 8m29s
Add DataStorageScreen for managing media cache and storage:
- Display cache statistics (image/video/audio counts and sizes)
- Show AsyncStorage usage information
- Add clear all cache functionality
- Add cleanup expired cache option (7 days threshold)
- Add storage usage breakdown by category
- Add "Last cleaned" timestamp display

Also fix MediaCacheManager to skip native cache operations on web platform:
- Return original URI directly on web without caching
- Skip directory creation on web
- Skip file existence checks on web
- Skip startup and periodic cleanup on web
2026-04-07 16:27:02 +08:00
lafay
445c1c5561 feat(auth): add user identity verification system with new screens
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 4m54s
Frontend CI / ota-android (push) Failing after 7m28s
Frontend CI / build-android-apk (push) Has been cancelled
Add comprehensive identity verification flow including:
- New verification guide screen at /verification-guide for selecting identity type
- New verification form screen at /verification-form for submitting verification documents
- Verification settings screen at /profile/verification for managing verification status
- Move terms and privacy policy to root-level routes (/terms, /privacy)
- Update login to require active terms agreement checkbox
- Database: add in-memory fallback when OPFS is unavailable on web

BREAKING CHANGE: Terms and privacy policy routes moved from /profile/terms and /profile/privacy to /terms and /privacy
2026-04-07 15:58:51 +08:00
lafay
accf7c04e8 feat(auth): enhance password policy with stronger requirements
Some checks failed
Frontend CI / build-android-apk (push) Failing after 8m40s
Frontend CI / ota-android (push) Successful in 12m13s
Frontend CI / build-and-push-web (push) Successful in 2m47s
Update password validation across authentication screens to require minimum 8 characters with uppercase letters, lowercase letters, and numbers. Changes applied to ForgotPasswordScreen, RegisterStep3Profile, and AccountSecurityScreen. LoginScreen password validation was removed as it occurs post-authentication.
2026-04-07 00:12:03 +08:00
lafay
542d385d08 feat(verification): add user identity verification system
Some checks failed
Frontend CI / build-android-apk (push) Failing after 9m29s
Frontend CI / build-and-push-web (push) Failing after 1m12s
Frontend CI / ota-android (push) Failing after 7m29s
Add comprehensive user verification feature including:
- VerificationGuideScreen: Guide screen explaining the verification process
- VerificationFormScreen: Form for submitting verification applications
- VerificationSettingsScreen: Settings page to view verification status
- verificationService: API service for verification operations
- verificationStore: State management for verification state
- DTOs: Types for verification records, status, and admin operations

The system supports multiple identity types (student, teacher, staff, alumni, external)
and includes both user-facing and admin-facing functionality.
2026-04-05 20:26:51 +08:00
lafay
82c2970a85 refactor(database): migrate to new modular database layer and unify data access
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m44s
Frontend CI / ota-android (push) Successful in 12m51s
Frontend CI / build-android-apk (push) Successful in 1h1m26s
- Remove legacy database.ts, LocalDataSource.ts, and MessageRepository.ts
- Create new src/database/ module with messageRepository, userCacheRepository, conversationRepository, and groupCacheRepository
- Update all consumers to import from @/database instead of services/database
- Add web platform blur handling for modal components to fix focus issues
- Flatten SystemMessageItem and NotificationsScreen styles for consistent design
- Add draggable slider in ChatSettingsScreen and dynamic font size support
- Introduce 9 new chat color themes
- Add profile screens for about, terms, and privacy policy with navigation routes
- Add policy links to login and registration screens
- Fix post share URL format from /posts/ to /post/
2026-04-04 08:01:45 +08:00
lafay
189b977fac fix(database): improve web environment database connection handling
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m46s
Frontend CI / ota-android (push) Successful in 12m40s
Frontend CI / build-android-apk (push) Successful in 1h2m43s
- Add pre-open check for web to release potentially stale OPFS handles
- Unify close logic across platforms instead of skipping for web
- Add proper logging for connection lifecycle operations
- Remove obsolete error check for "cannot create file" during retries
2026-04-04 02:38:56 +08:00
lafay
69717ea407 build(deploy): add nginx configuration for web application serving
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 4m28s
Frontend CI / ota-android (push) Successful in 11m33s
Frontend CI / build-android-apk (push) Successful in 1h3m22s
Add nginx.conf to serve the Expo web build and update Dockerfile.web to copy the configuration into the nginx container. This enables proper static file serving with appropriate MIME types and default caching headers for the production web deployment.
2026-04-04 00:15:51 +08:00
lafay
775deeb9c4 refactor(database): unify database connections and add retry logic
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 3m18s
Frontend CI / ota-android (push) Successful in 12m16s
Frontend CI / build-android-apk (push) Has been cancelled
Refactor LocalDataSource to reuse the shared database connection from database.ts, avoiding OPFS file handle conflicts. Add retry logic with exponential backoff for database open operations, particularly important for Web/OPFS environments. Also add dynamic theme support to chat input and message bubble styles, using theme colors instead of hardcoded values for better light/dark mode support.

BREAKING CHANGE: Database initialization now requires userId to be passed explicitly for user-specific databases
2026-04-04 00:02:22 +08:00
lafay
e8651215f7 feat(chat): implement dynamic theme support in chat components
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 7m31s
Frontend CI / ota-android (push) Successful in 13m19s
Frontend CI / build-android-apk (push) Successful in 1h10m3s
- Enhanced ChatHeader and ChatInput components to support dynamic theme colors, improving visual consistency across different themes.
- Updated styles in ChatScreen to utilize new dynamic styles for better responsiveness and user experience.
- Refactored styles in various profile screens to adopt a unified background color scheme and improved spacing for a cleaner layout.

This update significantly enhances the chat interface and profile settings by allowing for dynamic theming and improved visual elements.
2026-04-02 17:55:56 +08:00
lafay
72842352d9 feat(chat): enhance chat settings and message bubble styles
All checks were successful
Frontend CI / ota-android (push) Successful in 12m37s
Frontend CI / build-and-push-web (push) Successful in 22m42s
Frontend CI / build-android-apk (push) Successful in 57m42s
- Introduced dynamic chat settings for font size and message bubble radius, improving user customization.
- Updated ChatScreen styles to support dynamic themes and responsive layouts.
- Refactored bubble styles to utilize dynamic properties for better visual consistency.
- Simplified chat settings management by integrating Zustand store for state management.

This update significantly enhances the chat interface and user experience by allowing personalized settings and improved visual elements.
2026-04-01 16:30:44 +08:00
lafay
c771bd9755 feat(auth): implement multi-step registration process and enhance background services
All checks were successful
Frontend CI / ota-android (push) Successful in 13m20s
Frontend CI / build-and-push-web (push) Successful in 14m23s
Frontend CI / build-android-apk (push) Successful in 59m10s
- Updated RegisterScreen to support a multi-step registration flow with components for email input, verification, and profile setup.
- Added new components: VerificationCodeInput, StepIndicator, and corresponding types for step management.
- Enhanced background service to check authentication status before executing tasks, improving app reliability.
- Updated TypeScript configuration to include module resolution and JSON module support.

This update significantly improves the user registration experience and ensures background tasks are only executed for authenticated users.
2026-04-01 04:48:38 +08:00
lafay
5614b4078a feat(profile): add chat settings and language options to profile settings
All checks were successful
Frontend CI / ota-android (push) Successful in 13m7s
Frontend CI / build-and-push-web (push) Successful in 16m58s
Frontend CI / build-android-apk (push) Successful in 1h4m18s
- Introduced hrefProfileChatSettings() for navigation to chat settings.
- Updated SettingsScreen to include new settings items for chat settings, language selection, data storage, terms, and privacy policy.
- Enhanced user experience by providing alerts for language and data storage options.

This update improves the profile settings interface by adding more customization options for users.
2026-04-01 02:17:36 +08:00
lafay
20e9d69540 feat(auth): redesign auth screens with clean form style and add welcome page
All checks were successful
Frontend CI / ota-android (push) Successful in 11m41s
Frontend CI / build-and-push-web (push) Successful in 21m57s
Frontend CI / build-android-apk (push) Successful in 1h0m58s
- Replace split/responsive layout with unified clean form design
- Update LoginScreen, RegisterScreen, and ForgotPasswordScreen styles
- Add WelcomeScreen as new entry point for unauthenticated users
- Redirect unauthenticated users to /welcome instead of /login
- Add hrefAuthWelcome() navigation helper

BREAKING CHANGE: Auth screens no longer support split layout, unified mobile-first design
2026-04-01 00:50:38 +08:00
lafay
259de04f3e refactor(message): replace SubscriptionManager with Zustand selectors
All checks were successful
Frontend CI / ota-android (push) Successful in 11m17s
Frontend CI / build-and-push-web (push) Successful in 25m8s
Frontend CI / build-android-apk (push) Successful in 59m7s
Remove custom SubscriptionManager class and adopt Zustand's built-in
selector mechanism for reactive state updates. This simplifies the
architecture by eliminating manual subscription management and relying
on Zustand's automatic dependency tracking for component re-renders.

Key changes:
- Remove SubscriptionManager class from store.ts
- Replace all notifySubscribers calls with direct store updates
- Update hooks to use Zustand selectors with useShallow for complex data
- Remove subscribe/unsubscribe patterns from MessageManager
- Simplify EmbeddedChat to use selector instead of local state
- Rename requestConversationListRefresh to refreshConversations
2026-03-31 19:15:13 +08:00
lafay
94c11062f0 refactor(message): replace MessageStateManager with zustand store
Some checks failed
Frontend CI / ota-android (push) Successful in 11m15s
Frontend CI / build-and-push-web (push) Successful in 29m13s
Frontend CI / build-android-apk (push) Has been cancelled
- Remove MessageStateManager wrapper layer in favor of direct zustand store
- Move service modules to services/ subdirectory (ConversationOperations, MessageDeduplication, MessageSendService, MessageSyncService, ReadReceiptManager, UserCacheService, WSMessageHandler)
- Add new zustand-based store with subscriptionManager for event handling
- Introduce React hooks for message state (useConversations, useMessages, useUnreadCount, etc.)
- Update exports in index.ts to reflect new module structure
- Deprecate messageManager.ts entry point in favor of message/index.ts
- Maintain backward compatibility with existing MessageManager API
2026-03-31 18:22:24 +08:00
lafay
1bee7ea551 refactor(message): modularize MessageManager architecture
All checks were successful
Frontend CI / ota-android (push) Successful in 11m20s
Frontend CI / build-and-push-web (push) Successful in 34m19s
Frontend CI / build-android-apk (push) Successful in 57m45s
重构消息管理模块,将单体 MessageManager 拆分为职责单一的子模块:

- MessageStateManager: 纯状态管理
- MessageDeduplication: 消息去重服务
- UserCacheService: 用户缓存服务
- ReadReceiptManager: 已读回执管理
- ConversationOperations: 会话操作
- MessageSendService: 消息发送
- MessageSyncService: 消息同步
- WSMessageHandler: WebSocket 消息处理

新增类型定义和常量文件,保持原有 API 向后兼容。修复 CallScreen 状态值错误,补充 PostDetailScreen 缺失样式,优化 GroupInfoPanel 导入。
2026-03-31 16:13:24 +08:00
lafay
2ef267a897 refactor(TabBar, HomeScreen): update styles for improved UI consistency
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 3m2s
Frontend CI / ota-android (push) Successful in 15m35s
Frontend CI / build-android-apk (push) Successful in 1h19m59s
- Adjusted TabBar styles for better shadow effects and padding.
- Modified HomeScreen layout margins and padding for enhanced spacing.
- Updated font weight for modernTabText to improve text visibility.
- Changed active opacity for tab interactions to enhance user experience.
2026-03-30 18:01:32 +08:00
lafay
774b5c4b47 feat(CommentItem, PostDetailScreen, ReportDialog): integrate report functionality
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 2m54s
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled
- Added report button to CommentItem for reporting comments and replies.
- Integrated ReportDialog into PostDetailScreen for handling report submissions.
- Enhanced ReportDialog with detailed report reasons and improved UI interactions.
- Updated styles across various components for a more modern look and feel.

Made-with: Cursor
2026-03-30 17:53:30 +08:00
lafay
e0ee29caf8 feat: 添加举报功能前端支持
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m45s
Frontend CI / ota-android (push) Successful in 12m44s
Frontend CI / build-android-apk (push) Successful in 52m27s
- 新增 reportService API 服务
- 新增 ReportDialog 举报对话框组件
- 集成举报入口到 PostCard、CommentItem、LongPressMenu

Made-with: Cursor
2026-03-29 20:18:49 +08:00
lafay
584d98307c feat(ChatScreen): 优化聊天界面 UI 和交互体验
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m40s
Frontend CI / ota-android (push) Successful in 13m12s
Frontend CI / build-android-apk (push) Successful in 55m45s
Made-with: Cursor
2026-03-29 03:04:54 +08:00
e969e5bad4 Merge pull request '双端适配,web端的修改父容器和接口' (#7) from adapt into dev
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 4m10s
Frontend CI / ota-android (push) Successful in 12m56s
Frontend CI / build-android-apk (push) Has been cancelled
Reviewed-on: #7
2026-03-29 02:39:05 +08:00
ebb0c003e3 双端适配,web端的修改父容器和接口 2026-03-29 02:34:13 +08:00
lafay
3c071957ce feat(CallScreen, FloatingCallWindow, callStore, wsService): enhance call status handling and UI updates
All checks were successful
Frontend CI / ota-android (push) Successful in 11m58s
Frontend CI / build-and-push-web (push) Successful in 15m49s
Frontend CI / build-android-apk (push) Successful in 1h27m7s
- Updated call status messages to include 'calling', 'reconnecting', and 'failed' states for better user feedback.
- Improved video handling logic in CallScreen to use currentCall.isVideoEnabled for local video display.
- Enhanced callStore to manage new call statuses and ensure proper timeout handling during 'calling' state.
- Added a method in wsService to retrieve the active call state after WebSocket reconnections.
- Refactored FloatingCallWindow to reflect updated call status messages for consistency across components.
2026-03-28 04:35:05 +08:00
lafay
fa10ef5116 fix(WebRTCManager, callStore): improve signaling state handling and media track management
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m53s
Frontend CI / ota-android (push) Successful in 11m0s
Frontend CI / build-android-apk (push) Successful in 1h27m45s
- Enhanced ontrack event handling to construct remote streams and avoid duplicate tracks during renegotiation.
- Updated negotiation logic to ensure it only occurs when the signaling state is stable and the instance is the initiator.
- Added diagnostic logging for offer and answer SDP to assist in debugging video track handling.
- Improved rollback logic in callStore to handle signaling state checks more robustly after rollback attempts.
2026-03-28 01:41:26 +08:00
lafay
7c33409624 feat(wsService, WebRTCManager): enhance call handling with media type support and negotiation improvements
Some checks failed
Frontend CI / ota-android (push) Successful in 11m25s
Frontend CI / build-and-push-web (push) Successful in 11m30s
Frontend CI / build-android-apk (push) Has been cancelled
- Added optional media_type to WSCallIncomingMessage for better call type differentiation.
- Updated call_type assignment in WebSocketService to prioritize media_type if available.
- Removed initialOfferCreated flag in WebRTCManager to simplify negotiation logic.
- Improved negotiation handling by releasing the negotiation lock after offer creation, ensuring smoother call setup.
2026-03-28 01:06:51 +08:00
lafay
f6176c945b feat(CallScreen): enhance video call functionality and UI updates
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 3m13s
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled
- Integrated video call support by adding local and remote video stream handling.
- Implemented state management for video track detection in both local and peer streams.
- Updated UI to conditionally render video components based on the presence of video tracks.
- Added video toggle functionality to enable or disable local video during calls.
- Enhanced incoming call modal to display call type (voice or video).
- Refactored call store to manage call types and video state more effectively.
2026-03-28 00:56:52 +08:00
lafay
b19a2ced6f feat(CallFeature): implement call functionality and integrate WebSocket signaling
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 5m2s
Frontend CI / ota-android (push) Successful in 11m28s
Frontend CI / build-android-apk (push) Successful in 1h0m58s
- Updated app.json to include microphone permissions and background audio support.
- Added call-related dependencies in package.json and package-lock.json.
- Enhanced ChatScreen to initiate calls and handle call actions.
- Introduced call components in the layout for incoming call handling.
- Expanded WebSocket service to manage call signaling messages and events.
- Refactored authStore to initialize call state on user authentication.
2026-03-27 17:12:19 +08:00
lafay
db7885086f refactor(wsService): improve WebSocket connection handling and logging
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 5m51s
Frontend CI / ota-android (push) Successful in 16m58s
Frontend CI / build-android-apk (push) Successful in 1h20m19s
- Updated getWSUrl method to accept a token parameter for better flexibility.
- Enhanced connection logic with additional logging for connection status and errors.
- Improved error handling during connection attempts and disconnections.
- Added checks to prevent duplicate handling of disconnections.
- Refactored reconnect logic to provide clearer feedback on reconnection attempts.
2026-03-26 22:05:07 +08:00
lafay
ba99900624 refactor(WebSocket): migrate from SSE to WebSocket for real-time messaging
- Replaced SSEClient with WSClient for handling real-time messaging.
- Updated ProcessMessageUseCase to initialize WebSocket listeners.
- Refactored messageService to prioritize WebSocket for sending messages, with fallback to HTTP.
- Removed sseService and adjusted imports across the application to utilize wsService.
- Enhanced message handling and connection management for improved performance and reliability.
2026-03-26 22:04:46 +08:00
lan
4b89b50006 refactor(stores): unify data sources pattern and extract BaseManager base class
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 3m4s
Frontend CI / ota-android (push) Successful in 11m9s
Frontend CI / build-android-apk (push) Successful in 1h23m57s
- Add BaseManager/CachedManager base classes to eliminate duplicate cache logic
- Add postListSources.ts with IPostListPagedSource interface
- Add groupListSources.ts with IGroupListPagedSource interface
- Refactor postManager to use CachedManager and Sources pattern
- Refactor groupManager to use CachedManager and Sources pattern
- Clean up duplicate methods in groupService.ts
- Fix TypeScript errors and remove mock data
2026-03-26 16:46:22 +08:00
lafay
b6583e07c8 feat(TextComponent): enhance text styling with dynamic font weights and improved layout
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 3m7s
Frontend CI / ota-android (push) Successful in 13m37s
Frontend CI / build-android-apk (push) Failing after 54m7s
- Updated Text component to support dynamic font weights, allowing for more flexible text styling.
- Refactored variant styles to utilize new font weight constants for better consistency across text variants.
- Introduced letter spacing adjustments for improved readability and visual appeal.
- Added font family configuration for iOS and Android to optimize typography across platforms.
2026-03-26 03:58:09 +08:00
lafay
6d1514b2d1 refactor(ChatScreen): update text colors for dark mode compatibility
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 3m15s
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled
- Modified text color styles in SegmentRenderer and styles.ts to utilize theme colors for better adaptability in dark mode.
- Updated bubble colors in palettes.ts to enhance visual consistency across chat components.
2026-03-26 03:51:26 +08:00
lafay
d280ad1656 feat(APKUpdate): conditionally import native modules for APK updates on Android
Some checks failed
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled
Frontend CI / build-and-push-web (push) Has been cancelled
- Implemented conditional imports for `expo-file-system` and `expo-intent-launcher` to ensure compatibility with non-Android environments.
- Added error handling to alert users when native modules are unavailable, directing them to download APKs via the browser.
- Enhanced the `installAPK` function to check for the availability of native modules before proceeding with installation.
2026-03-26 03:45:39 +08:00
lafay
c903990aaf feat(APKUpdate): implement APK update check and enhance build workflow
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
- Added `expo-intent-launcher` dependency to support APK launching functionality.
- Introduced `APKUpdateBootstrap` component to check for APK updates during app initialization.
- Enhanced build workflow in `build.yml` to resolve runtime version and upload APK to the updates server.
- Updated `HomeScreen` and `TabsLayout` to manage tab visibility and scroll behavior based on user interactions.
- Refactored message bubble styles for improved UI consistency and user experience.
2026-03-26 03:41:43 +08:00
lafay
405cd271db feat(Comment): enhance like functionality for comments and replies
Some checks failed
Frontend CI / build-and-push-web (push) Failing after 1m54s
Frontend CI / ota-android (push) Failing after 5m36s
Frontend CI / build-android-apk (push) Failing after 9m3s
- Updated CommentItem component to accept the comment object in the onLike callback, allowing for more detailed like handling.
- Added a like button for replies, improving user interaction with nested comments.
- Refactored handleLikeComment function in PostDetailScreen for optimized state management and error handling during like operations.
- Enhanced SettingsScreen to provide debug information for theme preferences, improving user experience in theme selection.
- Updated themeStore to dynamically manage system theme changes and improve overall theme handling.
2026-03-26 01:25:42 +08:00
lafay
9529ea39c4 feat(Materials): add new materials navigation and href functions
All checks were successful
Frontend CI / build-and-push-web (push) Successful in 2m59s
Frontend CI / ota-android (push) Successful in 18m11s
Frontend CI / build-android-apk (push) Successful in 1h21m54s
- Introduced href functions for accessing materials, including hrefMaterials, hrefMaterialSubject, and hrefMaterialDetail.
- Updated AppsScreen to include a new entry for materials, enhancing the app's educational resources section.
- Exported material types in index.ts to support the new materials functionality.
2026-03-25 21:17:17 +08:00
216 changed files with 24970 additions and 9945 deletions

View File

@@ -272,6 +272,42 @@ jobs:
name: carrot-bbs-android-release-apk name: carrot-bbs-android-release-apk
path: android/app/build/outputs/apk/release/app-release.apk path: android/app/build/outputs/apk/release/app-release.apk
- name: Resolve runtime version
id: runtime
run: |
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
- name: Upload APK to Updates Server
env:
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
UPDATES_SERVER_URL: https://updates.littlelan.cn
run: |
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
APK_PATH="android/app/build/outputs/apk/release/app-release.apk"
RUNTIME_VERSION="${{ steps.runtime.outputs.runtime_version }}"
echo "Uploading APK for runtime version: ${RUNTIME_VERSION}"
HTTP_CODE=$(curl -s -o response.json -w "%{http_code}" \
-X POST \
"${UPDATES_SERVER_URL}/admin/apk/upload?runtimeVersion=${RUNTIME_VERSION}" \
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
-H "Content-Type: application/vnd.android.package-archive" \
--data-binary @"${APK_PATH}")
echo "HTTP Response Code: ${HTTP_CODE}"
cat response.json
if [ "${HTTP_CODE}" -ne 200 ]; then
echo "Failed to upload APK"
exit 1
fi
echo "APK uploaded successfully!"
build-and-push-web: build-and-push-web:
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:

View File

@@ -10,6 +10,7 @@ RUN npx expo export --platform web --output-dir dist-web
FROM nginx:1.27-alpine FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist-web /usr/share/nginx/html COPY --from=builder /app/dist-web /usr/share/nginx/html
EXPOSE 80 EXPOSE 80

View File

@@ -23,6 +23,29 @@ const devUpdatesUrl =
const expo = appJson.expo; const expo = appJson.expo;
// 检测是否为 Web 平台
const isWeb = process.env.EXPO_TARGET === 'web' || process.env.PLATFORM === 'web';
// 原生平台特有的插件(在 Web 端会报错)
const nativeOnlyPlugins = [
'expo-camera',
'expo-notifications',
'expo-background-fetch',
'expo-media-library',
'expo-image-picker',
'expo-video',
'expo-sqlite',
'./plugins/withMainActivityConfigChange',
];
// 过滤插件Web 端排除原生插件
const filteredPlugins = isWeb
? expo.plugins.filter((plugin) => {
const pluginName = Array.isArray(plugin) ? plugin[0] : plugin;
return !nativeOnlyPlugins.includes(pluginName);
})
: expo.plugins;
module.exports = { module.exports = {
...expo, ...expo,
name: isDevVariant ? `${expo.name} Dev` : expo.name, name: isDevVariant ? `${expo.name} Dev` : expo.name,
@@ -40,10 +63,27 @@ module.exports = {
...expo.android, ...expo.android,
package: 'skin.carrot.bbs', package: 'skin.carrot.bbs',
}, },
// Web 端使用过滤后的插件
plugins: filteredPlugins,
extra: { extra: {
...(expo.extra || {}), ...(expo.extra || {}),
appVariant: isDevVariant ? 'dev' : 'release', appVariant: isDevVariant ? 'dev' : 'release',
apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl, apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl,
updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl, updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
}, },
// Web 端配置
...(isWeb && {
web: {
...expo.web,
build: {
...expo.web?.build,
// 配置 Web 端别名,避免加载原生模块
babel: {
dangerouslyAddModulePathsToTranspile: [
'react-native-webrtc',
],
},
},
},
}),
}; };

View File

@@ -2,7 +2,7 @@
"expo": { "expo": {
"name": "萝卜社区", "name": "萝卜社区",
"slug": "qojo", "slug": "qojo",
"version": "1.0.11", "version": "1.0.12",
"orientation": "default", "orientation": "default",
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"userInterfaceStyle": "automatic", "userInterfaceStyle": "automatic",
@@ -15,11 +15,13 @@
"ios": { "ios": {
"supportsTablet": true, "supportsTablet": true,
"infoPlist": { "infoPlist": {
"NSMicrophoneUsageDescription": "允许萝卜社区访问您的麦克风以进行语音通话",
"UIBackgroundModes": [ "UIBackgroundModes": [
"fetch", "fetch",
"remote-notification", "remote-notification",
"fetch", "fetch",
"remote-notification" "remote-notification",
"audio"
], ],
"ITSAppUsesNonExemptEncryption": false "ITSAppUsesNonExemptEncryption": false
}, },
@@ -37,6 +39,7 @@
"versionCode": 6, "versionCode": 6,
"permissions": [ "permissions": [
"VIBRATE", "VIBRATE",
"RECORD_AUDIO",
"RECEIVE_BOOT_COMPLETED", "RECEIVE_BOOT_COMPLETED",
"WAKE_LOCK", "WAKE_LOCK",
"READ_EXTERNAL_STORAGE", "READ_EXTERNAL_STORAGE",
@@ -59,6 +62,7 @@
"favicon": "./assets/favicon.png" "favicon": "./assets/favicon.png"
}, },
"plugins": [ "plugins": [
"./plugins/withMainActivityConfigChange",
[ [
"expo-router", "expo-router",
{ {

View File

@@ -1,4 +1,4 @@
import { useMemo } from 'react'; import { useMemo, useCallback } from 'react';
import { Platform, useWindowDimensions } from 'react-native'; import { Platform, useWindowDimensions } from 'react-native';
import { Tabs, usePathname } from 'expo-router'; import { Tabs, usePathname } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
@@ -6,7 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useAppColors, shadows } from '../../../src/theme'; import { useAppColors, shadows } from '../../../src/theme';
import { BREAKPOINTS } from '../../../src/hooks/useResponsive'; import { BREAKPOINTS } from '../../../src/hooks/useResponsive';
import { useHomeTabBarVisibilityStore, useTotalUnreadCount } from '../../../src/stores'; import { useHomeTabBarVisibilityStore, useTotalUnreadCount, useHomeTabPressStore } from '../../../src/stores';
const TAB_BAR_HEIGHT = 56; const TAB_BAR_HEIGHT = 56;
const TAB_BAR_FLOATING_MARGIN = 12; const TAB_BAR_FLOATING_MARGIN = 12;
@@ -19,10 +19,17 @@ export default function TabsLayout() {
const pathname = usePathname(); const pathname = usePathname();
const unreadCount = useTotalUnreadCount(); const unreadCount = useTotalUnreadCount();
const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll); const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
const triggerHomeTabPress = useHomeTabPressStore((s) => s.triggerPress);
const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop; const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop;
const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/'); const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/');
const handleHomeTabPress = useCallback(() => {
if (pathname === '/home') {
triggerHomeTabPress();
}
}, [pathname, triggerHomeTabPress]);
const tabBarStyle = useMemo(() => { const tabBarStyle = useMemo(() => {
if (hideTabBar) { if (hideTabBar) {
return { display: 'none' as const, height: 0, overflow: 'hidden' as const }; return { display: 'none' as const, height: 0, overflow: 'hidden' as const };
@@ -81,6 +88,9 @@ export default function TabsLayout() {
/> />
), ),
}} }}
listeners={{
tabPress: handleHomeTabPress,
}}
/> />
<Tabs.Screen <Tabs.Screen
name="apps" name="apps"

View File

@@ -0,0 +1,11 @@
import { Stack } from 'expo-router';
export default function MaterialsStackLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="subject" />
<Stack.Screen name="detail" />
</Stack>
);
}

View File

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

View File

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

View File

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

View File

@@ -36,6 +36,14 @@ export default function ProfileStackLayout() {
<Stack.Screen name="bookmarks" options={{ title: '收藏' }} /> <Stack.Screen name="bookmarks" options={{ title: '收藏' }} />
<Stack.Screen name="notification-settings" options={{ title: '通知设置' }} /> <Stack.Screen name="notification-settings" options={{ title: '通知设置' }} />
<Stack.Screen name="blocked-users" options={{ title: '黑名单' }} /> <Stack.Screen name="blocked-users" options={{ title: '黑名单' }} />
<Stack.Screen name="chat-settings" options={{ title: '聊天设置' }} />
<Stack.Screen name="about" options={{ title: '关于我们' }} />
<Stack.Screen name="terms" options={{ title: '用户协议' }} />
<Stack.Screen name="privacy" options={{ title: '隐私政策' }} />
<Stack.Screen name="verification" options={{ title: '身份认证' }} />
<Stack.Screen name="data-storage" options={{ title: '数据与存储' }} />
<Stack.Screen name="privacy-settings" options={{ title: '隐私设置' }} />
<Stack.Screen name="account-deletion" options={{ title: '注销账号' }} />
</Stack> </Stack>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -10,6 +10,8 @@ import * as Notifications from 'expo-notifications';
import * as SystemUI from 'expo-system-ui'; import * as SystemUI from 'expo-system-ui';
import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences'; import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences';
import { api } from '../src/services/api';
import { wsService } from '../src/services/wsService';
import { import {
ThemeBootstrap, ThemeBootstrap,
useAppColors, useAppColors,
@@ -21,6 +23,8 @@ import AppPromptBar from '../src/components/common/AppPromptBar';
import AppDialogHost from '../src/components/common/AppDialogHost'; import AppDialogHost from '../src/components/common/AppDialogHost';
import { installAlertOverride } from '../src/services/alertOverride'; import { installAlertOverride } from '../src/services/alertOverride';
import { useAuthStore } from '../src/stores'; import { useAuthStore } from '../src/stores';
import { checkForAPKUpdate } from '../src/services/apkUpdateService';
import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call';
registerNotificationPresentationHandler(); registerNotificationPresentationHandler();
@@ -48,6 +52,25 @@ if (Platform.OS === 'web' && typeof document !== 'undefined') {
} }
*:focus { outline: none !important; } *:focus { outline: none !important; }
*:focus-visible { outline: none !important; } *:focus-visible { outline: none !important; }
/* 修复移动端 FlatList/ScrollView 滑动问题 */
html, body {
overscroll-behavior: none;
touch-action: pan-y;
}
/* React Native Web 生成的滚动容器 */
[class*="css-"] {
touch-action: pan-y !important;
-webkit-overflow-scrolling: touch !important;
}
/* 确保所有可滚动元素都可以垂直滑动 */
div[style*="overflow"] {
touch-action: pan-y !important;
-webkit-overflow-scrolling: touch !important;
}
/* 禁用水平滑动,只允许垂直滑动 */
* {
touch-action: pan-y pinch-zoom;
}
`; `;
document.head.appendChild(style); document.head.appendChild(style);
} }
@@ -137,21 +160,71 @@ function NotificationBootstrap() {
return null; return null;
} }
function APKUpdateBootstrap() {
const hasChecked = useRef(false);
useEffect(() => {
if (hasChecked.current) return;
hasChecked.current = true;
// 延迟执行,避免与启动流程冲突
const timer = setTimeout(() => {
checkForAPKUpdate().catch((error) => {
console.error('APK update check failed:', error);
});
}, 3000);
return () => clearTimeout(timer);
}, []);
return null;
}
function ThemedStack() { function ThemedStack() {
const router = useRouter(); const router = useRouter();
const colors = useAppColors(); const colors = useAppColors();
const resolved = useResolvedColorScheme(); const resolved = useResolvedColorScheme();
useEffect(() => {
api.setExpoRouter(router);
wsService.setRouter(router);
}, [router]);
return ( return (
<> <>
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} /> <StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
<SystemChrome /> <SystemChrome />
<SessionGate> <SessionGate>
<NotificationBootstrap /> <NotificationBootstrap />
<APKUpdateBootstrap />
<Stack screenOptions={{ headerShown: false }}> <Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" options={{ headerShown: false }} /> <Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} /> <Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="(app)" options={{ headerShown: false }} /> <Stack.Screen name="(app)" options={{ headerShown: false }} />
<Stack.Screen
name="terms"
options={{
headerShown: true,
title: '用户协议',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
headerShadowVisible: false,
headerBackVisible: false,
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
}}
/>
<Stack.Screen
name="privacy"
options={{
headerShown: true,
title: '隐私政策',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
headerShadowVisible: false,
headerBackVisible: false,
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
}}
/>
<Stack.Screen <Stack.Screen
name="post/[postId]" name="post/[postId]"
options={{ options={{
@@ -197,6 +270,9 @@ export default function RootLayout() {
<ThemedStack /> <ThemedStack />
<AppPromptBar /> <AppPromptBar />
<AppDialogHost /> <AppDialogHost />
<IncomingCallModal />
<CallScreen />
<FloatingCallWindow />
</QueryClientProvider> </QueryClientProvider>
</ThemedProviders> </ThemedProviders>
</SafeAreaProvider> </SafeAreaProvider>

View File

@@ -7,5 +7,5 @@ export default function Index() {
if (isAuthenticated) { if (isAuthenticated) {
return <Redirect href="/home" />; return <Redirect href="/home" />;
} }
return <Redirect href="/login" />; return <Redirect href="/welcome" />;
} }

5
app/privacy.tsx Normal file
View File

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

5
app/terms.tsx Normal file
View File

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

5
app/welcome.tsx Normal file
View File

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

View File

@@ -6,6 +6,10 @@ const config = getDefaultConfig(__dirname);
// Add wasm asset support // Add wasm asset support
config.resolver.assetExts.push('wasm'); config.resolver.assetExts.push('wasm');
// Fix for react-native-webrtc event-target-shim import warning
// The package doesn't properly export "./index" in its exports field
config.resolver.unstable_enablePackageExports = false;
// NOTE: enhanceMiddleware is marked as deprecated in Metro's types, // NOTE: enhanceMiddleware is marked as deprecated in Metro's types,
// but there's currently no official alternative for custom dev server headers. // but there's currently no official alternative for custom dev server headers.
// For production, configure COEP/COOP headers on your hosting platform. // For production, configure COEP/COOP headers on your hosting platform.

19
nginx.conf Normal file
View File

@@ -0,0 +1,19 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
gzip_min_length 256;
}

64
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "carrot_bbs", "name": "carrot_bbs",
"version": "1.0.1", "version": "1.0.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "carrot_bbs", "name": "carrot_bbs",
"version": "1.0.1", "version": "1.0.2",
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.1.1", "@expo/vector-icons": "^15.1.1",
"@react-native-async-storage/async-storage": "^2.2.0", "@react-native-async-storage/async-storage": "^2.2.0",
@@ -29,6 +29,7 @@
"expo-haptics": "~55.0.8", "expo-haptics": "~55.0.8",
"expo-image": "^55.0.5", "expo-image": "^55.0.5",
"expo-image-picker": "^55.0.10", "expo-image-picker": "^55.0.10",
"expo-intent-launcher": "~55.0.9",
"expo-linear-gradient": "^55.0.8", "expo-linear-gradient": "^55.0.8",
"expo-media-library": "~55.0.9", "expo-media-library": "~55.0.9",
"expo-notifications": "^55.0.10", "expo-notifications": "^55.0.10",
@@ -53,6 +54,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-webrtc": "^124.0.7",
"react-native-webview": "13.16.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",
@@ -5747,6 +5749,15 @@
"expo": "*" "expo": "*"
} }
}, },
"node_modules/expo-intent-launcher": {
"version": "55.0.9",
"resolved": "https://registry.npmmirror.com/expo-intent-launcher/-/expo-intent-launcher-55.0.9.tgz",
"integrity": "sha512-s8k8dF8PfgzN0c+xzNTd9ceHh+/6mt2ncnMuqFaIIry2BeDGITbsgVijHr39eB5vS+KopCcOYYBYr+O1epx4TA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-json-utils": { "node_modules/expo-json-utils": {
"version": "55.0.0", "version": "55.0.0",
"resolved": "https://registry.npmmirror.com/expo-json-utils/-/expo-json-utils-55.0.0.tgz", "resolved": "https://registry.npmmirror.com/expo-json-utils/-/expo-json-utils-55.0.0.tgz",
@@ -9789,6 +9800,55 @@
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-native-webrtc": {
"version": "124.0.7",
"resolved": "https://registry.npmmirror.com/react-native-webrtc/-/react-native-webrtc-124.0.7.tgz",
"integrity": "sha512-gnXPdbUS8IkKHq9WNaWptW/yy5s6nMyI6cNn90LXdobPVCgYSk6NA2uUGdT4c4J14BRgaFA95F+cR28tUPkMVA==",
"license": "MIT",
"dependencies": {
"base64-js": "1.5.1",
"debug": "4.3.4",
"event-target-shim": "6.0.2"
},
"peerDependencies": {
"react-native": ">=0.60.0"
}
},
"node_modules/react-native-webrtc/node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"license": "MIT",
"dependencies": {
"ms": "2.1.2"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/react-native-webrtc/node_modules/event-target-shim": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-6.0.2.tgz",
"integrity": "sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
},
"funding": {
"url": "https://github.com/sponsors/mysticatea"
}
},
"node_modules/react-native-webrtc/node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"license": "MIT"
},
"node_modules/react-native-webview": { "node_modules/react-native-webview": {
"version": "13.16.0", "version": "13.16.0",
"resolved": "https://registry.npmmirror.com/react-native-webview/-/react-native-webview-13.16.0.tgz", "resolved": "https://registry.npmmirror.com/react-native-webview/-/react-native-webview-13.16.0.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "carrot_bbs", "name": "carrot_bbs",
"version": "1.0.1", "version": "1.0.2",
"main": "expo-router/entry", "main": "expo-router/entry",
"scripts": { "scripts": {
"start": "expo start", "start": "expo start",
@@ -35,6 +35,7 @@
"expo-haptics": "~55.0.8", "expo-haptics": "~55.0.8",
"expo-image": "^55.0.5", "expo-image": "^55.0.5",
"expo-image-picker": "^55.0.10", "expo-image-picker": "^55.0.10",
"expo-intent-launcher": "~55.0.9",
"expo-linear-gradient": "^55.0.8", "expo-linear-gradient": "^55.0.8",
"expo-media-library": "~55.0.9", "expo-media-library": "~55.0.9",
"expo-notifications": "^55.0.10", "expo-notifications": "^55.0.10",
@@ -59,6 +60,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-webrtc": "^124.0.7",
"react-native-webview": "13.16.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",

View File

@@ -0,0 +1,73 @@
const { withMainActivity } = require('@expo/config-plugins');
/**
* 在 MainActivity 中添加 onConfigurationChanged 方法
* 用于监听系统深色模式变化并通知 React Native
*/
const withMainActivityConfigChange = (config) => {
return withMainActivity(config, async (config) => {
const { contents } = config.modResults;
// 检查是否已经添加了 onConfigurationChanged
if (contents.includes('onConfigurationChanged')) {
return config;
}
// 需要添加的 imports
const importsToAdd = `import android.content.Intent
import android.content.res.Configuration`;
// 需要添加的方法
const methodToAdd = `
/**
* 监听系统配置变化(包括深色模式切换),并广播给 React Native
* 这对于 Appearance API 正确检测系统主题至关重要
*/
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
val intent = Intent("onConfigurationChanged")
intent.putExtra("newConfig", newConfig)
sendBroadcast(intent)
}
`;
let newContents = contents;
// 添加 imports在已有 imports 后面)
if (!newContents.includes('import android.content.Intent')) {
// 找到最后一个 import 语句
const importRegex = /import [^\n]+\n/g;
const imports = newContents.match(importRegex);
if (imports && imports.length > 0) {
const lastImport = imports[imports.length - 1];
const lastImportIndex = newContents.lastIndexOf(lastImport);
const insertPosition = lastImportIndex + lastImport.length;
newContents =
newContents.slice(0, insertPosition) +
importsToAdd +
'\n' +
newContents.slice(insertPosition);
}
}
// 在类体的开头添加 onConfigurationChanged 方法
// 找到类定义后的第一个方法或属性
const classBodyRegex = /class MainActivity\s*:\s*ReactActivity\s*\(\)\s*\{([\s\S]*)/;
const match = newContents.match(classBodyRegex);
if (match) {
// 找到 onCreate 方法之前插入
const onCreateIndex = newContents.indexOf('override fun onCreate');
if (onCreateIndex !== -1) {
newContents =
newContents.slice(0, onCreateIndex) +
methodToAdd +
newContents.slice(onCreateIndex);
}
}
config.modResults.contents = newContents;
return config;
});
};
module.exports = withMainActivityConfigChange;

View File

@@ -18,7 +18,7 @@ interface CommentItemProps {
comment: Comment; comment: Comment;
onUserPress: () => void; onUserPress: () => void;
onReply: () => void; onReply: () => void;
onLike: () => void; onLike: (comment: Comment) => void; // 点赞回调,传入评论对象
floorNumber?: number; // 楼层号 floorNumber?: number; // 楼层号
isAuthor?: boolean; // 是否是楼主 isAuthor?: boolean; // 是否是楼主
replyToUser?: string; // 回复给哪位用户 replyToUser?: string; // 回复给哪位用户
@@ -29,6 +29,7 @@ interface CommentItemProps {
onDelete?: (comment: Comment) => void; // 删除评论的回调 onDelete?: (comment: Comment) => void; // 删除评论的回调
onImagePress?: (images: ImageGridItem[], index: number) => void; // 点击图片查看大图 onImagePress?: (images: ImageGridItem[], index: number) => void; // 点击图片查看大图
currentUserId?: string; // 当前用户ID用于判断子评论作者 currentUserId?: string; // 当前用户ID用于判断子评论作者
onReport?: (comment: Comment) => void; // 举报评论的回调
} }
function createCommentItemStyles(colors: AppColors) { function createCommentItemStyles(colors: AppColors) {
@@ -214,6 +215,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
onDelete, onDelete,
onImagePress, onImagePress,
currentUserId, currentUserId,
onReport,
}) => { }) => {
const colors = useAppColors(); const colors = useAppColors();
const styles = useMemo(() => createCommentItemStyles(colors), [colors]); const styles = useMemo(() => createCommentItemStyles(colors), [colors]);
@@ -504,6 +506,25 @@ const CommentItem: React.FC<CommentItemProps> = ({
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
{/* 点赞按钮 */}
<TouchableOpacity
style={styles.subActionButton}
onPress={() => onLike?.(reply)}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name={reply.is_liked ? 'heart' : 'heart-outline'}
size={12}
color={reply.is_liked ? colors.error.main : colors.text.hint}
/>
<Text
variant="caption"
color={reply.is_liked ? colors.error.main : colors.text.hint}
style={styles.subActionText}
>
{reply.likes_count > 0 ? formatNumber(reply.likes_count) : '赞'}
</Text>
</TouchableOpacity>
{/* 删除按钮 - 子评论作者可见 */} {/* 删除按钮 - 子评论作者可见 */}
{isSubReplyAuthor && ( {isSubReplyAuthor && (
<TouchableOpacity <TouchableOpacity
@@ -521,6 +542,18 @@ const CommentItem: React.FC<CommentItemProps> = ({
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
)} )}
{/* 举报按钮 - 对非子评论作者显示 */}
{!isSubReplyAuthor && onReport && (
<TouchableOpacity
style={styles.subActionButton}
onPress={() => onReport(reply)}
>
<MaterialCommunityIcons name="flag-outline" size={12} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
</Text>
</TouchableOpacity>
)}
</View> </View>
</View> </View>
</TouchableOpacity> </TouchableOpacity>
@@ -592,7 +625,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
{/* 操作按钮 - 更紧凑 */} {/* 操作按钮 - 更紧凑 */}
<View style={styles.actions}> <View style={styles.actions}>
{/* 点赞 */} {/* 点赞 */}
<TouchableOpacity style={styles.actionButton} onPress={onLike}> <TouchableOpacity style={styles.actionButton} onPress={() => onLike(comment)}>
<MaterialCommunityIcons <MaterialCommunityIcons
name={comment.is_liked ? 'heart' : 'heart-outline'} name={comment.is_liked ? 'heart' : 'heart-outline'}
size={14} size={14}
@@ -615,6 +648,19 @@ const CommentItem: React.FC<CommentItemProps> = ({
</Text> </Text>
</TouchableOpacity> </TouchableOpacity>
{/* 举报按钮 - 只对非评论作者显示 */}
{!isCommentAuthor && onReport && (
<TouchableOpacity
style={styles.actionButton}
onPress={() => onReport(comment)}
>
<MaterialCommunityIcons name="flag-outline" size={14} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
</Text>
</TouchableOpacity>
)}
{/* 删除按钮 - 只对评论作者显示 */} {/* 删除按钮 - 只对评论作者显示 */}
{isCommentAuthor && ( {isCommentAuthor && (
<TouchableOpacity <TouchableOpacity

View File

@@ -22,7 +22,8 @@ export type PostCardActionType =
| 'unbookmark' // 取消收藏 | 'unbookmark' // 取消收藏
| 'share' // 分享 | 'share' // 分享
| 'imagePress' // 点击图片 | 'imagePress' // 点击图片
| 'delete'; // 删除 | 'delete' // 删除
| 'report'; // 举报
/** /**
* Action payload 类型 * Action payload 类型

View File

@@ -7,8 +7,8 @@ import {
Alert, Alert,
Modal, Modal,
Dimensions, Dimensions,
Platform,
} from 'react-native'; } from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as hrefs from '../../navigation/hrefs'; import * as hrefs from '../../navigation/hrefs';
@@ -133,22 +133,81 @@ function createQrScannerStyles(colors: AppColors) {
fontSize: fontSizes.md, fontSize: fontSizes.md,
fontWeight: '600', fontWeight: '600',
}, },
webNotSupportedContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl,
backgroundColor: '#000',
},
webNotSupportedText: {
marginTop: spacing.lg,
fontSize: fontSizes.md,
color: 'rgba(255,255,255,0.72)',
textAlign: 'center',
lineHeight: 24,
},
}); });
} }
export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => { // Web 端不支持扫码组件
const QRCodeScannerWeb: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
const themeColors = useAppColors();
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
useEffect(() => {
if (visible && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
}, [visible]);
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.webNotSupportedContainer}>
<MaterialCommunityIcons name="web-off" size={64} color="rgba(255,255,255,0.5)" />
<Text style={styles.webNotSupportedText}>
使{'\n'}
App
</Text>
<TouchableOpacity style={styles.permissionButton} onPress={onClose}>
<Text style={styles.permissionButtonText}></Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
};
// 原生端扫码组件
const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
const router = useRouter(); const router = useRouter();
const themeColors = useAppColors(); const themeColors = useAppColors();
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]); const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
const [permission, requestPermission] = useCameraPermissions();
// 动态导入 expo-camera避免 Web 端加载
const [cameraModule, setCameraModule] = useState<typeof import('expo-camera') | null>(null);
const [permission, setPermission] = useState<any>(null);
const [scanned, setScanned] = useState(false); const [scanned, setScanned] = useState(false);
useEffect(() => { useEffect(() => {
if (visible) { if (visible && Platform.OS !== 'web') {
setScanned(false); import('expo-camera').then((module) => {
if (!permission?.granted) { setCameraModule(module);
requestPermission(); const [perm, requestPerm] = module.useCameraPermissions();
setPermission(perm);
if (!perm?.granted) {
requestPerm();
} }
});
} }
}, [visible]); }, [visible]);
@@ -178,7 +237,7 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
} }
}; };
if (!permission?.granted) { if (!cameraModule || !permission?.granted) {
return ( return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}> <Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={styles.container}> <View style={styles.container}>
@@ -192,7 +251,10 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
<View style={styles.permissionContainer}> <View style={styles.permissionContainer}>
<MaterialCommunityIcons name="camera-off" size={64} color="rgba(255,255,255,0.5)" /> <MaterialCommunityIcons name="camera-off" size={64} color="rgba(255,255,255,0.5)" />
<Text style={styles.permissionText}></Text> <Text style={styles.permissionText}></Text>
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}> <TouchableOpacity
style={styles.permissionButton}
onPress={() => cameraModule?.useCameraPermissions()[1]()}
>
<Text style={styles.permissionButtonText}></Text> <Text style={styles.permissionButtonText}></Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
@@ -201,6 +263,8 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
); );
} }
const { CameraView } = cameraModule;
return ( return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}> <Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={styles.container}> <View style={styles.container}>
@@ -243,4 +307,9 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
); );
}; };
// 根据平台导出不同组件
export const QRCodeScanner: React.FC<QRCodeScannerProps> = Platform.OS === 'web'
? QRCodeScannerWeb
: QRCodeScannerNative;
export default QRCodeScanner; export default QRCodeScanner;

View File

@@ -0,0 +1,493 @@
/**
* 举报对话框组件
* 支持举报帖子、评论、消息
* 提供友好的用户界面和流畅的交互体验
*/
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import {
Modal,
View,
TouchableOpacity,
StyleSheet,
ScrollView,
TextInput,
ActivityIndicator,
Alert,
Platform,
KeyboardAvoidingView,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors } from '../../../theme';
import { spacing, borderRadius, fontSizes, shadows } from '../../../theme';
import reportService, { ReportReason, ReportTargetType, REPORT_REASONS } from '../../../services/reportService';
import Text from '../../common/Text';
export interface ReportDialogProps {
visible: boolean;
targetType: ReportTargetType;
targetId: string;
onClose: () => void;
onSuccess?: () => void;
}
// 目标类型配置
const TARGET_CONFIG: Record<ReportTargetType, { label: string; icon: string; color: string }> = {
post: { label: '帖子', icon: 'file-document-outline', color: '#FF6B35' },
comment: { label: '评论', icon: 'comment-text-outline', color: '#4CAF50' },
message: { label: '消息', icon: 'message-text-outline', color: '#2196F3' },
};
// 举报原因详细配置(带图标和说明)
const REPORT_REASONS_DETAILED = [
{ value: 'spam' as ReportReason, label: '垃圾广告', icon: 'email-newsletter', description: '包含广告、推广或重复内容' },
{ value: 'inappropriate' as ReportReason, label: '违规内容', icon: 'alert-circle-outline', description: '包含违法、色情或暴力内容' },
{ value: 'harassment' as ReportReason, label: '辱骂攻击', icon: 'account-off-outline', description: '包含人身攻击、骚扰或歧视' },
{ value: 'misinformation' as ReportReason, label: '虚假信息', icon: 'alert-outline', description: '包含谣言、诈骗或误导信息' },
{ value: 'other' as ReportReason, label: '其他原因', icon: 'dots-horizontal-circle-outline', description: '其他需要举报的情况' },
];
const ReportDialog: React.FC<ReportDialogProps> = ({
visible,
targetType,
targetId,
onClose,
onSuccess,
}) => {
const colors = useAppColors();
const styles = useStyles(colors);
const [selectedReason, setSelectedReason] = useState<ReportReason | null>(null);
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
const targetConfig = TARGET_CONFIG[targetType];
useEffect(() => {
if (visible && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
}, [visible]);
// 判断补充说明是否必填(选择"其他原因"时必填)
const isDescriptionRequired = selectedReason === 'other';
const descriptionPlaceholder = isDescriptionRequired
? '请详细描述您举报的原因(必填)...'
: '请提供更多详细信息(选填)...';
// 提交举报
const handleSubmit = useCallback(async () => {
if (!selectedReason) {
Alert.alert('提示', '请选择举报原因', [{ text: '确定' }]);
return;
}
if (isDescriptionRequired && !description.trim()) {
Alert.alert('提示', '请选择"其他原因"时,请详细描述举报原因', [{ text: '确定' }]);
return;
}
setSubmitting(true);
try {
const result = await reportService.createReport(
targetType,
targetId,
selectedReason,
description.trim() || undefined
);
if (result) {
Alert.alert('举报成功', '感谢您的反馈,我们会尽快处理', [
{
text: '确定',
onPress: () => {
onClose();
onSuccess?.();
},
},
]);
} else {
Alert.alert('举报失败', '请稍后重试', [{ text: '确定' }]);
}
} catch (error) {
console.error('Submit report error:', error);
Alert.alert('举报失败', '网络错误,请稍后重试', [{ text: '确定' }]);
} finally {
setSubmitting(false);
}
}, [selectedReason, description, targetType, targetId, onClose, onSuccess]);
// 重置状态
const handleClose = useCallback(() => {
setSelectedReason(null);
setDescription('');
onClose();
}, [onClose]);
const selectedReasonData = useMemo(
() => REPORT_REASONS_DETAILED.find(r => r.value === selectedReason),
[selectedReason]
);
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={handleClose}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<View style={styles.overlay}>
<View style={styles.container}>
{/* 头部 */}
<View style={styles.header}>
<View style={styles.headerContent}>
<View style={[styles.headerIcon, { backgroundColor: targetConfig.color + '15' }]}>
<MaterialCommunityIcons
name={targetConfig.icon as any}
size={20}
color={targetConfig.color}
/>
</View>
<View style={styles.headerText}>
<Text style={styles.title}>{targetConfig.label}</Text>
<Text style={styles.subtitle}></Text>
</View>
</View>
<TouchableOpacity
style={styles.closeButton}
onPress={handleClose}
disabled={submitting}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<MaterialCommunityIcons
name="close"
size={24}
color={colors.chat.textSecondary}
/>
</TouchableOpacity>
</View>
{/* 内容 */}
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
{/* 举报原因选择 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.reasonList}>
{REPORT_REASONS_DETAILED.map((item) => (
<TouchableOpacity
key={item.value}
style={[
styles.reasonItem,
selectedReason === item.value && [
styles.reasonItemSelected,
{ borderColor: colors.primary.main + '40' }
],
]}
onPress={() => setSelectedReason(item.value)}
disabled={submitting}
activeOpacity={0.7}
>
<View style={styles.reasonLeft}>
<View
style={[
styles.reasonIcon,
selectedReason === item.value && styles.reasonIconSelected,
]}
>
<MaterialCommunityIcons
name={item.icon as any}
size={20}
color={selectedReason === item.value ? colors.primary.main : colors.chat.textSecondary}
/>
</View>
<View style={styles.reasonTextContainer}>
<Text style={[
styles.reasonLabel,
...(selectedReason === item.value ? [styles.reasonLabelSelected] : []),
]}>
{item.label}
</Text>
<Text style={styles.reasonDescription}>{item.description}</Text>
</View>
</View>
<View
style={[
styles.radio,
selectedReason === item.value && styles.radioSelected,
]}
>
{selectedReason === item.value && (
<View style={styles.radioDot} />
)}
</View>
</TouchableOpacity>
))}
</View>
</View>
{/* 补充说明 */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>
{isDescriptionRequired ? ' *' : ''}
</Text>
<Text style={styles.charCount}>{description.length}/500</Text>
</View>
<TextInput
style={[
styles.textInput,
isDescriptionRequired && !description && styles.textInputError,
]}
placeholder={descriptionPlaceholder}
placeholderTextColor={colors.chat.textPlaceholder}
multiline
maxLength={500}
value={description}
onChangeText={setDescription}
editable={!submitting}
textAlignVertical="top"
/>
</View>
</ScrollView>
{/* 底部按钮 */}
<View style={styles.footer}>
<TouchableOpacity
style={styles.cancelButton}
onPress={handleClose}
disabled={submitting}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.submitButton,
(!selectedReason || submitting) && styles.submitButtonDisabled,
]}
onPress={handleSubmit}
disabled={!selectedReason || submitting}
activeOpacity={0.9}
>
{submitting ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.submitButtonText}></Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
);
};
// 创建样式
const useStyles = (colors: ReturnType<typeof useAppColors>) =>
StyleSheet.create({
keyboardView: {
flex: 1,
},
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
container: {
backgroundColor: colors.chat.card,
borderRadius: borderRadius.xl,
width: '100%',
maxWidth: 420,
maxHeight: '85%',
...shadows.lg,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: spacing.lg,
paddingBottom: spacing.md,
},
headerContent: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
headerIcon: {
width: 40,
height: 40,
borderRadius: borderRadius.md,
justifyContent: 'center',
alignItems: 'center',
},
headerText: {
flex: 1,
},
title: {
fontSize: fontSizes.xl,
fontWeight: '600',
color: colors.chat.textPrimary,
},
subtitle: {
fontSize: fontSizes.sm,
color: colors.chat.textSecondary,
marginTop: 2,
},
closeButton: {
padding: spacing.xs,
borderRadius: borderRadius.full,
},
content: {
padding: spacing.lg,
paddingTop: 0,
maxHeight: 400,
},
section: {
marginBottom: spacing.lg,
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.sm,
},
sectionTitle: {
fontSize: fontSizes.md,
fontWeight: '600',
color: colors.chat.textPrimary,
},
reasonList: {
gap: spacing.sm,
},
reasonItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: spacing.md,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: 'transparent',
backgroundColor: colors.chat.surfaceMuted,
},
reasonItemSelected: {
backgroundColor: colors.primary.main + '08',
borderWidth: 1,
},
reasonLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
gap: spacing.md,
},
reasonIcon: {
width: 36,
height: 36,
borderRadius: borderRadius.md,
backgroundColor: colors.chat.surfaceRaised,
justifyContent: 'center',
alignItems: 'center',
},
reasonIconSelected: {
backgroundColor: colors.primary.main + '15',
},
reasonTextContainer: {
flex: 1,
},
reasonLabel: {
fontSize: fontSizes.md,
fontWeight: '500',
color: colors.chat.textPrimary,
},
reasonLabelSelected: {
color: colors.primary.main,
fontWeight: '600',
},
reasonDescription: {
fontSize: fontSizes.sm,
color: colors.chat.textSecondary,
marginTop: 2,
},
radio: {
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 2,
borderColor: colors.chat.border,
justifyContent: 'center',
alignItems: 'center',
marginLeft: spacing.sm,
},
radioSelected: {
borderColor: colors.primary.main,
backgroundColor: colors.primary.main,
},
radioDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: '#FFFFFF',
},
textInput: {
borderWidth: 1,
borderColor: colors.chat.border,
borderRadius: borderRadius.md,
padding: spacing.md,
fontSize: fontSizes.md,
color: colors.chat.textPrimary,
minHeight: 100,
backgroundColor: colors.chat.surfaceMuted,
},
textInputError: {
borderColor: colors.error.main,
backgroundColor: colors.error.main + '08',
},
charCount: {
fontSize: fontSizes.sm,
color: colors.chat.textSecondary,
},
footer: {
flexDirection: 'row',
padding: spacing.lg,
gap: spacing.md,
borderTopWidth: 1,
borderTopColor: colors.chat.borderLight,
},
cancelButton: {
flex: 1,
paddingVertical: spacing.md,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.chat.border,
alignItems: 'center',
backgroundColor: colors.chat.surfaceMuted,
},
cancelButtonText: {
fontSize: fontSizes.md,
fontWeight: '500',
color: colors.chat.textSecondary,
},
submitButton: {
flex: 1,
paddingVertical: spacing.md,
borderRadius: borderRadius.md,
backgroundColor: colors.primary.main,
alignItems: 'center',
},
submitButtonDisabled: {
backgroundColor: colors.background.disabled,
},
submitButtonText: {
fontSize: fontSizes.md,
color: '#FFFFFF',
fontWeight: '600',
},
});
export default ReportDialog;

View File

@@ -0,0 +1,2 @@
export { default } from './ReportDialog';
export type { ReportDialogProps } from './ReportDialog';

View File

@@ -1,7 +1,7 @@
/** /**
* SystemMessageItem 系统消息项组件 - 美化版 * SystemMessageItem 系统消息项组件 - 扁平化风格
* 根据系统消息类型显示不同图标和内容 * 根据系统消息类型显示不同图标和内容
* 采用卡片式设计符合胡萝卜BBS整体风格 * 与登录、注册、设置页面风格保持一致
*/ */
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
@@ -9,7 +9,7 @@ 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 { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme'; import { spacing, borderRadius, 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';
@@ -124,17 +124,13 @@ function createSystemMessageStyles(colors: AppColors) {
container: { container: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'flex-start', alignItems: 'flex-start',
padding: spacing.md, padding: 16,
backgroundColor: colors.background.paper, backgroundColor: '#fff',
borderRadius: borderRadius.lg, borderBottomWidth: 1,
marginHorizontal: spacing.md, borderBottomColor: colors.divider || '#E5E5EA',
marginBottom: spacing.sm,
...shadows.sm,
}, },
unreadContainer: { unreadContainer: {
backgroundColor: colors.primary.light + '08', backgroundColor: '#FFF8F6',
borderLeftWidth: 3,
borderLeftColor: colors.primary.main,
}, },
unreadIndicator: { unreadIndicator: {
position: 'absolute', position: 'absolute',
@@ -143,19 +139,19 @@ function createSystemMessageStyles(colors: AppColors) {
marginTop: -4, marginTop: -4,
width: 8, width: 8,
height: 8, height: 8,
borderRadius: borderRadius.full, borderRadius: 4,
backgroundColor: colors.primary.main, backgroundColor: '#FF6B35',
}, },
iconContainer: { iconContainer: {
marginRight: spacing.md, marginRight: 14,
}, },
avatarWrapper: { avatarWrapper: {
position: 'relative', position: 'relative',
}, },
iconWrapper: { iconWrapper: {
width: 48, width: 44,
height: 48, height: 44,
borderRadius: borderRadius.lg, borderRadius: 12,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
}, },
@@ -163,13 +159,13 @@ function createSystemMessageStyles(colors: AppColors) {
position: 'absolute', position: 'absolute',
bottom: -2, bottom: -2,
right: -2, right: -2,
width: 20, width: 18,
height: 20, height: 18,
borderRadius: borderRadius.full, borderRadius: 9,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
borderWidth: 2, borderWidth: 2,
borderColor: colors.background.paper, borderColor: '#fff',
}, },
content: { content: {
flex: 1, flex: 1,
@@ -180,17 +176,17 @@ function createSystemMessageStyles(colors: AppColors) {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
marginBottom: spacing.xs, marginBottom: 4,
}, },
titleLeft: { titleLeft: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
flex: 1, flex: 1,
marginRight: spacing.sm, marginRight: 8,
}, },
title: { title: {
fontWeight: '500', fontWeight: '500',
fontSize: fontSizes.md, fontSize: 15,
color: colors.text.primary, color: colors.text.primary,
}, },
unreadTitle: { unreadTitle: {
@@ -200,10 +196,10 @@ function createSystemMessageStyles(colors: AppColors) {
statusBadge: { statusBadge: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingHorizontal: spacing.xs, paddingHorizontal: 6,
paddingVertical: 2, paddingVertical: 2,
borderRadius: borderRadius.sm, borderRadius: 4,
marginLeft: spacing.xs, marginLeft: 6,
}, },
statusText: { statusText: {
fontSize: 10, fontSize: 10,
@@ -211,55 +207,59 @@ function createSystemMessageStyles(colors: AppColors) {
marginLeft: 2, marginLeft: 2,
}, },
time: { time: {
fontSize: fontSizes.sm, fontSize: 13,
flexShrink: 0, flexShrink: 0,
color: colors.text.hint,
}, },
messageContent: { messageContent: {
lineHeight: 20, lineHeight: 20,
fontSize: fontSizes.sm, fontSize: 14,
color: colors.text.secondary,
}, },
extraInfo: { extraInfo: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
marginTop: spacing.xs, marginTop: 8,
backgroundColor: colors.primary.light + '12', backgroundColor: '#F5F5F7',
paddingHorizontal: spacing.sm, paddingHorizontal: 10,
paddingVertical: spacing.xs, paddingVertical: 6,
borderRadius: borderRadius.md, borderRadius: 8,
alignSelf: 'flex-start', alignSelf: 'flex-start',
}, },
extraText: { extraText: {
marginLeft: spacing.xs, marginLeft: 6,
flex: 1, flex: 1,
fontWeight: '500', fontWeight: '500',
fontSize: 13,
}, },
actionsRow: { actionsRow: {
flexDirection: 'row', flexDirection: 'row',
marginTop: spacing.sm, marginTop: 12,
gap: spacing.sm, gap: 10,
}, },
actionBtn: { actionBtn: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
paddingVertical: spacing.xs, paddingVertical: 8,
paddingHorizontal: spacing.md, paddingHorizontal: 16,
borderRadius: borderRadius.md, borderRadius: 10,
borderWidth: 1, borderWidth: 1,
gap: spacing.xs, gap: 4,
}, },
rejectBtn: { rejectBtn: {
borderColor: `${colors.error.main}55`, borderColor: colors.error.main + '40',
backgroundColor: `${colors.error.main}18`, backgroundColor: colors.error.light + '20',
}, },
approveBtn: { approveBtn: {
borderColor: `${colors.success.main}55`, borderColor: colors.success.main + '40',
backgroundColor: `${colors.success.main}18`, backgroundColor: colors.success.light + '20',
}, },
actionBtnText: { actionBtnText: {
fontWeight: '500', fontWeight: '500',
fontSize: 13,
}, },
arrowContainer: { arrowContainer: {
marginLeft: spacing.sm, marginLeft: 8,
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
height: 20, height: 20,
@@ -318,6 +318,13 @@ const getSystemMessageTitle = (message: SystemMessageResponse): string => {
} }
}; };
// 胡萝卜橙主题色
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
const SystemMessageItem: React.FC<SystemMessageItemProps> = ({ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
message, message,
onPress, onPress,

View File

@@ -135,26 +135,33 @@ function createTabBarStyles(colors: AppColors) {
modernContainer: { modernContainer: {
flexDirection: 'row', flexDirection: 'row',
backgroundColor: colors.background.paper, backgroundColor: colors.background.paper,
borderRadius: borderRadius.xl, borderRadius: borderRadius['2xl'],
marginHorizontal: spacing.lg, marginHorizontal: spacing.lg,
marginVertical: spacing.md, marginVertical: spacing.sm,
padding: spacing.xs, padding: spacing.xs,
shadowColor: colors.chat.shadow, shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 2 }, shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.08, shadowOpacity: 0.1,
shadowRadius: 8, shadowRadius: 16,
elevation: 3, elevation: 6,
}, },
modernTab: { modernTab: {
flex: 1, flex: 1,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
paddingVertical: spacing.sm, paddingVertical: spacing.md,
paddingHorizontal: spacing.sm,
borderRadius: borderRadius.lg, borderRadius: borderRadius.lg,
position: 'relative', position: 'relative',
backgroundColor: 'transparent',
}, },
modernTabActive: { modernTabActive: {
backgroundColor: colors.primary.main + '15', backgroundColor: colors.primary.main + '12',
shadowColor: colors.primary.main + '20',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
}, },
modernTabContent: { modernTabContent: {
flexDirection: 'row', flexDirection: 'row',
@@ -165,7 +172,7 @@ function createTabBarStyles(colors: AppColors) {
marginRight: spacing.xs, marginRight: spacing.xs,
}, },
modernTabText: { modernTabText: {
fontWeight: '500', fontWeight: '600',
fontSize: fontSizes.md, fontSize: fontSizes.md,
}, },
modernTabTextActive: { modernTabTextActive: {
@@ -206,7 +213,7 @@ const TabBar: React.FC<TabBarProps> = ({
key={index} key={index}
style={[styles.modernTab, isActive && styles.modernTabActive]} style={[styles.modernTab, isActive && styles.modernTabActive]}
onPress={() => onTabChange(index)} onPress={() => onTabChange(index)}
activeOpacity={0.85} activeOpacity={0.7}
> >
<View style={styles.modernTabContent}> <View style={styles.modernTabContent}>
{icon && ( {icon && (

View File

@@ -22,3 +22,4 @@ export { default as TabBar } from './TabBar';
export { default as VoteCard } from './VoteCard'; export { default as VoteCard } from './VoteCard';
export { default as VoteEditor } from './VoteEditor'; export { default as VoteEditor } from './VoteEditor';
export { default as VotePreview } from './VotePreview'; export { default as VotePreview } from './VotePreview';
export { default as ReportDialog } from './ReportDialog';

View File

@@ -0,0 +1,412 @@
import React, { useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
StatusBar,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { callStore } from '../../stores/callStore';
import { RTCView } from 'react-native-webrtc';
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const CallScreen: React.FC = () => {
const currentCall = callStore((s) => s.currentCall);
const callDuration = callStore((s) => s.callDuration);
const peerStream = callStore((s) => s.peerStream);
const localStream = callStore((s) => s.localStream);
const endCall = callStore((s) => s.endCall);
const toggleMute = callStore((s) => s.toggleMute);
const toggleSpeaker = callStore((s) => s.toggleSpeaker);
const toggleVideo = callStore((s) => s.toggleVideo);
const isMinimized = callStore((s) => s.isMinimized);
const toggleMinimize = callStore((s) => s.toggleMinimize);
// Track whether peer has video
const [hasPeerVideo, setHasPeerVideo] = useState(false);
// Track whether local has video
const [hasLocalVideo, setHasLocalVideo] = useState(false);
// Check peer stream for video tracks
useEffect(() => {
if (peerStream) {
const videoTracks = peerStream.getVideoTracks();
const hasVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled);
setHasPeerVideo(hasVideo);
} else {
setHasPeerVideo(false);
}
}, [peerStream]);
// Check local stream for video tracks
useEffect(() => {
if (localStream) {
const videoTracks = localStream.getVideoTracks();
const hasVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled);
setHasLocalVideo(hasVideo);
} else {
setHasLocalVideo(false);
}
}, [localStream]);
// Don't render full screen when minimized or no call
if (!currentCall || isMinimized) return null;
const getStatusText = (): string => {
switch (currentCall.status) {
case 'calling':
return '正在等待对方接听...';
case 'ringing':
return '来电响铃中...';
case 'connecting':
return '连接中...';
case 'connected':
return formatDuration(callDuration);
case 'reconnecting':
return '网络重连中...';
case 'ended':
return '通话已结束';
case 'failed':
return '连接失败';
default:
return '';
}
};
const handleEndCall = () => {
endCall('hangup');
};
const handleToggleMute = () => {
toggleMute();
};
const handleToggleSpeaker = () => {
toggleSpeaker();
};
const handleToggleVideo = () => {
toggleVideo();
};
const handleMinimize = () => {
toggleMinimize();
};
// Determine if we should show video UI
const showRemoteVideo = hasPeerVideo;
// Use currentCall.isVideoEnabled directly for local video
// This is more reliable than checking localStream.getVideoTracks()
// because the stream object reference may not trigger useEffect properly
const showLocalVideo = currentCall?.isVideoEnabled && localStream;
const isVideoCallActive = showRemoteVideo || showLocalVideo;
return (
<View style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
{/* Background */}
<View style={styles.background} />
{/* Remote video - full screen when peer has video */}
{showRemoteVideo && peerStream && (
<RTCView
streamURL={peerStream.toURL()}
style={styles.fullScreenVideo}
objectFit="cover"
mirror={false}
/>
)}
{/* Local video - picture in picture */}
{showLocalVideo && localStream && (
<View style={styles.localVideoContainer}>
<RTCView
streamURL={localStream.toURL()}
style={styles.localVideo}
objectFit="cover"
mirror={true}
/>
</View>
)}
{/* Top area: minimize button */}
<View style={styles.topBar}>
<TouchableOpacity
style={styles.topButton}
onPress={handleMinimize}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="chevron-down" size={28} color="#fff" />
</TouchableOpacity>
</View>
{/* Center: Peer info - only show when no video */}
{!isVideoCallActive && (
<View style={styles.centerArea}>
<View style={styles.avatarOuter}>
{currentCall.peerAvatar ? (
<Image source={{ uri: currentCall.peerAvatar }} style={styles.avatar} />
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
</Text>
</View>
)}
</View>
<Text style={styles.peerName} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
</Text>
<Text style={styles.status}>{getStatusText()}</Text>
</View>
)}
{/* Peer name overlay when video is active */}
{isVideoCallActive && (
<View style={styles.videoOverlayInfo}>
<Text style={styles.videoPeerName} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
</Text>
<Text style={styles.videoStatus}>{getStatusText()}</Text>
</View>
)}
{/* Bottom controls */}
<View style={styles.controls}>
<View style={styles.controlRow}>
{/* Mute */}
<TouchableOpacity
style={[styles.controlButton, currentCall.isMuted && styles.controlButtonActive]}
onPress={handleToggleMute}
activeOpacity={0.7}
>
<View style={[styles.controlCircle, currentCall.isMuted && styles.controlCircleActive]}>
<MaterialCommunityIcons
name={currentCall.isMuted ? 'microphone-off' : 'microphone'}
size={26}
color={currentCall.isMuted ? '#333' : '#fff'}
/>
</View>
<Text style={[styles.controlLabel, currentCall.isMuted && styles.controlLabelActive]}>
{currentCall.isMuted ? '取消静音' : '静音'}
</Text>
</TouchableOpacity>
{/* Video toggle */}
<TouchableOpacity
style={[styles.controlButton, hasLocalVideo && styles.controlButtonActive]}
onPress={handleToggleVideo}
activeOpacity={0.7}
>
<View style={[styles.controlCircle, hasLocalVideo && styles.controlCircleActive]}>
<MaterialCommunityIcons
name={hasLocalVideo ? 'video' : 'video-off'}
size={26}
color={hasLocalVideo ? '#333' : '#fff'}
/>
</View>
<Text style={[styles.controlLabel, hasLocalVideo && styles.controlLabelActive]}>
{hasLocalVideo ? '关闭摄像头' : '打开摄像头'}
</Text>
</TouchableOpacity>
{/* Speaker */}
<TouchableOpacity
style={[styles.controlButton, currentCall.isSpeakerOn && styles.controlButtonActive]}
onPress={handleToggleSpeaker}
activeOpacity={0.7}
>
<View style={[styles.controlCircle, currentCall.isSpeakerOn && styles.controlCircleActive]}>
<MaterialCommunityIcons
name={currentCall.isSpeakerOn ? 'volume-high' : 'cellphone'}
size={26}
color={currentCall.isSpeakerOn ? '#333' : '#fff'}
/>
</View>
<Text style={[styles.controlLabel, currentCall.isSpeakerOn && styles.controlLabelActive]}>
{currentCall.isSpeakerOn ? '免提' : '听筒'}
</Text>
</TouchableOpacity>
</View>
{/* End call - prominent red button */}
<TouchableOpacity
style={styles.endCallButton}
onPress={handleEndCall}
activeOpacity={0.8}
>
<View style={styles.endCallCircle}>
<MaterialCommunityIcons name="phone-hangup" size={32} color="#fff" />
</View>
<Text style={styles.endCallLabel}></Text>
</TouchableOpacity>
</View>
</View>
);
};
const CONTROL_CIRCLE_SIZE = 56;
const END_CALL_SIZE = 64;
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#1A1A2E',
zIndex: 9999,
},
background: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#1A1A2E',
},
topBar: {
position: 'absolute',
top: 50,
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
height: 44,
zIndex: 100,
},
topButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
},
centerArea: {
position: 'absolute',
top: '28%',
left: 0,
right: 0,
alignItems: 'center',
paddingHorizontal: 30,
},
avatarOuter: {
marginBottom: 16,
},
avatar: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: '#3A3A5C',
},
avatarPlaceholder: {
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 40,
color: '#fff',
fontWeight: '600',
},
peerName: {
fontSize: 22,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 6,
textAlign: 'center',
maxWidth: 280,
},
status: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.5)',
letterSpacing: 0.3,
},
fullScreenVideo: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#1A1A2E',
},
localVideoContainer: {
position: 'absolute',
top: 100,
right: 20,
width: 120,
height: 160,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: '#2A2A4E',
borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.2)',
zIndex: 50,
},
localVideo: {
width: '100%',
height: '100%',
},
videoOverlayInfo: {
position: 'absolute',
top: 100,
left: 0,
right: 0,
alignItems: 'center',
zIndex: 50,
},
videoPeerName: {
fontSize: 18,
fontWeight: '600',
color: '#FFFFFF',
textShadowColor: 'rgba(0, 0, 0, 0.5)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
videoStatus: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.8)',
marginTop: 4,
textShadowColor: 'rgba(0, 0, 0, 0.5)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
controls: {
position: 'absolute',
bottom: 60,
left: 0,
right: 0,
alignItems: 'center',
zIndex: 100,
},
controlRow: {
flexDirection: 'row',
justifyContent: 'center',
gap: 24,
marginBottom: 24,
},
controlButton: {
alignItems: 'center',
width: 70,
},
controlCircle: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: 'rgba(255, 255, 255, 0.12)',
justifyContent: 'center',
alignItems: 'center',
},
controlCircleActive: {
backgroundColor: '#FFFFFF',
},
controlButtonActive: {},
controlLabel: {
fontSize: 11,
color: 'rgba(255, 255, 255, 0.55)',
marginTop: 8,
textAlign: 'center',
},
controlLabelActive: {
color: '#FFFFFF',
fontWeight: '500',
},
endCallButton: {
alignItems: 'center',
},
endCallCircle: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: '#E54D42',
justifyContent: 'center',
alignItems: 'center',
},
endCallLabel: {
fontSize: 11,
color: 'rgba(255, 255, 255, 0.55)',
marginTop: 8,
},
});
export default CallScreen;

View File

@@ -0,0 +1,160 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
StatusBar,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { callStore } from '../../stores/callStore';
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const CallScreen: React.FC = () => {
const currentCall = callStore((s) => s.currentCall);
const callDuration = callStore((s) => s.callDuration);
const endCall = callStore((s) => s.endCall);
const isMinimized = callStore((s) => s.isMinimized);
// Don't render full screen when minimized or no call
if (!currentCall || isMinimized) return null;
const getStatusText = (): string => {
switch (currentCall.status) {
case 'ringing':
return '通话功能暂不支持网页端';
case 'connecting':
return '通话功能暂不支持网页端';
case 'connected':
return '通话功能暂不支持网页端';
case 'ended':
return '通话已结束';
default:
return '';
}
};
const handleEndCall = () => {
endCall('hangup');
};
return (
<View style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
{/* Background - single consistent color */}
<View style={styles.background} />
{/* Center: Peer info */}
<View style={styles.centerArea}>
<View style={styles.avatarOuter}>
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<MaterialCommunityIcons name="web-off" size={50} color="#fff" />
</View>
</View>
<Text style={styles.peerName} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
</Text>
<Text style={styles.status}>{getStatusText()}</Text>
</View>
{/* Bottom controls */}
<View style={styles.controls}>
{/* End call - prominent red button */}
<TouchableOpacity
style={styles.endCallButton}
onPress={handleEndCall}
activeOpacity={0.8}
>
<View style={styles.endCallCircle}>
<MaterialCommunityIcons name="phone-hangup" size={32} color="#fff" />
</View>
<Text style={styles.endCallLabel}></Text>
</TouchableOpacity>
</View>
</View>
);
};
const END_CALL_SIZE = 64;
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#1A1A2E',
zIndex: 9999,
},
background: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#1A1A2E',
},
centerArea: {
position: 'absolute',
top: '28%',
left: 0,
right: 0,
alignItems: 'center',
paddingHorizontal: 30,
},
avatarOuter: {
marginBottom: 16,
},
avatar: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: '#3A3A5C',
},
avatarPlaceholder: {
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 40,
color: '#fff',
fontWeight: '600',
},
peerName: {
fontSize: 22,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 6,
textAlign: 'center',
maxWidth: 280,
},
status: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.5)',
letterSpacing: 0.3,
},
controls: {
position: 'absolute',
bottom: 60,
left: 0,
right: 0,
alignItems: 'center',
},
endCallButton: {
alignItems: 'center',
},
endCallCircle: {
width: END_CALL_SIZE,
height: END_CALL_SIZE,
borderRadius: END_CALL_SIZE / 2,
backgroundColor: '#E54D42',
justifyContent: 'center',
alignItems: 'center',
},
endCallLabel: {
fontSize: 11,
color: 'rgba(255, 255, 255, 0.55)',
marginTop: 8,
},
});
export default CallScreen;

View File

@@ -0,0 +1,159 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { callStore } from '../../stores/callStore';
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const FloatingCallWindow: React.FC = () => {
const currentCall = callStore((s) => s.currentCall);
const callDuration = callStore((s) => s.callDuration);
const isMinimized = callStore((s) => s.isMinimized);
const toggleMinimize = callStore((s) => s.toggleMinimize);
const endCall = callStore((s) => s.endCall);
// Only show when there's an active call and it's minimized
if (!currentCall || !isMinimized) return null;
const getStatusText = (): string => {
switch (currentCall.status) {
case 'calling':
return '等待接听...';
case 'ringing':
return '来电响铃...';
case 'connecting':
return '连接中...';
case 'connected':
return formatDuration(callDuration);
case 'reconnecting':
return '重连中...';
case 'ended':
return '已结束';
case 'failed':
return '连接失败';
default:
return '';
}
};
return (
<View style={styles.container}>
<TouchableOpacity
style={styles.window}
onPress={toggleMinimize}
activeOpacity={0.9}
>
{/* Avatar */}
<View style={styles.avatarContainer}>
{currentCall.peerAvatar ? (
<Image
source={{ uri: currentCall.peerAvatar }}
style={styles.avatar}
/>
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
</Text>
</View>
)}
</View>
{/* Info */}
<View style={styles.info}>
<Text style={styles.name} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
</Text>
<Text style={styles.status}>{getStatusText()}</Text>
</View>
{/* End call button */}
<TouchableOpacity
style={styles.endButton}
onPress={(e) => {
e.stopPropagation();
endCall('hangup');
}}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="phone-hangup" size={20} color="#fff" />
</TouchableOpacity>
</TouchableOpacity>
</View>
);
};
const styles = StyleSheet.create({
container: {
position: 'absolute',
top: 100,
left: 16,
right: 16,
zIndex: 10000,
},
window: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#2A2A4E',
borderRadius: 16,
padding: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
avatarContainer: {
marginRight: 12,
},
avatar: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: '#3A3A5C',
},
avatarPlaceholder: {
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 18,
color: '#fff',
fontWeight: '600',
},
info: {
flex: 1,
justifyContent: 'center',
},
name: {
fontSize: 15,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 2,
},
status: {
fontSize: 12,
color: 'rgba(255, 255, 255, 0.6)',
},
endButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: '#E54D42',
justifyContent: 'center',
alignItems: 'center',
marginLeft: 8,
},
});
export default FloatingCallWindow;

View File

@@ -0,0 +1,281 @@
import React, { useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
Animated,
Modal,
StatusBar,
Dimensions,
Platform,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { callStore } from '../../stores/callStore';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
const IncomingCallModal: React.FC = () => {
const incomingCall = callStore((s) => s.incomingCall);
const acceptCall = callStore((s) => s.acceptCall);
const rejectCall = callStore((s) => s.rejectCall);
const pulseAnim = useRef(new Animated.Value(1)).current;
const rippleAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (incomingCall) {
if (Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
const pulse = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.06,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
])
);
const ripple = Animated.loop(
Animated.sequence([
Animated.timing(rippleAnim, {
toValue: 1,
duration: 2000,
useNativeDriver: true,
}),
Animated.timing(rippleAnim, {
toValue: 0,
duration: 0,
useNativeDriver: true,
}),
])
);
Animated.parallel([pulse, ripple]).start();
return () => {
pulse.stop();
ripple.stop();
};
}
}, [incomingCall, pulseAnim, rippleAnim]);
const handleAccept = () => {
acceptCall();
};
const handleReject = () => {
rejectCall();
};
if (!incomingCall) return null;
return (
<Modal
visible={!!incomingCall}
transparent
animationType="fade"
statusBarTranslucent
>
<View style={styles.overlay}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
{/* Avatar with ripple effect - positioned in upper area */}
<View style={styles.avatarSection}>
{/* Ripple rings */}
{[0, 1, 2].map((i) => (
<Animated.View
key={i}
style={[
styles.rippleRing,
{
transform: [
{
scale: rippleAnim.interpolate({
inputRange: [0, 1],
outputRange: [1, 1.5 + i * 0.2],
}),
},
],
opacity: rippleAnim.interpolate({
inputRange: [0, 0.4, 1],
outputRange: [0.25, 0.12, 0],
}),
},
]}
/>
))}
<Animated.View
style={[styles.avatarContainer, { transform: [{ scale: pulseAnim }] }]}
>
{incomingCall.callerAvatar ? (
<Image
source={{ uri: incomingCall.callerAvatar }}
style={styles.avatar}
/>
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>
{incomingCall.callerName?.charAt(0).toUpperCase() || '?'}
</Text>
</View>
)}
</Animated.View>
</View>
{/* Caller info */}
<View style={styles.infoSection}>
<Text style={styles.callerName} numberOfLines={1}>
{incomingCall.callerName || '未知用户'}
</Text>
<Text style={styles.callType}>
{incomingCall.callType === 'video' ? '视频通话' : '语音通话'}
</Text>
</View>
{/* Bottom controls */}
<View style={styles.controls}>
{/* Decline */}
<TouchableOpacity
style={styles.actionButton}
onPress={handleReject}
activeOpacity={0.8}
>
<View style={styles.declineCircle}>
<MaterialCommunityIcons name="phone-hangup" size={28} color="#fff" />
</View>
<Text style={styles.actionLabel}></Text>
</TouchableOpacity>
{/* Accept */}
<TouchableOpacity
style={styles.actionButton}
onPress={handleAccept}
activeOpacity={0.8}
>
<View style={styles.acceptCircle}>
<MaterialCommunityIcons name="phone" size={28} color="#fff" />
</View>
<Text style={styles.actionLabel}></Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
};
const AVATAR_SIZE = 100;
const ACTION_CIRCLE_SIZE = 60;
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: '#0D0D0D',
},
avatarSection: {
position: 'absolute',
top: '18%',
left: 0,
right: 0,
height: AVATAR_SIZE + 60,
justifyContent: 'center',
alignItems: 'center',
},
rippleRing: {
position: 'absolute',
width: AVATAR_SIZE + 16,
height: AVATAR_SIZE + 16,
borderRadius: (AVATAR_SIZE + 16) / 2,
borderWidth: 2,
borderColor: '#4CD964',
},
avatarContainer: {
width: AVATAR_SIZE + 8,
height: AVATAR_SIZE + 8,
borderRadius: (AVATAR_SIZE + 8) / 2,
backgroundColor: '#4CD964',
justifyContent: 'center',
alignItems: 'center',
},
avatar: {
width: AVATAR_SIZE,
height: AVATAR_SIZE,
borderRadius: AVATAR_SIZE / 2,
backgroundColor: '#3A3A5C',
},
avatarPlaceholder: {
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 36,
color: '#fff',
fontWeight: '600',
},
infoSection: {
position: 'absolute',
top: '42%',
left: 0,
right: 0,
alignItems: 'center',
paddingHorizontal: 40,
},
callerName: {
fontSize: 24,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 6,
textAlign: 'center',
maxWidth: 280,
},
callType: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.5)',
letterSpacing: 0.3,
},
controls: {
position: 'absolute',
bottom: '12%',
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'center',
gap: 60,
},
actionButton: {
alignItems: 'center',
width: 80,
},
declineCircle: {
width: ACTION_CIRCLE_SIZE,
height: ACTION_CIRCLE_SIZE,
borderRadius: ACTION_CIRCLE_SIZE / 2,
backgroundColor: '#E54D42',
justifyContent: 'center',
alignItems: 'center',
},
acceptCircle: {
width: ACTION_CIRCLE_SIZE,
height: ACTION_CIRCLE_SIZE,
borderRadius: ACTION_CIRCLE_SIZE / 2,
backgroundColor: '#4CD964',
justifyContent: 'center',
alignItems: 'center',
},
actionLabel: {
fontSize: 12,
color: 'rgba(255, 255, 255, 0.6)',
marginTop: 10,
},
});
export default IncomingCallModal;

View File

@@ -0,0 +1,3 @@
export { default as CallScreen } from './CallScreen';
export { default as IncomingCallModal } from './IncomingCallModal';
export { default as FloatingCallWindow } from './FloatingCallWindow';

View File

@@ -15,6 +15,7 @@ import {
Modal, Modal,
Pressable, Pressable,
Dimensions, Dimensions,
Platform,
} from 'react-native'; } from 'react-native';
import { import {
useResponsive, useResponsive,
@@ -159,6 +160,10 @@ export function AdaptiveLayout({
// 抽屉动画 // 抽屉动画
useEffect(() => { useEffect(() => {
if (isDrawerOpen) { if (isDrawerOpen) {
if (Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
Animated.parallel([ Animated.parallel([
Animated.timing(slideAnim, { Animated.timing(slideAnim, {
toValue: 1, toValue: 1,

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; import { Modal, Pressable, StyleSheet, Text, TouchableOpacity, View, Platform } from 'react-native';
import type { AlertButton } from 'react-native'; import type { AlertButton } 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';
@@ -93,6 +93,12 @@ function createDialogHostStyles(colors: AppColors) {
}); });
} }
const blurActiveElementOnWeb = () => {
if (Platform.OS !== 'web') return;
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
};
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 colors = useAppColors();
@@ -105,6 +111,12 @@ const AppDialogHost: React.FC = () => {
return unbind; return unbind;
}, []); }, []);
useEffect(() => {
if (dialog) {
blurActiveElementOnWeb();
}
}, [dialog]);
const actions = useMemo<AlertButton[]>(() => { const actions = useMemo<AlertButton[]>(() => {
if (!dialog?.actions?.length) return [{ text: '确定' }]; if (!dialog?.actions?.length) return [{ text: '确定' }];
return dialog.actions; return dialog.actions;

View File

@@ -14,6 +14,7 @@ import {
Text, Text,
StatusBar, StatusBar,
Alert, Alert,
Platform,
} from 'react-native'; } from 'react-native';
import { Image as ExpoImage } from 'expo-image'; import { Image as ExpoImage } from 'expo-image';
import { import {
@@ -126,6 +127,10 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
// 打开/关闭时重置状态 // 打开/关闭时重置状态
useEffect(() => { useEffect(() => {
if (visible) { if (visible) {
if (Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
setCurrentIndex(initialIndex); setCurrentIndex(initialIndex);
setShowControls(true); setShowControls(true);
setError(false); setError(false);

View File

@@ -132,10 +132,10 @@ function createImageGridStyles(colors: AppColors) {
aspectRatio: 1, aspectRatio: 1,
}, },
gridItem2: { gridItem2: {
width: '49%', width: '48.5%',
}, },
gridItem3: { gridItem3: {
width: '32.5%', width: '31.8%',
}, },
masonryContainer: { masonryContainer: {
flexDirection: 'row', flexDirection: 'row',

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { ViewStyle } from 'react-native';
import PagerViewNative from 'react-native-pager-view';
type PagerViewProps = {
style?: ViewStyle;
initialPage?: number;
onPageSelected?: (e: { nativeEvent: { position: number } }) => void;
overdrag?: boolean;
children: React.ReactNode;
};
export const PagerView = React.forwardRef<PagerViewNative, PagerViewProps>(
({ style, initialPage, onPageSelected, overdrag, children }, ref) => {
return (
<PagerViewNative
ref={ref}
style={style}
initialPage={initialPage}
onPageSelected={onPageSelected}
overdrag={overdrag}
>
{children}
</PagerViewNative>
);
}
);
PagerView.displayName = 'PagerView';
export default PagerView;

View File

@@ -0,0 +1,27 @@
import React, { ForwardedRef } from 'react';
import { View, ViewStyle } from 'react-native';
type PagerViewProps = {
style?: ViewStyle;
initialPage?: number;
onPageSelected?: (e: { nativeEvent: { position: number } }) => void;
overdrag?: boolean;
children: React.ReactNode;
};
function PagerViewInternal(
{ style, children }: PagerViewProps,
_ref: ForwardedRef<{ setPage: (page: number) => void }>
) {
const childrenArray = React.Children.toArray(children);
return (
<View style={style}>
{childrenArray[0]}
</View>
);
}
export const PagerView = React.forwardRef(PagerViewInternal);
PagerView.displayName = 'PagerView';
export default PagerView;

View File

@@ -0,0 +1,200 @@
/**
* 步骤指示器组件 - 极简扁平设计
* 与下方表单风格统一
*/
import React from 'react';
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
import { useAppColors, type AppColors } from '../../theme';
export interface Step {
id: number;
title: string;
}
export interface StepIndicatorProps {
steps: Step[];
currentStep: number;
containerStyle?: ViewStyle;
}
export const StepIndicator: React.FC<StepIndicatorProps> = ({
steps,
currentStep,
containerStyle,
}) => {
const colors = useAppColors();
const styles = createStyles(colors);
return (
<View style={[styles.container, containerStyle]}>
{steps.map((step, index) => {
const isCompleted = index < currentStep;
const isCurrent = index === currentStep;
const isPending = index > currentStep;
const isLast = index === steps.length - 1;
return (
<React.Fragment key={step.id}>
<View style={styles.stepWrapper}>
{/* 步骤编号/状态 */}
<View
style={[
styles.stepBadge,
isCompleted && styles.stepBadgeCompleted,
isCurrent && styles.stepBadgeCurrent,
isPending && styles.stepBadgePending,
]}
>
<Text
style={[
styles.stepBadgeText,
isCompleted && styles.stepBadgeTextCompleted,
isCurrent && styles.stepBadgeTextCurrent,
isPending && styles.stepBadgeTextPending,
]}
>
{isCompleted ? '✓' : index + 1}
</Text>
</View>
{/* 步骤标题 */}
<Text
style={[
styles.stepTitle,
isCompleted && styles.stepTitleCompleted,
isCurrent && styles.stepTitleCurrent,
isPending && styles.stepTitlePending,
]}
>
{step.title}
</Text>
</View>
{/* 连接线 */}
{!isLast && (
<View style={styles.connector}>
<View
style={[
styles.connectorLine,
isCompleted && styles.connectorLineCompleted,
]}
/>
</View>
)}
</React.Fragment>
);
})}
</View>
);
};
// 注册流程专用步骤指示器
export const RegisterStepIndicator: React.FC<{
currentStep: number;
containerStyle?: ViewStyle;
}> = ({ currentStep, containerStyle }) => {
const steps: Step[] = [
{ id: 0, title: '输入邮箱' },
{ id: 1, title: '验证码' },
{ id: 2, title: '设置信息' },
];
return (
<StepIndicator
steps={steps}
currentStep={currentStep}
containerStyle={containerStyle}
/>
);
};
function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
width: '100%',
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'center',
paddingHorizontal: 16,
paddingVertical: 8,
},
stepWrapper: {
alignItems: 'center',
minWidth: 80,
},
// 步骤徽章
stepBadge: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#F5F5F7',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 0,
},
stepBadgeCompleted: {
backgroundColor: '#FF6B35',
},
stepBadgeCurrent: {
backgroundColor: '#FFFFFF',
borderWidth: 2,
borderColor: '#FF6B35',
},
stepBadgePending: {
backgroundColor: '#F5F5F7',
},
stepBadgeText: {
fontSize: 13,
fontWeight: '600',
color: '#999',
},
stepBadgeTextCompleted: {
color: '#FFFFFF',
fontSize: 14,
},
stepBadgeTextCurrent: {
color: '#FF6B35',
},
stepBadgeTextPending: {
color: '#BBB',
},
// 步骤标题
stepTitle: {
marginTop: 8,
fontSize: 12,
color: '#999',
fontWeight: '500',
textAlign: 'center',
},
stepTitleCompleted: {
color: '#FF6B35',
fontWeight: '600',
},
stepTitleCurrent: {
color: '#333',
fontWeight: '600',
},
stepTitlePending: {
color: '#BBB',
},
// 连接线
connector: {
width: 60,
height: 28,
justifyContent: 'center',
alignItems: 'center',
},
connectorLine: {
width: '100%',
height: 2,
backgroundColor: '#E5E5E5',
borderRadius: 1,
},
connectorLineCompleted: {
backgroundColor: '#FF6B35',
},
});
}
export default StepIndicator;

View File

@@ -4,8 +4,8 @@
*/ */
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, Platform } from 'react-native';
import { useAppColors, fontSizes } from '../../theme'; import { useAppColors, fontSizes, fontWeights } from '../../theme';
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label'; type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
@@ -16,38 +16,45 @@ interface CustomTextProps extends Omit<TextProps, 'style'> {
numberOfLines?: number; numberOfLines?: number;
onPress?: () => void; onPress?: () => void;
style?: TextStyle | TextStyle[]; style?: TextStyle | TextStyle[];
weight?: 'regular' | 'medium' | 'semibold' | 'bold';
} }
const variantStyles: Record<TextVariant, object> = { const variantStyles: Record<TextVariant, object> = {
h1: { h1: {
fontSize: fontSizes['4xl'], fontSize: fontSizes['4xl'],
fontWeight: '700', fontWeight: fontWeights.bold,
lineHeight: fontSizes['4xl'] * 1.4, lineHeight: fontSizes['4xl'] * 1.3,
letterSpacing: -0.5,
}, },
h2: { h2: {
fontSize: fontSizes['3xl'], fontSize: fontSizes['3xl'],
fontWeight: '600', fontWeight: fontWeights.semibold,
lineHeight: fontSizes['3xl'] * 1.4, lineHeight: fontSizes['3xl'] * 1.3,
letterSpacing: -0.3,
}, },
h3: { h3: {
fontSize: fontSizes['2xl'], fontSize: fontSizes['2xl'],
fontWeight: '600', fontWeight: fontWeights.semibold,
lineHeight: fontSizes['2xl'] * 1.3, lineHeight: fontSizes['2xl'] * 1.3,
letterSpacing: -0.2,
}, },
body: { body: {
fontSize: fontSizes.md, fontSize: fontSizes.md,
fontWeight: '400', fontWeight: fontWeights.regular,
lineHeight: fontSizes.md * 1.5, lineHeight: fontSizes.md * 1.6,
letterSpacing: 0.1,
}, },
caption: { caption: {
fontSize: fontSizes.sm, fontSize: fontSizes.sm,
fontWeight: '400', fontWeight: fontWeights.regular,
lineHeight: fontSizes.sm * 1.4, lineHeight: fontSizes.sm * 1.5,
letterSpacing: 0.2,
}, },
label: { label: {
fontSize: fontSizes.xs, fontSize: fontSizes.xs,
fontWeight: '500', fontWeight: fontWeights.medium,
lineHeight: fontSizes.xs * 1.4, lineHeight: fontSizes.xs * 1.4,
letterSpacing: 0.3,
}, },
}; };
@@ -55,6 +62,7 @@ const Text: React.FC<CustomTextProps> = ({
children, children,
variant = 'body', variant = 'body',
color, color,
weight,
numberOfLines, numberOfLines,
onPress, onPress,
style, style,
@@ -64,6 +72,7 @@ const Text: React.FC<CustomTextProps> = ({
const textStyle = [ const textStyle = [
styles.base, styles.base,
variantStyles[variant], variantStyles[variant],
weight ? { fontWeight: fontWeights[weight] } : null,
color ? { color } : { color: colors.text.primary }, color ? { color } : { color: colors.text.primary },
style, style,
]; ];

View File

@@ -0,0 +1,256 @@
/**
* 验证码输入组件
* 参考设计4-6位数字验证码每个数字独立显示在一个方框中
* 支持自动聚焦、粘贴、删除回退
*/
import React, { useRef, useEffect, useCallback } from 'react';
import {
View,
TextInput,
StyleSheet,
Keyboard,
Platform,
Text,
ViewStyle,
} from 'react-native';
import { useAppColors, type AppColors } from '../../theme';
interface VerificationCodeInputProps {
value: string;
onChangeValue: (value: string) => void;
length?: number; // 验证码位数默认4位
autoFocus?: boolean;
onComplete?: (value: string) => void; // 输入完成回调
containerStyle?: ViewStyle;
disabled?: boolean;
}
const CELL_WIDTH = 56;
const CELL_HEIGHT = 64;
const CELL_MARGIN = 8;
export const VerificationCodeInput: React.FC<VerificationCodeInputProps> = ({
value,
onChangeValue,
length = 4,
autoFocus = true,
onComplete,
containerStyle,
disabled = false,
}) => {
const colors = useAppColors();
const styles = createStyles(colors);
// 为每个数字创建一个 ref
const inputRefs = useRef<(TextInput | null)[]>(Array(length).fill(null));
// 将 value 分割为单个字符数组
const chars = value.split('').slice(0, length);
// 填充空位
while (chars.length < length) {
chars.push('');
}
// 获取当前聚焦的位置
const getFocusedIndex = useCallback(() => {
return Math.min(value.length, length - 1);
}, [value.length, length]);
// 聚焦到指定位置
const focusInput = useCallback((index: number) => {
if (index >= 0 && index < length) {
inputRefs.current[index]?.focus();
}
}, [length]);
// 自动聚焦第一个输入框
useEffect(() => {
if (autoFocus && !disabled) {
const timer = setTimeout(() => {
focusInput(0);
}, 100);
return () => clearTimeout(timer);
}
}, [autoFocus, disabled, focusInput]);
// 当值改变时,自动聚焦到下一个输入框
useEffect(() => {
if (value.length < length) {
focusInput(value.length);
}
// 当输入完成时触发回调
if (value.length === length && onComplete) {
onComplete(value);
}
}, [value, length, onComplete, focusInput]);
// 处理文本变化
const handleChangeText = (text: string, index: number) => {
if (disabled) return;
// 只允许数字
const numericText = text.replace(/[^0-9]/g, '');
if (numericText.length === 0) {
// 删除操作
if (value.length > index) {
// 删除当前位置的字符
const newValue = value.slice(0, index) + value.slice(index + 1);
onChangeValue(newValue);
// 聚焦到前一个输入框
if (index > 0) {
focusInput(index - 1);
}
}
} else if (numericText.length === 1) {
// 单个字符输入
let newValue: string;
if (index < value.length) {
// 替换已有字符
newValue = value.slice(0, index) + numericText + value.slice(index + 1);
} else {
// 追加新字符
newValue = value + numericText;
}
onChangeValue(newValue.slice(0, length));
// 自动聚焦到下一个
if (index < length - 1) {
focusInput(index + 1);
}
} else if (numericText.length > 1) {
// 粘贴操作:尝试将粘贴的内容作为完整验证码
const newValue = numericText.slice(0, length);
onChangeValue(newValue);
// 聚焦到最后一个或下一个
const focusIndex = Math.min(newValue.length, length - 1);
focusInput(focusIndex);
}
};
// 处理按键事件(用于删除键)
const handleKeyPress = (e: any, index: number) => {
if (disabled) return;
if (e.nativeEvent.key === 'Backspace') {
if (value.length > index) {
// 删除当前位置的字符
const newValue = value.slice(0, index) + value.slice(index + 1);
onChangeValue(newValue);
} else if (index > 0 && value.length === index) {
// 当前位置没有字符,删除前一个
const newValue = value.slice(0, -1);
onChangeValue(newValue);
focusInput(index - 1);
}
}
};
// 聚焦时全选文本
const handleFocus = (index: number) => {
if (disabled) return;
inputRefs.current[index]?.setNativeProps({
selection: { start: 0, end: 1 },
});
};
return (
<View style={[styles.container, containerStyle]}>
<View style={styles.cellsContainer}>
{chars.map((char, index) => {
const isFocused = value.length === index;
const isFilled = char !== '';
const isLastFilled = index === value.length - 1 && isFilled;
return (
<View
key={index}
style={[
styles.cell,
isFocused && styles.cellFocused,
isFilled && styles.cellFilled,
disabled && styles.cellDisabled,
]}
>
<TextInput
ref={(ref) => {
inputRefs.current[index] = ref;
}}
style={styles.cellInput}
value={char}
onChangeText={(text) => handleChangeText(text, index)}
onKeyPress={(e) => handleKeyPress(e, index)}
onFocus={() => handleFocus(index)}
keyboardType="number-pad"
maxLength={1}
selectTextOnFocus
editable={!disabled}
caretHidden={true}
textContentType="oneTimeCode" // iOS 自动填充支持
/>
{/* 光标指示器 */}
{isFocused && !isFilled && !disabled && (
<View style={styles.cursor} />
)}
</View>
);
})}
</View>
</View>
);
};
function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
},
cellsContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
cell: {
width: CELL_WIDTH,
height: CELL_HEIGHT,
borderRadius: 12,
borderWidth: 1.5,
borderColor: colors.divider,
backgroundColor: colors.background.paper,
marginHorizontal: CELL_MARGIN / 2,
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
},
cellFocused: {
borderColor: colors.primary.main,
backgroundColor: `${colors.primary.main}10`,
},
cellFilled: {
borderColor: colors.primary.main,
},
cellDisabled: {
opacity: 0.5,
backgroundColor: colors.background.disabled,
},
cellInput: {
width: '100%',
height: '100%',
textAlign: 'center',
fontSize: 28,
fontWeight: '600',
color: colors.text.primary,
padding: 0,
},
cursor: {
position: 'absolute',
width: 2,
height: 28,
backgroundColor: colors.primary.main,
borderRadius: 1,
},
});
}
export default VerificationCodeInput;

View File

@@ -3,7 +3,7 @@
* 全屏模态播放视频,基于 expo-video * 全屏模态播放视频,基于 expo-video
*/ */
import React, { useCallback } from 'react'; import React, { useCallback, useEffect } from 'react';
import { import {
Modal, Modal,
View, View,
@@ -12,6 +12,7 @@ import {
TouchableOpacity, TouchableOpacity,
Text, Text,
StatusBar, StatusBar,
Platform,
} from 'react-native'; } from 'react-native';
import { VideoView, useVideoPlayer } from 'expo-video'; import { VideoView, useVideoPlayer } from 'expo-video';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
@@ -79,6 +80,13 @@ export const VideoPlayerModal: React.FC<VideoPlayerModalProps> = ({
url, url,
onClose, onClose,
}) => { }) => {
useEffect(() => {
if (visible && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
}, [visible]);
return ( return (
<Modal <Modal
visible={visible} visible={visible}

View File

@@ -17,6 +17,7 @@ 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 AppBackButton } from './AppBackButton';
export { default as PagerView } from './PagerView';
// 图片相关组件 // 图片相关组件
export { default as SmartImage } from './SmartImage'; export { default as SmartImage } from './SmartImage';
@@ -32,3 +33,8 @@ export type { ImageGalleryProps, GalleryImageItem } from './ImageGallery';
export type { ResponsiveGridProps } from './ResponsiveGrid'; export type { ResponsiveGridProps } from './ResponsiveGrid';
export type { ResponsiveStackProps, StackDirection, StackAlignment, StackJustify } from './ResponsiveStack'; export type { ResponsiveStackProps, StackDirection, StackAlignment, StackJustify } from './ResponsiveStack';
export type { AdaptiveLayoutProps, SidebarLayoutProps } from './AdaptiveLayout'; export type { AdaptiveLayoutProps, SidebarLayoutProps } from './AdaptiveLayout';
// 注册流程相关组件
export { default as VerificationCodeInput } from './VerificationCodeInput';
export { default as StepIndicator, RegisterStepIndicator } from './StepIndicator';
export type { Step, StepIndicatorProps } from './StepIndicator';

View File

@@ -4,8 +4,8 @@
* 纯业务逻辑无UI依赖 * 纯业务逻辑无UI依赖
*/ */
import { messageRepository } from '../../data/repositories/MessageRepository'; import { messageRepository, userCacheRepository, CachedMessage } from '@/database';
import { sseClient, SSEEventType } from '../../data/datasources/SSEClient'; import { wsClient, WSEventType } from '../../data/datasources/WSClient';
import { import {
Message, Message,
Conversation, Conversation,
@@ -18,6 +18,16 @@ import {
import { messageService } from '../../services/messageService'; import { messageService } from '../../services/messageService';
import { api } from '../../services/api'; import { api } from '../../services/api';
const cachedMessageToMessage = (cached: CachedMessage): Message => ({
id: cached.id,
conversationId: cached.conversationId,
senderId: cached.senderId,
seq: cached.seq,
segments: cached.segments || [],
createdAt: cached.createdAt,
status: cached.status as Message['status'],
});
// 事件类型定义 // 事件类型定义
export type MessageUseCaseEventType = export type MessageUseCaseEventType =
| 'message_received' | 'message_received'
@@ -62,7 +72,7 @@ class ProcessMessageUseCase {
*/ */
initialize(currentUserId: string | null): void { initialize(currentUserId: string | null): void {
this.currentUserId = currentUserId; this.currentUserId = currentUserId;
this.setupSSEListeners(); this.setupWSListeners();
} }
/** /**
@@ -81,49 +91,49 @@ class ProcessMessageUseCase {
/** /**
* 设置SSE监听器 * 设置SSE监听器
*/ */
private setupSSEListeners(): void { private setupWSListeners(): void {
// 监听私聊消息 // 监听私聊消息
const unsubChat = sseClient.on('chat', (message) => { const unsubChat = wsClient.on('chat', (message) => {
this.handleNewMessage(message); this.handleNewMessage(message);
}); });
// 监听群聊消息 // 监听群聊消息
const unsubGroupMessage = sseClient.on('group_message', (message) => { const unsubGroupMessage = wsClient.on('group_message', (message) => {
this.handleNewMessage(message); this.handleNewMessage(message);
}); });
// 监听私聊已读回执 // 监听私聊已读回执
const unsubRead = sseClient.on('read', (message) => { const unsubRead = wsClient.on('read', (message) => {
this.handleReadReceipt(message); this.handleReadReceipt(message);
}); });
// 监听群聊已读回执 // 监听群聊已读回执
const unsubGroupRead = sseClient.on('group_read', (message) => { const unsubGroupRead = wsClient.on('group_read', (message) => {
this.handleGroupReadReceipt(message); this.handleGroupReadReceipt(message);
}); });
// 监听私聊消息撤回 // 监听私聊消息撤回
const unsubRecall = sseClient.on('recall', (message) => { const unsubRecall = wsClient.on('recall', (message) => {
this.handleRecallMessage(message); this.handleRecallMessage(message);
}); });
// 监听群聊消息撤回 // 监听群聊消息撤回
const unsubGroupRecall = sseClient.on('group_recall', (message) => { const unsubGroupRecall = wsClient.on('group_recall', (message) => {
this.handleGroupRecallMessage(message); this.handleGroupRecallMessage(message);
}); });
// 监听群聊输入状态 // 监听群聊输入状态
const unsubGroupTyping = sseClient.on('group_typing', (message) => { const unsubGroupTyping = wsClient.on('group_typing', (message) => {
this.handleGroupTyping(message); this.handleGroupTyping(message);
}); });
// 监听群通知 // 监听群通知
const unsubGroupNotice = sseClient.on('group_notice', (message) => { const unsubGroupNotice = wsClient.on('group_notice', (message) => {
this.handleGroupNotice(message); this.handleGroupNotice(message);
}); });
// 监听连接状态 // 监听连接状态
const unsubConnected = sseClient.on('connected', () => { const unsubConnected = wsClient.on('connected', () => {
this.notifySubscribers({ this.notifySubscribers({
type: 'connection_changed', type: 'connection_changed',
payload: { connected: true }, payload: { connected: true },
@@ -131,7 +141,7 @@ class ProcessMessageUseCase {
}); });
}); });
const unsubDisconnected = sseClient.on('disconnected', () => { const unsubDisconnected = wsClient.on('disconnected', () => {
this.notifySubscribers({ this.notifySubscribers({
type: 'connection_changed', type: 'connection_changed',
payload: { connected: false }, payload: { connected: false },
@@ -182,7 +192,16 @@ class ProcessMessageUseCase {
// 异步保存到本地数据库 // 异步保存到本地数据库
const isRead = _isAck || sender_id === this.currentUserId; const isRead = _isAck || sender_id === this.currentUserId;
messageRepository.saveMessage(newMessage, isRead).catch((error) => { messageRepository.saveMessage({
id: newMessage.id,
conversationId: newMessage.conversationId,
senderId: newMessage.senderId,
seq: newMessage.seq,
segments: newMessage.segments,
createdAt: newMessage.createdAt,
status: newMessage.status || 'normal',
isRead,
}).catch((error) => {
console.error('[ProcessMessageUseCase] 保存消息失败:', error); console.error('[ProcessMessageUseCase] 保存消息失败:', error);
}); });
@@ -227,7 +246,7 @@ class ProcessMessageUseCase {
*/ */
private async getSenderInfo(userId: string): Promise<any | null> { private async getSenderInfo(userId: string): Promise<any | null> {
// 先检查本地缓存 // 先检查本地缓存
const cachedUser = await messageRepository.getUserCache(userId); const cachedUser = await userCacheRepository.get(userId);
if (cachedUser) { if (cachedUser) {
return cachedUser; return cachedUser;
} }
@@ -255,9 +274,9 @@ class ProcessMessageUseCase {
*/ */
private async fetchUserInfo(userId: string): Promise<any | null> { private async fetchUserInfo(userId: string): Promise<any | null> {
try { try {
const response = await api.get(`/users/${userId}`); const response = await api.get<any>(`/users/${userId}`);
if (response.code === 0 && response.data) { if (response.code === 0 && response.data) {
await messageRepository.saveUserCache(response.data); await userCacheRepository.save(response.data as any);
return response.data; return response.data;
} }
return null; return null;
@@ -322,8 +341,8 @@ class ProcessMessageUseCase {
// 更新本地数据库状态 // 更新本地数据库状态
messageRepository messageRepository
.updateMessageStatus(message_id, 'recalled', true) .updateStatus(message_id, 'recalled', true)
.catch((error) => { .catch((error: unknown) => {
console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error); console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error);
}); });
@@ -349,8 +368,8 @@ class ProcessMessageUseCase {
// 更新本地数据库状态 // 更新本地数据库状态
messageRepository messageRepository
.updateMessageStatus(message_id, 'recalled', true) .updateStatus(message_id, 'recalled', true)
.catch((error) => { .catch((error: unknown) => {
console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error); console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error);
}); });
@@ -506,7 +525,16 @@ class ProcessMessageUseCase {
}); });
// 保存到本地数据库 // 保存到本地数据库
await messageRepository.saveMessage(message, true); await messageRepository.saveMessage({
id: message.id,
conversationId: message.conversationId,
senderId: message.senderId,
seq: message.seq,
segments: message.segments,
createdAt: message.createdAt,
status: message.status || 'normal',
isRead: true,
});
return message; return message;
} }
@@ -522,7 +550,8 @@ class ProcessMessageUseCase {
* 获取本地消息 * 获取本地消息
*/ */
async getLocalMessages(conversationId: string, limit: number = 20): Promise<Message[]> { async getLocalMessages(conversationId: string, limit: number = 20): Promise<Message[]> {
return messageRepository.getMessagesByConversation(conversationId, limit); const cached = await messageRepository.getByConversation(conversationId, limit);
return cached.map(cachedMessageToMessage);
} }
/** /**
@@ -534,14 +563,14 @@ class ProcessMessageUseCase {
limit: number = 20 limit: number = 20
): Promise<Message[]> { ): Promise<Message[]> {
// 先从本地获取 // 先从本地获取
const localMessages = await messageRepository.getMessagesBeforeSeq( const localMessages = await messageRepository.getBeforeSeq(
conversationId, conversationId,
beforeSeq, beforeSeq,
limit limit
); );
if (localMessages.length >= limit) { if (localMessages.length >= limit) {
return localMessages; return localMessages.map(cachedMessageToMessage);
} }
// 本地数据不足,从服务端获取 // 本地数据不足,从服务端获取
@@ -567,7 +596,16 @@ class ProcessMessageUseCase {
); );
// 保存到本地 // 保存到本地
await messageRepository.saveMessages(serverMessages, false); await messageRepository.saveMessagesBatch(serverMessages.map((m: any) => ({
id: m.id,
conversationId: m.conversation_id || conversationId,
senderId: m.sender_id,
seq: m.seq,
segments: m.segments || [],
createdAt: m.created_at,
status: m.status || 'normal',
isRead: false,
})));
return serverMessages; return serverMessages;
} }
@@ -575,7 +613,7 @@ class ProcessMessageUseCase {
console.error('[ProcessMessageUseCase] 获取历史消息失败:', error); console.error('[ProcessMessageUseCase] 获取历史消息失败:', error);
} }
return localMessages; return localMessages.map(cachedMessageToMessage);
} }
/** /**
@@ -602,7 +640,16 @@ class ProcessMessageUseCase {
); );
// 保存到本地 // 保存到本地
await messageRepository.saveMessages(messages, false); await messageRepository.saveMessagesBatch(messages.map((m: any) => ({
id: m.id,
conversationId: m.conversation_id || conversationId,
senderId: m.sender_id,
seq: m.seq,
segments: m.segments || [],
createdAt: m.created_at,
status: m.status || 'normal',
isRead: false,
})));
return messages; return messages;
} }

View File

@@ -1,323 +0,0 @@
/**
* 本地数据库数据源实现
* 封装所有 SQLite 操作,提供统一的错误处理和事务支持
*/
import * as SQLite from 'expo-sqlite';
import { ILocalDataSource, DataSourceError } from './interfaces';
// 数据库实例管理
let dbInstance: SQLite.SQLiteDatabase | null = null;
let currentDbName: string | null = null;
let writeQueue: Promise<void> = Promise.resolve();
export interface LocalDataSourceConfig {
dbName?: string;
userId?: string;
}
export class LocalDataSource implements ILocalDataSource {
private db: SQLite.SQLiteDatabase | null = null;
private dbName: string;
private initialized = false;
private initializingPromise: Promise<void> | null = null;
constructor(config: LocalDataSourceConfig = {}) {
// 如果提供了userId使用用户专属数据库
this.dbName = config.dbName || (config.userId ? `carrot_bbs_${config.userId}.db` : 'carrot_bbs.db');
}
/**
* 初始化数据库连接
*/
async initialize(): Promise<void> {
if (this.initialized && this.db) {
return;
}
// 防止并发初始化导致连接状态竞争
if (this.initializingPromise) {
await this.initializingPromise;
return;
}
this.initializingPromise = this.doInitialize();
try {
await this.initializingPromise;
} finally {
this.initializingPromise = null;
}
}
private async doInitialize(): Promise<void> {
try {
// 使用全局实例管理
if (dbInstance && currentDbName === this.dbName) {
this.db = dbInstance;
this.initialized = true;
return;
}
// 关闭旧连接
if (dbInstance) {
try {
await dbInstance.closeAsync();
} catch (e) {
console.warn('关闭旧数据库连接失败:', e);
}
}
// 创建新连接
this.db = await SQLite.openDatabaseAsync(this.dbName);
dbInstance = this.db;
currentDbName = this.dbName;
this.initialized = true;
// 初始化数据库表结构
await this.createTables();
} catch (error) {
this.handleError(error, 'INITIALIZE');
}
}
/**
* 创建数据库表结构
*/
private async createTables(): Promise<void> {
if (!this.db) return;
const tables = [
// 消息表
`CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY NOT NULL,
conversationId TEXT NOT NULL,
senderId TEXT NOT NULL,
content TEXT,
type TEXT DEFAULT 'text',
isRead INTEGER DEFAULT 0,
createdAt TEXT NOT NULL,
seq INTEGER DEFAULT 0,
status TEXT DEFAULT 'normal',
segments TEXT
)`,
// 会话表
`CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY NOT NULL,
participantId TEXT NOT NULL,
lastMessageId TEXT,
lastSeq INTEGER DEFAULT 0,
unreadCount INTEGER DEFAULT 0,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
// 会话缓存表
`CREATE TABLE IF NOT EXISTS conversation_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
// 会话列表缓存表
`CREATE TABLE IF NOT EXISTS conversation_list_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
// 用户缓存表
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
// 当前登录用户缓存
`CREATE TABLE IF NOT EXISTS current_user_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
// 群组缓存表
`CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
// 群成员缓存表
`CREATE TABLE IF NOT EXISTS group_members (
groupId TEXT NOT NULL,
userId TEXT NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL,
PRIMARY KEY (groupId, userId)
)`,
// 帖子缓存表
`CREATE TABLE IF NOT EXISTS posts_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
];
for (const sql of tables) {
await this.db.execAsync(sql);
}
// 创建索引
const indexes = [
`CREATE INDEX IF NOT EXISTS idx_messages_conversationId ON messages(conversationId)`,
`CREATE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq)`,
`CREATE INDEX IF NOT EXISTS idx_messages_conversation_seq ON messages(conversationId, seq)`,
`CREATE INDEX IF NOT EXISTS idx_group_members_groupId ON group_members(groupId)`,
];
for (const sql of indexes) {
await this.db.execAsync(sql);
}
}
/**
* 处理错误
*/
private handleError(error: unknown, operation: string): never {
const message = error instanceof Error ? error.message : 'Unknown error';
throw new DataSourceError(
`Database ${operation} failed: ${message}`,
'DB_ERROR',
'LocalDataSource',
error instanceof Error ? error : undefined
);
}
/**
* 检查数据库连接
*/
private ensureDb(): SQLite.SQLiteDatabase {
if (!this.db) {
throw new DataSourceError(
'Database not initialized',
'DB_NOT_INITIALIZED',
'LocalDataSource'
);
}
return this.db;
}
// ==================== ILocalDataSource 实现 ====================
async query<T>(sql: string, params?: any[]): Promise<T[]> {
try {
const db = this.ensureDb();
if (params && params.length > 0) {
return await db.getAllAsync<T>(sql, params as any);
}
return await db.getAllAsync<T>(sql);
} catch (error) {
this.handleError(error, 'QUERY');
}
}
async getFirst<T>(sql: string, params?: any[]): Promise<T | null> {
try {
const db = this.ensureDb();
if (params && params.length > 0) {
return await db.getFirstAsync<T>(sql, params as any);
}
return await db.getFirstAsync<T>(sql);
} catch (error) {
this.handleError(error, 'GET_FIRST');
}
}
async execute(sql: string, params?: any[]): Promise<void> {
try {
const db = this.ensureDb();
await db.execAsync(sql);
} catch (error) {
this.handleError(error, 'EXECUTE');
}
}
async run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }> {
try {
const db = this.ensureDb();
if (params && params.length > 0) {
return await db.runAsync(sql, params as any);
}
return await db.runAsync(sql);
} catch (error) {
if (this.isRecoverableError(error)) {
this.db = null;
this.initialized = false;
dbInstance = null;
await this.initialize();
const db = this.ensureDb();
if (params && params.length > 0) {
return await db.runAsync(sql, params as any);
}
return await db.runAsync(sql);
}
this.handleError(error, 'RUN');
}
}
async transaction(operations: () => Promise<void>): Promise<void> {
try {
const db = this.ensureDb();
await db.withTransactionAsync(async () => {
await operations();
});
} catch (error) {
this.handleError(error, 'TRANSACTION');
}
}
async batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void> {
try {
await this.transaction(async () => {
await operations(this);
});
} catch (error) {
this.handleError(error, 'BATCH');
}
}
// ==================== 队列写入支持 ====================
/**
* 使用队列执行写入操作(避免并发写入冲突)
*/
async enqueueWrite<T>(operation: () => Promise<T>): Promise<T> {
const wrappedOperation = async () => {
try {
return await operation();
} catch (error) {
// 尝试重连后重试
if (this.isRecoverableError(error)) {
console.error('数据库写入异常,尝试重连后重试:', error);
this.db = null;
dbInstance = null;
await this.initialize();
return operation();
}
throw error;
}
};
const queued = writeQueue.then(wrappedOperation, wrappedOperation);
writeQueue = queued.then(() => undefined, () => undefined);
return queued;
}
/**
* 判断错误是否可恢复
*/
private isRecoverableError(error: unknown): boolean {
const message = String(error);
return (
message.includes('NativeDatabase.prepareAsync') ||
message.includes('NullPointerException') ||
message.includes('database is locked')
);
}
}
// 导出单例实例
export const localDataSource = new LocalDataSource();

View File

@@ -1,10 +1,10 @@
/** /**
* SSEClient - SSE连接管理 * WSClient - WebSocket连接管理
* SSE连接管理 * WebSocket连接管理
*/ */
import { import {
sseService, wsService,
WSChatMessage, WSChatMessage,
WSGroupChatMessage, WSGroupChatMessage,
WSReadMessage, WSReadMessage,
@@ -14,13 +14,13 @@ import {
WSGroupTypingMessage, WSGroupTypingMessage,
WSGroupNoticeMessage, WSGroupNoticeMessage,
WSMessageType, WSMessageType,
} from '../../services/sseService'; } from '../../services/wsService';
// 事件处理器类型 // 事件处理器类型
type EventHandler<T> = (data: T) => void; type EventHandler<T> = (data: T) => void;
// SSE事件类型 // WS事件类型
export interface SSEEvents { export interface WSEvents {
'chat': WSChatMessage; 'chat': WSChatMessage;
'group_message': WSGroupChatMessage; 'group_message': WSGroupChatMessage;
'read': WSReadMessage; 'read': WSReadMessage;
@@ -34,65 +34,65 @@ export interface SSEEvents {
'error': Error; 'error': Error;
} }
export type SSEEventType = keyof SSEEvents; export type WSEventType = keyof WSEvents;
class SSEClient { class WSClient {
private handlers: Map<SSEEventType, Set<EventHandler<any>>> = new Map(); private handlers: Map<WSEventType, Set<EventHandler<any>>> = new Map();
private unsubscribeFns: Array<() => void> = []; private unsubscribeFns: Array<() => void> = [];
private isInitialized = false; private isInitialized = false;
/** /**
* SSE监听 * WebSocket监听
*/ */
initialize(): void { initialize(): void {
if (this.isInitialized) return; if (this.isInitialized) return;
// 监听私聊消息 // 监听私聊消息
const unsubChat = sseService.on('chat', (message) => { const unsubChat = wsService.on('chat', (message) => {
this.emit('chat', message); this.emit('chat', message);
}); });
// 监听群聊消息 // 监听群聊消息
const unsubGroupMessage = sseService.on('group_message', (message) => { const unsubGroupMessage = wsService.on('group_message', (message) => {
this.emit('group_message', message); this.emit('group_message', message);
}); });
// 监听私聊已读回执 // 监听私聊已读回执
const unsubRead = sseService.on('read', (message) => { const unsubRead = wsService.on('read', (message) => {
this.emit('read', message); this.emit('read', message);
}); });
// 监听群聊已读回执 // 监听群聊已读回执
const unsubGroupRead = sseService.on('group_read', (message) => { const unsubGroupRead = wsService.on('group_read', (message) => {
this.emit('group_read', message); this.emit('group_read', message);
}); });
// 监听私聊消息撤回 // 监听私聊消息撤回
const unsubRecall = sseService.on('recall', (message) => { const unsubRecall = wsService.on('recall', (message) => {
this.emit('recall', message); this.emit('recall', message);
}); });
// 监听群聊消息撤回 // 监听群聊消息撤回
const unsubGroupRecall = sseService.on('group_recall', (message) => { const unsubGroupRecall = wsService.on('group_recall', (message) => {
this.emit('group_recall', message); this.emit('group_recall', message);
}); });
// 监听群聊输入状态 // 监听群聊输入状态
const unsubGroupTyping = sseService.on('group_typing', (message) => { const unsubGroupTyping = wsService.on('group_typing', (message) => {
this.emit('group_typing', message); this.emit('group_typing', message);
}); });
// 监听群通知 // 监听群通知
const unsubGroupNotice = sseService.on('group_notice', (message) => { const unsubGroupNotice = wsService.on('group_notice', (message) => {
this.emit('group_notice', message); this.emit('group_notice', message);
}); });
// 监听连接状态 // 监听连接状态
const unsubConnect = sseService.onConnect(() => { const unsubConnect = wsService.onConnect(() => {
this.emit('connected', undefined); this.emit('connected', undefined);
}); });
const unsubDisconnect = sseService.onDisconnect(() => { const unsubDisconnect = wsService.onDisconnect(() => {
this.emit('disconnected', undefined); this.emit('disconnected', undefined);
}); });
@@ -113,7 +113,7 @@ class SSEClient {
} }
/** /**
* SSE监听 * WebSocket监听
*/ */
destroy(): void { destroy(): void {
this.unsubscribeFns.forEach((fn) => fn()); this.unsubscribeFns.forEach((fn) => fn());
@@ -125,9 +125,9 @@ class SSEClient {
/** /**
* *
*/ */
on<T extends SSEEventType>( on<T extends WSEventType>(
event: T, event: T,
handler: EventHandler<SSEEvents[T]> handler: EventHandler<WSEvents[T]>
): () => void { ): () => void {
if (!this.handlers.has(event)) { if (!this.handlers.has(event)) {
this.handlers.set(event, new Set()); this.handlers.set(event, new Set());
@@ -142,9 +142,9 @@ class SSEClient {
/** /**
* *
*/ */
private emit<T extends SSEEventType>( private emit<T extends WSEventType>(
event: T, event: T,
data: SSEEvents[T] data: WSEvents[T]
): void { ): void {
const handlers = this.handlers.get(event); const handlers = this.handlers.get(event);
if (handlers) { if (handlers) {
@@ -152,7 +152,7 @@ class SSEClient {
try { try {
handler(data); handler(data);
} catch (error) { } catch (error) {
console.error(`[SSEClient] 事件处理器执行失败: ${event}`, error); console.error(`[WSClient] 事件处理器执行失败: ${event}`, error);
} }
}); });
} }
@@ -162,23 +162,23 @@ class SSEClient {
* *
*/ */
isConnected(): boolean { isConnected(): boolean {
return sseService.isConnected(); return wsService.isConnected();
} }
/** /**
* SSE连接 * WebSocket连接
*/ */
async connect(): Promise<boolean> { async connect(): Promise<boolean> {
return sseService.start(); return wsService.start();
} }
/** /**
* SSE连接 * WebSocket连接
*/ */
disconnect(): void { disconnect(): void {
sseService.stop(); wsService.stop();
} }
} }
export const sseClient = new SSEClient(); export const wsClient = new WSClient();
export default sseClient; export default wsClient;

View File

@@ -5,5 +5,5 @@
export * from './interfaces'; export * from './interfaces';
export * from './ApiDataSource'; export * from './ApiDataSource';
export * from './LocalDataSource'; export { LocalDataSource, localDataSource } from '@/database';
export * from './CacheDataSource'; export * from './CacheDataSource';

View File

@@ -20,6 +20,10 @@ export interface ILocalDataSource {
run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }>; run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }>;
transaction(operations: () => Promise<void>): Promise<void>; transaction(operations: () => Promise<void>): Promise<void>;
batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void>; batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void>;
enqueueWrite<T>(operation: () => Promise<T>): Promise<T>;
initialize(userId?: string): Promise<void>;
switchDatabase(userId?: string | null): Promise<void>;
closeDatabase(): Promise<void>;
} }
// 缓存数据源接口 // 缓存数据源接口

View File

@@ -0,0 +1,289 @@
/**
* 学习资料 Repository
* 提供学习资料的数据访问接口
*/
import type {
MaterialSubject,
MaterialFile,
MaterialFileType,
} from '../../types/material';
import { api } from '../../services/api';
// ==================== 接口定义 ====================
export interface GetMaterialsParams {
subjectId?: string;
fileType?: MaterialFileType;
keyword?: string;
sortBy?: 'createdAt' | 'downloadCount' | 'title';
sortOrder?: 'asc' | 'desc';
page?: number;
pageSize?: number;
}
export interface MaterialsResult {
materials: MaterialFile[];
total: number;
page: number;
pageSize: number;
hasMore: boolean;
}
// ==================== API 响应类型 ====================
interface SubjectResponse {
id: string;
name: string;
icon: string;
color: string;
description: string;
sort_order: number;
is_active: boolean;
material_count: number;
created_at: string;
updated_at: string;
}
interface MaterialFileResponse {
id: string;
subject_id: string;
title: string;
description: string;
file_type: string;
file_size: number;
file_url: string;
file_name: string;
download_count: number;
author_id: string;
tags: string[];
status: string;
created_at: string;
updated_at: string;
subject?: SubjectResponse;
}
// ==================== 转换函数 ====================
function toMaterialSubject(res: SubjectResponse): MaterialSubject {
return {
id: res.id,
name: res.name,
icon: res.icon,
color: res.color,
description: res.description,
sort_order: res.sort_order,
is_active: res.is_active,
material_count: res.material_count,
created_at: res.created_at,
updated_at: res.updated_at,
};
}
function toMaterialFile(res: MaterialFileResponse): MaterialFile {
return {
id: res.id,
subject_id: res.subject_id,
title: res.title,
description: res.description,
file_type: res.file_type as MaterialFileType,
file_size: res.file_size,
file_url: res.file_url,
file_name: res.file_name,
download_count: res.download_count,
author_id: res.author_id,
tags: res.tags || [],
status: res.status as 'active' | 'inactive',
created_at: res.created_at,
updated_at: res.updated_at,
subject: res.subject ? toMaterialSubject(res.subject) : undefined,
};
}
// ==================== Repository 实现 ====================
/**
* 学习资料数据仓库
* 使用真实 API
*/
export class MaterialRepository {
private static instance: MaterialRepository;
private constructor() {}
static getInstance(): MaterialRepository {
if (!MaterialRepository.instance) {
MaterialRepository.instance = new MaterialRepository();
}
return MaterialRepository.instance;
}
/**
* 获取学科分类列表
*/
async getSubjects(): Promise<MaterialSubject[]> {
try {
const response = await api.get<{ list: SubjectResponse[] }>('/materials/subjects');
return response.data.list.map(toMaterialSubject);
} catch (error) {
console.error('获取学科列表失败:', error);
return [];
}
}
/**
* 根据ID获取学科详情
*/
async getSubjectById(id: string): Promise<MaterialSubject | null> {
try {
const response = await api.get<SubjectResponse>(`/materials/subjects/${id}`);
return toMaterialSubject(response.data);
} catch (error) {
console.error('获取学科详情失败:', error);
return null;
}
}
/**
* 获取资料列表
*/
async getMaterials(params: GetMaterialsParams): Promise<MaterialsResult> {
const {
subjectId,
fileType,
keyword,
sortBy = 'createdAt',
sortOrder = 'desc',
page = 1,
pageSize = 20,
} = params;
try {
const queryParams: Record<string, any> = {
page,
page_size: pageSize,
};
if (subjectId) {
queryParams.subject_id = subjectId;
}
if (fileType) {
queryParams.file_type = fileType;
}
if (keyword) {
queryParams.keyword = keyword;
}
const response = await api.get<{
list: MaterialFileResponse[];
total: number;
page: number;
page_size: number;
has_more: boolean;
}>('/materials', queryParams);
return {
materials: response.data.list.map(toMaterialFile),
total: response.data.total,
page: response.data.page,
pageSize: response.data.page_size,
hasMore: response.data.has_more,
};
} catch (error) {
console.error('获取资料列表失败:', error);
return {
materials: [],
total: 0,
page: 1,
pageSize,
hasMore: false,
};
}
}
/**
* 根据ID获取资料详情
*/
async getMaterialById(id: string): Promise<MaterialFile | null> {
try {
const response = await api.get<MaterialFileResponse>(`/materials/${id}`);
return toMaterialFile(response.data);
} catch (error) {
console.error('获取资料详情失败:', error);
return null;
}
}
/**
* 搜索资料
*/
async searchMaterials(keyword: string, params?: { fileType?: MaterialFileType }): Promise<MaterialsResult> {
return this.getMaterials({
keyword,
fileType: params?.fileType,
});
}
/**
* 获取热门资料
*/
async getHotMaterials(limit: number = 10): Promise<MaterialFile[]> {
try {
const response = await api.get<{
list: MaterialFileResponse[];
total: number;
page: number;
page_size: number;
has_more: boolean;
}>('/materials', {
page: 1,
page_size: limit,
sort_by: 'download_count',
sort_order: 'desc',
});
return response.data.list.map(toMaterialFile);
} catch (error) {
console.error('获取热门资料失败:', error);
return [];
}
}
/**
* 获取最新资料
*/
async getLatestMaterials(limit: number = 10): Promise<MaterialFile[]> {
try {
const response = await api.get<{
list: MaterialFileResponse[];
total: number;
page: number;
page_size: number;
has_more: boolean;
}>('/materials', {
page: 1,
page_size: limit,
sort_by: 'created_at',
sort_order: 'desc',
});
return response.data.list.map(toMaterialFile);
} catch (error) {
console.error('获取最新资料失败:', error);
return [];
}
}
/**
* 获取资料的下载链接(增加下载次数)
*/
async downloadMaterial(id: string): Promise<{ file_url: string; file_name: string } | null> {
try {
const response = await api.get<{ file_url: string; file_name: string }>(`/materials/${id}/download`);
return response.data;
} catch (error) {
console.error('获取下载链接失败:', error);
return null;
}
}
}
// 导出单例
export const materialRepository = MaterialRepository.getInstance();

View File

@@ -1,220 +0,0 @@
/**
* MessageRepository - 消息仓库实现
* 封装所有SQLite数据库操作不依赖任何UI或状态管理
*/
import {
saveMessage as dbSaveMessage,
saveMessagesBatch as dbSaveMessagesBatch,
getMessagesByConversation as dbGetMessagesByConversation,
getMaxSeq as dbGetMaxSeq,
getMinSeq as dbGetMinSeq,
getMessagesBeforeSeq as dbGetMessagesBeforeSeq,
markConversationAsRead as dbMarkConversationAsRead,
updateConversationCacheUnreadCount as dbUpdateConversationCacheUnreadCount,
getUserCache as dbGetUserCache,
saveUserCache as dbSaveUserCache,
deleteConversation as dbDeleteConversation,
updateMessageStatus as dbUpdateMessageStatus,
CachedMessage,
} from '../../services/database';
import { Message, Conversation, createMessage, createConversation } from '../../core/entities/Message';
// 数据库消息到领域实体的转换
const cachedMessageToMessage = (cached: CachedMessage): Message => ({
id: cached.id,
conversationId: cached.conversationId,
senderId: cached.senderId,
seq: cached.seq,
segments: cached.segments || [],
createdAt: cached.createdAt,
status: cached.status as Message['status'],
});
// 领域实体到数据库消息的转换
const messageToCachedMessage = (message: Message, isRead: boolean): CachedMessage => ({
id: message.id,
conversationId: message.conversationId,
senderId: message.senderId,
content: message.segments
.filter((s) => s.type === 'text')
.map((s) => s.data?.text || '')
.join(''),
type: message.segments.find((s) => s.type === 'image') ? 'image' : 'text',
isRead,
createdAt: message.createdAt,
seq: message.seq,
status: message.status,
segments: message.segments,
});
class MessageRepository {
// ==================== 消息操作 ====================
/**
* 保存单条消息
*/
async saveMessage(message: Message, isRead: boolean): Promise<void> {
try {
const cachedMessage = messageToCachedMessage(message, isRead);
await dbSaveMessage(cachedMessage);
} catch (error) {
console.error('[MessageRepository] 保存消息失败:', error);
throw error;
}
}
/**
* 批量保存消息
*/
async saveMessages(messages: Message[], isRead: boolean): Promise<void> {
if (!messages || messages.length === 0) return;
try {
const cachedMessages = messages.map((m) => messageToCachedMessage(m, isRead));
await dbSaveMessagesBatch(cachedMessages);
} catch (error) {
console.error('[MessageRepository] 批量保存消息失败:', error);
throw error;
}
}
/**
* 获取会话的消息
*/
async getMessagesByConversation(
conversationId: string,
limit: number = 20
): Promise<Message[]> {
try {
const cachedMessages = await dbGetMessagesByConversation(conversationId, limit);
return cachedMessages.map(cachedMessageToMessage);
} catch (error) {
console.error('[MessageRepository] 获取消息失败:', error);
return [];
}
}
/**
* 获取会话的最大消息序号
*/
async getMaxSeq(conversationId: string): Promise<number> {
try {
return await dbGetMaxSeq(conversationId);
} catch (error) {
console.error('[MessageRepository] 获取最大序号失败:', error);
return 0;
}
}
/**
* 获取会话的最小消息序号
*/
async getMinSeq(conversationId: string): Promise<number> {
try {
return await dbGetMinSeq(conversationId);
} catch (error) {
console.error('[MessageRepository] 获取最小序号失败:', error);
return 0;
}
}
/**
* 获取指定seq之前的历史消息
*/
async getMessagesBeforeSeq(
conversationId: string,
beforeSeq: number,
limit: number = 20
): Promise<Message[]> {
try {
const cachedMessages = await dbGetMessagesBeforeSeq(conversationId, beforeSeq, limit);
return cachedMessages.map(cachedMessageToMessage);
} catch (error) {
console.error('[MessageRepository] 获取历史消息失败:', error);
return [];
}
}
/**
* 标记会话的所有消息为已读
*/
async markConversationAsRead(conversationId: string): Promise<void> {
try {
await dbMarkConversationAsRead(conversationId);
} catch (error) {
console.error('[MessageRepository] 标记会话已读失败:', error);
throw error;
}
}
/**
* 更新会话缓存的未读数
*/
async updateConversationUnreadCount(
conversationId: string,
count: number
): Promise<void> {
try {
await dbUpdateConversationCacheUnreadCount(conversationId, count);
} catch (error) {
console.error('[MessageRepository] 更新会话未读数失败:', error);
}
}
/**
* 更新消息状态(如撤回)
*/
async updateMessageStatus(
messageId: string,
status: string,
clearContent: boolean = false
): Promise<void> {
try {
await dbUpdateMessageStatus(messageId, status, clearContent);
} catch (error) {
console.error('[MessageRepository] 更新消息状态失败:', error);
throw error;
}
}
/**
* 删除会话及其所有消息
*/
async deleteConversation(conversationId: string): Promise<void> {
try {
await dbDeleteConversation(conversationId);
} catch (error) {
console.error('[MessageRepository] 删除会话失败:', error);
throw error;
}
}
// ==================== 用户缓存操作 ====================
/**
* 获取用户缓存
*/
async getUserCache(userId: string): Promise<any | null> {
try {
return await dbGetUserCache(userId);
} catch (error) {
console.error('[MessageRepository] 获取用户缓存失败:', error);
return null;
}
}
/**
* 保存用户缓存
*/
async saveUserCache(user: any): Promise<void> {
try {
await dbSaveUserCache(user);
} catch (error) {
console.error('[MessageRepository] 保存用户缓存失败:', error);
}
}
}
export const messageRepository = new MessageRepository();
export default messageRepository;

View File

@@ -4,7 +4,7 @@
*/ */
import { ApiDataSource, apiDataSource } from '../datasources/ApiDataSource'; import { ApiDataSource, apiDataSource } from '../datasources/ApiDataSource';
import { LocalDataSource, localDataSource } from '../datasources/LocalDataSource'; import { localDataSource } from '@/database';
import { PostMapper } from '../mappers/PostMapper'; import { PostMapper } from '../mappers/PostMapper';
import type { Post, PostImage, PostStatus } from '../../core/entities/Post'; import type { Post, PostImage, PostStatus } from '../../core/entities/Post';
import { createPost } from '../../core/entities/Post'; import { createPost } from '../../core/entities/Post';
@@ -23,8 +23,8 @@ import type {
* API帖子响应类型 * API帖子响应类型
*/ */
interface PostApiResponse { interface PostApiResponse {
id: number; id: string;
user_id: number; user_id: string;
title: string; title: string;
content: string; content: string;
images?: Array<{ url: string; width?: number; height?: number; thumbnail_url?: string }>; images?: Array<{ url: string; width?: number; height?: number; thumbnail_url?: string }>;
@@ -44,7 +44,7 @@ interface PostApiResponse {
content_edited_at?: string; content_edited_at?: string;
channel?: { id: string; name: string } | null; channel?: { id: string; name: string } | null;
author?: { author?: {
id: number; id: string;
username: string; username: string;
nickname?: string; nickname?: string;
avatar?: string; avatar?: string;
@@ -79,13 +79,12 @@ interface CachedPost {
export class PostRepository implements IPostRepository { export class PostRepository implements IPostRepository {
private api: ApiDataSource; private api: ApiDataSource;
private localDb: LocalDataSource; private localDb = localDataSource;
private memoryCache: Map<string, { post: Post; timestamp: number }> = new Map(); private memoryCache: Map<string, { post: Post; timestamp: number }> = new Map();
private readonly CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存过期时间 private readonly CACHE_TTL = 5 * 60 * 1000;
constructor(api: ApiDataSource, localDb: LocalDataSource) { constructor(api: ApiDataSource) {
this.api = api; this.api = api;
this.localDb = localDb;
} }
// ==================== 私有辅助方法 ==================== // ==================== 私有辅助方法 ====================
@@ -180,7 +179,6 @@ export class PostRepository implements IPostRepository {
*/ */
private async getFromLocalCache(id: string): Promise<Post | null> { private async getFromLocalCache(id: string): Promise<Post | null> {
try { try {
await this.localDb.initialize();
const result = await this.localDb.getFirst<CachedPost>( const result = await this.localDb.getFirst<CachedPost>(
'SELECT * FROM posts_cache WHERE id = ?', 'SELECT * FROM posts_cache WHERE id = ?',
[id] [id]
@@ -201,7 +199,6 @@ export class PostRepository implements IPostRepository {
*/ */
private async saveToLocalCache(post: Post): Promise<void> { private async saveToLocalCache(post: Post): Promise<void> {
try { try {
await this.localDb.initialize();
await this.localDb.enqueueWrite(async () => { await this.localDb.enqueueWrite(async () => {
await this.localDb.run( await this.localDb.run(
`INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`, `INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
@@ -218,7 +215,6 @@ export class PostRepository implements IPostRepository {
*/ */
private async clearLocalCache(id: string): Promise<void> { private async clearLocalCache(id: string): Promise<void> {
try { try {
await this.localDb.initialize();
await this.localDb.enqueueWrite(async () => { await this.localDb.enqueueWrite(async () => {
await this.localDb.run('DELETE FROM posts_cache WHERE id = ?', [id]); await this.localDb.run('DELETE FROM posts_cache WHERE id = ?', [id]);
}); });
@@ -555,7 +551,6 @@ export class PostRepository implements IPostRepository {
async clearAllCache(): Promise<void> { async clearAllCache(): Promise<void> {
this.memoryCache.clear(); this.memoryCache.clear();
try { try {
await this.localDb.initialize();
await this.localDb.enqueueWrite(async () => { await this.localDb.enqueueWrite(async () => {
await this.localDb.run('DELETE FROM posts_cache'); await this.localDb.run('DELETE FROM posts_cache');
}); });
@@ -575,5 +570,5 @@ export class PostRepository implements IPostRepository {
// ==================== 单例导出 ==================== // ==================== 单例导出 ====================
export const postRepository = new PostRepository(apiDataSource, localDataSource); export const postRepository = new PostRepository(apiDataSource);
export default postRepository; export default postRepository;

View File

@@ -0,0 +1,88 @@
import * as SQLite from 'expo-sqlite';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
import { databaseManager } from './core/DatabaseManager';
class OperationQueue {
private queue: Promise<unknown> = Promise.resolve();
enqueue<T>(operation: () => Promise<T>): Promise<T> {
const result = this.queue.then(() => operation(), () => operation());
this.queue = result.catch(() => {});
return result;
}
async drain(): Promise<void> {
await this.queue;
}
}
const operationQueue = new OperationQueue();
export class LocalDataSource implements ILocalDataSource {
private async getDb(): Promise<SQLite.SQLiteDatabase> {
return databaseManager.getDb();
}
async initialize(userId?: string): Promise<void> {
if (databaseManager.isReady()) {
return;
}
await operationQueue.enqueue(() => databaseManager.initialize(userId));
}
async query<T>(sql: string, params?: any[]): Promise<T[]> {
const db = await this.getDb();
if (params && params.length > 0) {
return db.getAllAsync<T>(sql, params as any);
}
return db.getAllAsync<T>(sql);
}
async getFirst<T>(sql: string, params?: any[]): Promise<T | null> {
const db = await this.getDb();
if (params && params.length > 0) {
return db.getFirstAsync<T>(sql, params as any);
}
return db.getFirstAsync<T>(sql);
}
async execute(sql: string, params?: any[]): Promise<void> {
const db = await this.getDb();
await db.execAsync(sql);
}
async run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }> {
const db = await this.getDb();
if (params && params.length > 0) {
return db.runAsync(sql, params as any);
}
return db.runAsync(sql);
}
async transaction(operations: () => Promise<void>): Promise<void> {
const db = await this.getDb();
await db.withTransactionAsync(operations);
}
async batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void> {
await this.transaction(async () => {
await operations(this);
});
}
async enqueueWrite<T>(operation: () => Promise<T>): Promise<T> {
return operationQueue.enqueue(operation);
}
async switchDatabase(userId?: string | null): Promise<void> {
await operationQueue.drain();
await operationQueue.enqueue(() => databaseManager.initialize(userId));
}
async closeDatabase(): Promise<void> {
await operationQueue.drain();
await operationQueue.enqueue(() => databaseManager.close());
}
}
export const localDataSource = new LocalDataSource();

View File

@@ -0,0 +1,6 @@
const DEFAULT_DB_NAME = 'carrot_bbs.db';
const DB_NAME_PREFIX = 'carrot_bbs_';
export function getDbName(userId: string | null): string {
return userId ? `${DB_NAME_PREFIX}${userId}.db` : DEFAULT_DB_NAME;
}

View File

@@ -0,0 +1,137 @@
import * as SQLite from 'expo-sqlite';
import { Platform } from 'react-native';
import { getDbName } from './DatabaseConfig';
import { runMigrations } from '../schema/MigrationManager';
type SQLiteDatabase = SQLite.SQLiteDatabase;
class DatabaseManager {
private static instance: DatabaseManager;
private db: SQLiteDatabase | null = null;
private currentUserId: string | null = null;
private initPromise: Promise<void> | null = null;
static getInstance(): DatabaseManager {
if (!DatabaseManager.instance) {
DatabaseManager.instance = new DatabaseManager();
}
return DatabaseManager.instance;
}
async initialize(userId?: string | null): Promise<void> {
const targetUserId = userId || null;
if (this.db && this.currentUserId === targetUserId) {
return;
}
if (this.initPromise) {
await this.initPromise;
if (this.db && this.currentUserId === targetUserId) {
return;
}
}
this.initPromise = this.doInitialize(targetUserId);
try {
await this.initPromise;
} finally {
this.initPromise = null;
}
}
private async doInitialize(targetUserId: string | null): Promise<void> {
if (this.db) {
this.forceClose(this.db);
this.db = null;
this.currentUserId = null;
}
const dbName = getDbName(targetUserId);
const isWeb = Platform.OS === 'web';
if (isWeb) {
try {
const db = await this.tryOpenDatabase(dbName);
await runMigrations(db);
this.db = db;
this.currentUserId = targetUserId;
} catch (error) {
console.warn('[DatabaseManager] Web数据库打开失败使用内存数据库:', error);
const db = await SQLite.openDatabaseAsync(':memory:');
await runMigrations(db);
this.db = db;
this.currentUserId = targetUserId;
}
return;
}
const db = await this.tryOpenDatabase(dbName);
await runMigrations(db);
this.db = db;
this.currentUserId = targetUserId;
}
private async tryOpenDatabase(dbName: string): Promise<SQLiteDatabase> {
try {
return await SQLite.openDatabaseAsync(dbName, {
useNewConnection: true,
});
} catch (error) {
const msg = String(error);
if (msg.includes('cannot create file') || msg.includes('SQLITE_CANTOPEN') || msg.includes('database is locked')) {
return await SQLite.openDatabaseAsync(':memory:');
}
throw error;
}
}
private forceClose(db: SQLiteDatabase): void {
try {
db.closeSync();
} catch {
try {
db.closeAsync().catch(() => {});
} catch {}
}
}
async close(): Promise<void> {
if (this.initPromise) {
await this.initPromise.catch(() => {});
}
if (this.db) {
this.forceClose(this.db);
this.db = null;
this.currentUserId = null;
}
}
async getDb(): Promise<SQLiteDatabase> {
if (this.db) return this.db;
if (this.initPromise) {
await this.initPromise;
if (this.db) return this.db;
}
throw new Error('数据库未初始化');
}
getDbSync(): SQLiteDatabase | null {
return this.db;
}
getCurrentUserId(): string | null {
return this.currentUserId;
}
isReady(): boolean {
return this.db !== null;
}
}
export const databaseManager = DatabaseManager.getInstance();
export { DatabaseManager };

View File

@@ -0,0 +1,34 @@
export class LockManager {
private static mutex: Promise<void> = Promise.resolve();
static async withMutex<T>(fn: () => Promise<T>): Promise<T> {
const prev = LockManager.mutex;
let release: () => void = () => {};
LockManager.mutex = new Promise<void>((r) => { release = r; });
try {
await prev;
return await fn();
} finally {
release();
}
}
static async withWebLock<T>(lockName: string, fn: () => Promise<T>): Promise<T> {
if (typeof navigator !== 'undefined' && navigator.locks) {
return new Promise((resolve, reject) => {
navigator.locks.request(lockName, { mode: 'exclusive' }, async (lock) => {
if (!lock) {
return reject(new Error('无法获取跨标签页锁'));
}
try {
const result = await fn();
resolve(result);
} catch (e) {
reject(e);
}
});
});
}
return fn();
}
}

58
src/database/index.ts Normal file
View File

@@ -0,0 +1,58 @@
import { localDataSource } from './LocalDataSource';
import { databaseManager } from './core/DatabaseManager';
export { databaseManager } from './core/DatabaseManager';
export { DatabaseManager } from './core/DatabaseManager';
export { localDataSource, LocalDataSource } from './LocalDataSource';
export {
messageRepository, MessageRepository,
conversationRepository, ConversationRepository,
userCacheRepository, UserCacheRepository,
groupCacheRepository, GroupCacheRepository,
postCacheRepository, PostCacheRepository,
} from './repositories';
export { CachedMessage } from './types';
export async function clearAllData(): Promise<void> {
await localDataSource.enqueueWrite(async () => {
await localDataSource.execute(`DELETE FROM messages`);
await localDataSource.execute(`DELETE FROM conversations`);
await localDataSource.execute(`DELETE FROM conversation_cache`);
await localDataSource.execute(`DELETE FROM conversation_list_cache`);
await localDataSource.execute(`DELETE FROM users`);
await localDataSource.execute(`DELETE FROM current_user_cache`);
await localDataSource.execute(`DELETE FROM groups`);
await localDataSource.execute(`DELETE FROM group_members`);
await localDataSource.execute(`DELETE FROM posts_cache`);
});
}
export const initDatabase = async (userId?: string | null): Promise<void> => {
await databaseManager.initialize(userId);
};
export const switchDatabase = async (userId?: string | null): Promise<void> => {
await localDataSource.switchDatabase(userId);
};
export const closeDatabase = async (): Promise<void> => {
await localDataSource.closeDatabase();
};
export const getCurrentDbUserId = (): string | null => {
return databaseManager.getCurrentUserId();
};
export const isDbInitialized = (): boolean => {
return databaseManager.isReady();
};
export const getDbInstance = () => {
return databaseManager.getDbSync();
};
export const getDb = async () => {
return databaseManager.getDb();
};

View File

@@ -0,0 +1,197 @@
import { localDataSource } from '../LocalDataSource';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
import type { ConversationResponse, ConversationDetailResponse } from '@/types/dto';
type ConversationCacheData = ConversationResponse | ConversationDetailResponse;
const safeParseJson = <T>(value: string): T | null => {
try { return JSON.parse(value) as T; } catch { return null; }
};
export interface IConversationRepository {
save(conv: { id: string; participantId: string; lastMessageId?: string; lastSeq?: number; unreadCount?: number; createdAt: string; updatedAt: string }): Promise<void>;
getAll(): Promise<any[]>;
updateUnreadCount(id: string, count: number): Promise<void>;
updateLastMessage(id: string, lastMessageId: string, lastSeq: number, updatedAt: string): Promise<void>;
delete(conversationId: string): Promise<void>;
getCache(id: string): Promise<ConversationCacheData | null>;
getListCache(): Promise<ConversationResponse[]>;
saveCache(conv: ConversationCacheData): Promise<void>;
saveListCache(convs: ConversationResponse[]): Promise<void>;
updateListEntry(id: string, updates: Partial<ConversationResponse>): Promise<void>;
updateCacheUnreadCount(id: string, count: number, myLastReadSeq?: number): Promise<void>;
saveWithRelated(convs: ConversationResponse[], users?: any[], groups?: any[]): Promise<void>;
}
export class ConversationRepository implements IConversationRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async save(conv: { id: string; participantId: string; lastMessageId?: string; lastSeq?: number; unreadCount?: number; createdAt: string; updatedAt: string }): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO conversations (id, participantId, lastMessageId, lastSeq, unreadCount, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[conv.id, conv.participantId, conv.lastMessageId || null, conv.lastSeq || 0, conv.unreadCount || 0, conv.createdAt, conv.updatedAt]
);
});
}
async getAll(): Promise<any[]> {
return this.dataSource.query<any>(`SELECT * FROM conversations ORDER BY updatedAt DESC`);
}
async updateUnreadCount(id: string, count: number): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`UPDATE conversations SET unreadCount = ? WHERE id = ?`, [count, id]);
});
}
async updateLastMessage(id: string, lastMessageId: string, lastSeq: number, updatedAt: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`UPDATE conversations SET lastMessageId = ?, lastSeq = ?, updatedAt = ? WHERE id = ?`,
[lastMessageId, lastSeq, updatedAt, id]
);
});
}
async delete(conversationId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM messages WHERE conversationId = ?`, [conversationId]);
await this.dataSource.run(`DELETE FROM conversations WHERE id = ?`, [conversationId]);
});
}
async getCache(id: string): Promise<ConversationCacheData | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM conversation_cache WHERE id = ?`, [String(id)]
);
return r?.data ? safeParseJson<ConversationCacheData>(r.data) : null;
}
async getListCache(): Promise<ConversationResponse[]> {
let rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM conversation_list_cache ORDER BY updatedAt DESC`
);
if (!rows.length) {
rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM conversation_cache ORDER BY updatedAt DESC`
);
}
const list = rows.map(r => safeParseJson<ConversationResponse>(r.data))
.filter((c): c is ConversationResponse => Boolean(c))
.filter(c => typeof c.id !== 'undefined' && typeof c.type !== 'undefined');
return list.sort((a, b) => {
const aPinned = a.is_pinned ? 1 : 0;
const bPinned = b.is_pinned ? 1 : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
const aTime = new Date(a.last_message_at || a.updated_at || 0).getTime();
const bTime = new Date(b.last_message_at || b.updated_at || 0).getTime();
return bTime - aTime;
});
}
async saveCache(conv: ConversationCacheData): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
const updatedAt = ('updated_at' in conv && conv.updated_at) ? String(conv.updated_at) : new Date().toISOString();
await this.dataSource.run(
`INSERT OR REPLACE INTO conversation_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(conv.id), JSON.stringify(conv), updatedAt]
);
});
}
async saveListCache(convs: ConversationResponse[]): Promise<void> {
if (!convs?.length) return;
await this.dataSource.enqueueWrite(async () => {
for (const c of convs) {
await this.dataSource.run(
`INSERT OR REPLACE INTO conversation_list_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(c.id), JSON.stringify(c), c.updated_at || new Date().toISOString()]
);
}
});
}
async updateListEntry(id: string, updates: Partial<ConversationResponse>): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM conversation_list_cache WHERE id = ?`, [String(id)]
);
if (!r?.data) return;
const conv = safeParseJson<ConversationResponse>(r.data);
if (!conv) return;
const updated = { ...conv, ...updates };
const updatedAt = updates.last_message_at || updated.last_message_at || new Date().toISOString();
await this.dataSource.run(
`UPDATE conversation_list_cache SET data = ?, updatedAt = ? WHERE id = ?`,
[JSON.stringify(updated), updatedAt, String(id)]
);
});
}
async updateCacheUnreadCount(id: string, count: number, myLastReadSeq?: number): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
const listRow = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM conversation_list_cache WHERE id = ?`, [String(id)]
);
if (listRow?.data) {
const conv = safeParseJson<ConversationResponse>(listRow.data);
if (conv) {
conv.unread_count = count;
if (typeof myLastReadSeq === 'number' && !Number.isNaN(myLastReadSeq)) conv.my_last_read_seq = myLastReadSeq;
await this.dataSource.run(
`UPDATE conversation_list_cache SET data = ? WHERE id = ?`,
[JSON.stringify(conv), String(id)]
);
}
}
const cacheRow = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM conversation_cache WHERE id = ?`, [String(id)]
);
if (cacheRow?.data) {
const conv = safeParseJson<Record<string, unknown>>(cacheRow.data);
if (conv) {
conv.unread_count = count;
if (typeof myLastReadSeq === 'number' && !Number.isNaN(myLastReadSeq)) conv.my_last_read_seq = myLastReadSeq;
await this.dataSource.run(
`UPDATE conversation_cache SET data = ? WHERE id = ?`,
[JSON.stringify(conv), String(id)]
);
}
}
});
}
async saveWithRelated(convs: ConversationResponse[], users?: any[], groups?: any[]): Promise<void> {
if (!convs?.length) return;
const now = new Date().toISOString();
await this.dataSource.enqueueWrite(async () => {
for (const c of convs) {
await this.dataSource.run(
`INSERT OR REPLACE INTO conversation_list_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(c.id), JSON.stringify(c), c.updated_at || now]
);
}
if (users?.length) {
for (const u of users) {
await this.dataSource.run(
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(u.id), JSON.stringify(u), now]
);
}
}
if (groups?.length) {
for (const g of groups) {
await this.dataSource.run(
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(g.id), JSON.stringify(g), now]
);
}
}
});
}
}
export const conversationRepository = new ConversationRepository();

View File

@@ -0,0 +1,78 @@
import { localDataSource } from '../LocalDataSource';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
import type { GroupResponse, GroupMemberResponse } from '@/types/dto';
const safeParseJson = <T>(value: string): T | null => {
try { return JSON.parse(value) as T; } catch { return null; }
};
export interface IGroupCacheRepository {
save(group: GroupResponse): Promise<void>;
saveBatch(groups: GroupResponse[]): Promise<void>;
get(groupId: string): Promise<GroupResponse | null>;
getAll(): Promise<GroupResponse[]>;
saveMembers(groupId: string, members: GroupMemberResponse[]): Promise<void>;
getMembers(groupId: string): Promise<GroupMemberResponse[]>;
}
export class GroupCacheRepository implements IGroupCacheRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async save(group: GroupResponse): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(group.id), JSON.stringify(group), new Date().toISOString()]
);
});
}
async saveBatch(groups: GroupResponse[]): Promise<void> {
if (!groups?.length) return;
await this.dataSource.enqueueWrite(async () => {
const now = new Date().toISOString();
for (const g of groups) {
await this.dataSource.run(
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(g.id), JSON.stringify(g), now]
);
}
});
}
async get(groupId: string): Promise<GroupResponse | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM groups WHERE id = ?`, [String(groupId)]
);
return r?.data ? safeParseJson<GroupResponse>(r.data) : null;
}
async getAll(): Promise<GroupResponse[]> {
const rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM groups ORDER BY updatedAt DESC`
);
return rows.map(r => safeParseJson<GroupResponse>(r.data)).filter((g): g is GroupResponse => Boolean(g));
}
async saveMembers(groupId: string, members: GroupMemberResponse[]): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
const now = new Date().toISOString();
await this.dataSource.run(`DELETE FROM group_members WHERE groupId = ?`, [String(groupId)]);
for (const m of members) {
await this.dataSource.run(
`INSERT OR REPLACE INTO group_members (groupId, userId, data, updatedAt) VALUES (?, ?, ?, ?)`,
[String(groupId), String(m.user_id), JSON.stringify(m), now]
);
}
});
}
async getMembers(groupId: string): Promise<GroupMemberResponse[]> {
const rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM group_members WHERE groupId = ? ORDER BY updatedAt DESC`, [String(groupId)]
);
return rows.map(r => safeParseJson<GroupMemberResponse>(r.data)).filter((m): m is GroupMemberResponse => Boolean(m));
}
}
export const groupCacheRepository = new GroupCacheRepository();

View File

@@ -0,0 +1,152 @@
import { localDataSource } from '../LocalDataSource';
import { CachedMessage } from '../types';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
export interface IMessageRepository {
saveMessage(message: CachedMessage): Promise<void>;
saveMessagesBatch(messages: CachedMessage[]): Promise<void>;
getByConversation(conversationId: string, limit?: number): Promise<CachedMessage[]>;
getMaxSeq(conversationId: string): Promise<number>;
getMinSeq(conversationId: string): Promise<number>;
getBeforeSeq(conversationId: string, beforeSeq: number, limit?: number): Promise<CachedMessage[]>;
getCount(conversationId: string): Promise<number>;
getCountBeforeSeq(conversationId: string, beforeSeq: number): Promise<number>;
markAsRead(messageId: string): Promise<void>;
markConversationAsRead(conversationId: string): Promise<void>;
delete(messageId: string): Promise<void>;
updateStatus(messageId: string, status: string, clearContent?: boolean): Promise<void>;
clearConversation(conversationId: string): Promise<void>;
search(keyword: string): Promise<CachedMessage[]>;
getStats(): Promise<{ totalMessages: number; totalConversations: number }>;
}
export class MessageRepository implements IMessageRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async saveMessage(message: CachedMessage): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO messages (id, conversationId, senderId, content, type, isRead, createdAt, seq, status, segments)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[message.id, message.conversationId, message.senderId, message.content || '',
message.type || 'text', message.isRead ? 1 : 0, message.createdAt,
message.seq || 0, message.status || 'normal',
message.segments ? JSON.stringify(message.segments) : null]
);
});
}
async saveMessagesBatch(messages: CachedMessage[]): Promise<void> {
if (!messages?.length) return;
await this.dataSource.enqueueWrite(async () => {
for (const msg of messages) {
await this.dataSource.run(
`INSERT OR REPLACE INTO messages (id, conversationId, senderId, content, type, isRead, createdAt, seq, status, segments)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[msg.id, msg.conversationId, msg.senderId, msg.content || '',
msg.type || 'text', msg.isRead ? 1 : 0, msg.createdAt,
msg.seq || 0, msg.status || 'normal',
msg.segments ? JSON.stringify(msg.segments) : null]
);
}
});
}
async getByConversation(conversationId: string, limit: number = 20): Promise<CachedMessage[]> {
const rows = await this.dataSource.query<any>(
`SELECT * FROM messages WHERE conversationId = ? ORDER BY seq DESC LIMIT ?`,
[conversationId, limit]
);
return rows.reverse().map(r => ({
...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined
}));
}
async getMaxSeq(conversationId: string): Promise<number> {
const r = await this.dataSource.getFirst<{ maxSeq: number }>(
`SELECT MAX(seq) as maxSeq FROM messages WHERE conversationId = ?`, [conversationId]
);
return r?.maxSeq || 0;
}
async getMinSeq(conversationId: string): Promise<number> {
const r = await this.dataSource.getFirst<{ minSeq: number }>(
`SELECT MIN(seq) as minSeq FROM messages WHERE conversationId = ?`, [conversationId]
);
return r?.minSeq || 0;
}
async getBeforeSeq(conversationId: string, beforeSeq: number, limit: number = 20): Promise<CachedMessage[]> {
const rows = await this.dataSource.query<any>(
`SELECT * FROM messages WHERE conversationId = ? AND seq < ? ORDER BY seq DESC LIMIT ?`,
[conversationId, beforeSeq, limit]
);
return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined })).reverse();
}
async getCount(conversationId: string): Promise<number> {
const r = await this.dataSource.getFirst<{ count: number }>(
`SELECT COUNT(*) as count FROM messages WHERE conversationId = ?`, [conversationId]
);
return r?.count || 0;
}
async getCountBeforeSeq(conversationId: string, beforeSeq: number): Promise<number> {
const r = await this.dataSource.getFirst<{ count: number }>(
`SELECT COUNT(*) as count FROM messages WHERE conversationId = ? AND seq < ?`, [conversationId, beforeSeq]
);
return r?.count || 0;
}
async markAsRead(messageId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`UPDATE messages SET isRead = 1 WHERE id = ?`, [messageId]);
});
}
async markConversationAsRead(conversationId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`UPDATE messages SET isRead = 1 WHERE conversationId = ?`, [conversationId]);
});
}
async delete(messageId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM messages WHERE id = ?`, [messageId]);
});
}
async updateStatus(messageId: string, status: string, clearContent: boolean = false): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
if (clearContent) {
await this.dataSource.run(
`UPDATE messages SET status = ?, content = '', segments = '[]' WHERE id = ?`,
[status, messageId]
);
} else {
await this.dataSource.run(`UPDATE messages SET status = ? WHERE id = ?`, [status, messageId]);
}
});
}
async clearConversation(conversationId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM messages WHERE conversationId = ?`, [conversationId]);
});
}
async search(keyword: string): Promise<CachedMessage[]> {
const rows = await this.dataSource.query<any>(
`SELECT * FROM messages WHERE content LIKE ? ORDER BY createdAt DESC LIMIT 50`, [`%${keyword}%`]
);
return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined }));
}
async getStats(): Promise<{ totalMessages: number; totalConversations: number }> {
const msgCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM messages`);
const convCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM conversations`);
return { totalMessages: msgCount?.count || 0, totalConversations: convCount?.count || 0 };
}
}
export const messageRepository = new MessageRepository();

View File

@@ -0,0 +1,44 @@
import { localDataSource } from '../LocalDataSource';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
export interface IPostCacheRepository {
save(id: string, data: any): Promise<void>;
get(id: string): Promise<any | null>;
getAll(): Promise<any[]>;
delete(id: string): Promise<void>;
}
export class PostCacheRepository implements IPostCacheRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async save(id: string, data: any): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(id), JSON.stringify(data), new Date().toISOString()]
);
});
}
async get(id: string): Promise<any | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM posts_cache WHERE id = ?`, [String(id)]
);
return r?.data ? JSON.parse(r.data) : null;
}
async getAll(): Promise<any[]> {
const rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM posts_cache ORDER BY updatedAt DESC`
);
return rows.map(r => JSON.parse(r.data));
}
async delete(id: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM posts_cache WHERE id = ?`, [String(id)]);
});
}
}
export const postCacheRepository = new PostCacheRepository();

View File

@@ -0,0 +1,81 @@
import { localDataSource } from '../LocalDataSource';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
import type { UserDTO } from '@/types/dto';
const safeParseJson = <T>(value: string): T | null => {
try { return JSON.parse(value) as T; } catch { return null; }
};
export interface IUserCacheRepository {
save(user: UserDTO): Promise<void>;
saveBatch(users: UserDTO[]): Promise<void>;
get(userId: string): Promise<UserDTO | null>;
getLatest(): Promise<UserDTO | null>;
saveCurrent(user: UserDTO): Promise<void>;
getCurrent(): Promise<UserDTO | null>;
clearCurrent(): Promise<void>;
}
export class UserCacheRepository implements IUserCacheRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async save(user: UserDTO): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(user.id), JSON.stringify(user), new Date().toISOString()]
);
});
}
async saveBatch(users: UserDTO[]): Promise<void> {
if (!users?.length) return;
await this.dataSource.enqueueWrite(async () => {
const now = new Date().toISOString();
for (const u of users) {
await this.dataSource.run(
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(u.id), JSON.stringify(u), now]
);
}
});
}
async get(userId: string): Promise<UserDTO | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM users WHERE id = ?`, [String(userId)]
);
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
}
async getLatest(): Promise<UserDTO | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM users ORDER BY updatedAt DESC LIMIT 1`
);
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
}
async saveCurrent(user: UserDTO): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO current_user_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
['me', JSON.stringify(user), new Date().toISOString()]
);
});
}
async getCurrent(): Promise<UserDTO | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM current_user_cache WHERE id = 'me'`
);
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
}
async clearCurrent(): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM current_user_cache WHERE id = 'me'`);
});
}
}
export const userCacheRepository = new UserCacheRepository();

View File

@@ -0,0 +1,5 @@
export { IMessageRepository, MessageRepository, messageRepository } from './MessageRepository';
export { IConversationRepository, ConversationRepository, conversationRepository } from './ConversationRepository';
export { IUserCacheRepository, UserCacheRepository, userCacheRepository } from './UserCacheRepository';
export { IGroupCacheRepository, GroupCacheRepository, groupCacheRepository } from './GroupCacheRepository';
export { IPostCacheRepository, PostCacheRepository, postCacheRepository } from './PostCacheRepository';

View File

@@ -0,0 +1,40 @@
import type { SQLiteDatabase } from 'expo-sqlite';
export const CONVERSATIONS_TABLE = 'conversations';
export const CONVERSATION_CACHE_TABLE = 'conversation_cache';
export const CONVERSATION_LIST_CACHE_TABLE = 'conversation_list_cache';
export const CREATE_CONVERSATIONS_TABLE = `CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY NOT NULL,
participantId TEXT NOT NULL,
lastMessageId TEXT,
lastSeq INTEGER DEFAULT 0,
unreadCount INTEGER DEFAULT 0,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_CONVERSATION_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS conversation_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_CONVERSATION_LIST_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS conversation_list_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const MIGRATE_CONVERSATIONS_TABLE = async (db: SQLiteDatabase): Promise<void> => {
try {
const tableInfo = await db.getAllAsync<{ name: string }>(`PRAGMA table_info(conversations)`);
const columns = tableInfo.map(col => col.name);
if (!columns.includes('lastSeq')) {
await db.execAsync(`ALTER TABLE conversations ADD COLUMN lastSeq INTEGER DEFAULT 0`);
}
} catch (error) {
console.error('[ConversationSchema] 迁移失败:', error);
}
};

View File

@@ -0,0 +1,20 @@
export const GROUPS_TABLE = 'groups';
export const GROUP_MEMBERS_TABLE = 'group_members';
export const CREATE_GROUPS_TABLE = `CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_GROUP_MEMBERS_TABLE = `CREATE TABLE IF NOT EXISTS group_members (
groupId TEXT NOT NULL,
userId TEXT NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL,
PRIMARY KEY (groupId, userId)
)`;
export const CREATE_GROUP_INDEXES = [
`CREATE INDEX IF NOT EXISTS idx_group_members_groupId ON group_members(groupId)`,
];

View File

@@ -0,0 +1,41 @@
import type { SQLiteDatabase } from 'expo-sqlite';
export const MESSAGES_TABLE = 'messages';
export const CREATE_MESSAGES_TABLE = `CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY NOT NULL,
conversationId TEXT NOT NULL,
senderId TEXT NOT NULL,
content TEXT,
type TEXT DEFAULT 'text',
isRead INTEGER DEFAULT 0,
createdAt TEXT NOT NULL,
seq INTEGER DEFAULT 0,
status TEXT DEFAULT 'normal',
segments TEXT
)`;
export const CREATE_MESSAGES_INDEXES = [
`CREATE INDEX IF NOT EXISTS idx_messages_conversationId ON messages(conversationId)`,
`CREATE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq)`,
`CREATE INDEX IF NOT EXISTS idx_messages_conversation_seq ON messages(conversationId, seq)`,
];
export const MIGRATE_MESSAGES_TABLE = async (db: SQLiteDatabase): Promise<void> => {
try {
const tableInfo = await db.getAllAsync<{ name: string }>(`PRAGMA table_info(messages)`);
const columns = tableInfo.map(col => col.name);
if (!columns.includes('seq')) {
await db.execAsync(`ALTER TABLE messages ADD COLUMN seq INTEGER DEFAULT 0`);
}
if (!columns.includes('status')) {
await db.execAsync(`ALTER TABLE messages ADD COLUMN status TEXT DEFAULT 'normal'`);
}
if (!columns.includes('segments')) {
await db.execAsync(`ALTER TABLE messages ADD COLUMN segments TEXT`);
}
} catch (error) {
console.error('[MessageSchema] 迁移失败:', error);
}
};

View File

@@ -0,0 +1,66 @@
import type { SQLiteDatabase } from 'expo-sqlite';
import {
CREATE_MESSAGES_TABLE,
CREATE_MESSAGES_INDEXES,
MIGRATE_MESSAGES_TABLE,
} from './MessageSchema';
import {
CREATE_CONVERSATIONS_TABLE,
CREATE_CONVERSATION_CACHE_TABLE,
CREATE_CONVERSATION_LIST_CACHE_TABLE,
MIGRATE_CONVERSATIONS_TABLE,
} from './ConversationSchema';
import {
CREATE_USERS_TABLE,
CREATE_CURRENT_USER_CACHE_TABLE,
} from './UserSchema';
import {
CREATE_GROUPS_TABLE,
CREATE_GROUP_MEMBERS_TABLE,
CREATE_GROUP_INDEXES,
} from './GroupSchema';
import {
CREATE_POSTS_CACHE_TABLE,
} from './PostSchema';
async function execWithRetry(db: SQLiteDatabase, sql: string, maxRetries = 5): Promise<void> {
for (let i = 0; i < maxRetries; i++) {
try {
await db.execAsync(sql);
return;
} catch (error) {
const msg = String(error);
if (msg.includes('database is locked') && i < maxRetries - 1) {
const delay = 200 * (i + 1);
console.warn(`[MigrationManager] 数据库锁定,${delay}ms 后重试 (${i + 1}/${maxRetries})`);
await new Promise(r => setTimeout(r, delay));
continue;
}
throw error;
}
}
}
export async function runMigrations(db: SQLiteDatabase): Promise<void> {
await execWithRetry(db, 'PRAGMA busy_timeout = 10000;');
await execWithRetry(db, CREATE_MESSAGES_TABLE);
await execWithRetry(db, CREATE_CONVERSATIONS_TABLE);
await execWithRetry(db, CREATE_CONVERSATION_CACHE_TABLE);
await execWithRetry(db, CREATE_CONVERSATION_LIST_CACHE_TABLE);
await execWithRetry(db, CREATE_USERS_TABLE);
await execWithRetry(db, CREATE_CURRENT_USER_CACHE_TABLE);
await execWithRetry(db, CREATE_GROUPS_TABLE);
await execWithRetry(db, CREATE_GROUP_MEMBERS_TABLE);
await execWithRetry(db, CREATE_POSTS_CACHE_TABLE);
for (const indexSql of CREATE_MESSAGES_INDEXES) {
await execWithRetry(db, indexSql);
}
for (const indexSql of CREATE_GROUP_INDEXES) {
await execWithRetry(db, indexSql);
}
await MIGRATE_MESSAGES_TABLE(db);
await MIGRATE_CONVERSATIONS_TABLE(db);
}

View File

@@ -0,0 +1,9 @@
export const POSTS_CACHE_TABLE = 'posts_cache';
export const CREATE_POSTS_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS posts_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_POSTS_INDEXES: string[] = [];

View File

@@ -0,0 +1,16 @@
export const USERS_TABLE = 'users';
export const CURRENT_USER_CACHE_TABLE = 'current_user_cache';
export const CREATE_USERS_TABLE = `CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_CURRENT_USER_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS current_user_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_USER_INDEXES: string[] = [];

View File

@@ -0,0 +1,6 @@
export * from './MessageSchema';
export * from './ConversationSchema';
export * from './UserSchema';
export * from './GroupSchema';
export * from './PostSchema';
export { runMigrations } from './MigrationManager';

View File

@@ -0,0 +1,14 @@
export interface CachedMessage {
id: string;
conversationId: string;
senderId: string;
content?: string;
type?: string;
isRead: boolean;
createdAt: string;
seq: number;
status: string;
segments?: any;
}
export { CachedMessage as default };

View File

@@ -0,0 +1 @@
export { CachedMessage } from './CachedMessage';

View File

@@ -14,6 +14,7 @@ import { postManager } from '../stores/postManager';
import { groupManager } from '../stores/groupManager'; import { groupManager } from '../stores/groupManager';
import { userManager } from '../stores/userManager'; import { userManager } from '../stores/userManager';
import { messageManager } from '../stores/messageManager'; import { messageManager } from '../stores/messageManager';
import type { PostListTab } from '../stores/postListSources';
// ==================== 预取配置 ==================== // ==================== 预取配置 ====================
@@ -134,7 +135,7 @@ const prefetchService = new PrefetchService();
* 预取帖子数据 * 预取帖子数据
* @param types 帖子类型数组 * @param types 帖子类型数组
*/ */
function prefetchPosts(types: string[] = ['latest', 'hot']): void { function prefetchPosts(types: PostListTab[] = ['latest', 'hot']): void {
types.forEach((type, index) => { types.forEach((type, index) => {
prefetchService.schedule({ prefetchService.schedule({
key: `posts:${type}:1`, key: `posts:${type}:1`,
@@ -152,10 +153,7 @@ function prefetchConversations(): void {
key: 'conversations:list', key: 'conversations:list',
executor: async () => { executor: async () => {
await messageManager.initialize(); await messageManager.initialize();
await messageManager.requestConversationListRefresh('prefetch', { await messageManager.refreshConversations(true, 'prefetch');
force: true,
allowDefer: true,
});
return messageManager.getConversations(); return messageManager.getConversations();
}, },
priority: Priority.HIGH, priority: Priority.HIGH,
@@ -279,7 +277,7 @@ export function usePrefetch() {
const hasInitialPrefetched = useRef(false); const hasInitialPrefetched = useRef(false);
/** 预取帖子 */ /** 预取帖子 */
const prefetchPostsData = useCallback((types?: string[]) => { const prefetchPostsData = useCallback((types?: PostListTab[]) => {
prefetchPosts(types); prefetchPosts(types);
}, []); }, []);

View File

@@ -4,8 +4,9 @@
* @description 管理图片、视频、音频缓存,支持多种清理策略 * @description 管理图片、视频、音频缓存,支持多种清理策略
*/ */
import { File, Paths } from 'expo-file-system'; import { File, Directory, Paths } from 'expo-file-system';
import AsyncStorage from '@react-native-async-storage/async-storage'; import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform } from 'react-native';
import { import {
MediaType, MediaType,
CleanupTrigger, CleanupTrigger,
@@ -27,7 +28,7 @@ const CACHE_RECORD_PREFIX = 'media_cache_record_';
* 获取缓存目录路径 * 获取缓存目录路径
*/ */
function getCacheDir(): string { function getCacheDir(): string {
const cachePath = Paths.cache; const cachePath = Paths.cache.uri;
return `${cachePath}media_cache/`; return `${cachePath}media_cache/`;
} }
@@ -102,13 +103,15 @@ export class MediaCacheManager {
// 加载缓存记录 // 加载缓存记录
await this.loadCacheRecords(); await this.loadCacheRecords();
// 启动时清理 // 启动时清理 (非web平台)
if (this.policy.cleanupOnStart) { if (this.policy.cleanupOnStart && Platform.OS !== 'web') {
await this.cleanup(CleanupTrigger.STARTUP); await this.cleanup(CleanupTrigger.STARTUP);
} }
// 启动定期清理 // 启动定期清理 (非web平台)
if (Platform.OS !== 'web') {
this.startPeriodicCleanup(); this.startPeriodicCleanup();
}
this.isInitialized = true; this.isInitialized = true;
} catch (error) { } catch (error) {
@@ -121,12 +124,19 @@ export class MediaCacheManager {
* 确保缓存目录存在 * 确保缓存目录存在
*/ */
private async ensureDirectories(): Promise<void> { private async ensureDirectories(): Promise<void> {
if (Platform.OS === 'web') return;
try { try {
const dirs = [getImageDir(), getVideoDir(), getAudioDir()]; const cacheDir = new Directory(Paths.cache, 'media_cache');
for (const dir of dirs) { if (!await cacheDir.exists) {
const dirFile = new File(dir); await cacheDir.create();
if (!await dirFile.exists) { }
await dirFile.create();
const subDirs = ['images', 'videos', 'audios'];
for (const subDir of subDirs) {
const dir = new Directory(cacheDir, subDir);
if (!await dir.exists) {
await dir.create();
} }
} }
} catch (error) { } catch (error) {
@@ -168,6 +178,8 @@ export class MediaCacheManager {
* 检查文件是否存在 * 检查文件是否存在
*/ */
private async checkFileExists(path: string): Promise<boolean> { private async checkFileExists(path: string): Promise<boolean> {
if (Platform.OS === 'web') return false;
try { try {
const file = new File(path); const file = new File(path);
return await file.exists; return await file.exists;
@@ -199,6 +211,10 @@ export class MediaCacheManager {
size?: number; size?: number;
} = {} } = {}
): Promise<string> { ): Promise<string> {
if (Platform.OS === 'web') {
return uri;
}
const { conversationId, messageId, size = 0 } = options; const { conversationId, messageId, size = 0 } = options;
// 生成唯一键 // 生成唯一键

View File

@@ -61,6 +61,10 @@ export function hrefProfileBlocked(): string {
return '/profile/blocked-users'; return '/profile/blocked-users';
} }
export function hrefProfileChatSettings(): string {
return '/profile/chat-settings';
}
export function hrefProfileMyPosts(): string { export function hrefProfileMyPosts(): string {
return '/profile/my-posts'; return '/profile/my-posts';
} }
@@ -155,3 +159,62 @@ export function hrefAuthRegister(): string {
export function hrefAuthForgot(): string { export function hrefAuthForgot(): string {
return '/forgot-password'; return '/forgot-password';
} }
export function hrefAuthWelcome(): string {
return '/welcome';
}
// ==================== Materials (学习资料) ====================
export function hrefMaterials(): string {
return '/apps/materials';
}
export function hrefMaterialSubject(subjectId: string): string {
return `/apps/materials/subject?subjectId=${encodeURIComponent(subjectId)}`;
}
export function hrefMaterialDetail(materialId: string): string {
return `/apps/materials/detail?materialId=${encodeURIComponent(materialId)}`;
}
// ==================== Profile Settings (设置相关) ====================
export function hrefProfileAbout(): string {
return '/profile/about';
}
export function hrefProfileTerms(): string {
return '/terms';
}
export function hrefProfilePrivacy(): string {
return '/privacy';
}
export function hrefProfileVerification(): string {
return '/profile/verification';
}
export function hrefVerificationGuide(): string {
return '/verification-guide';
}
export function hrefVerificationForm(identity?: string): string {
if (identity) {
return `/verification-form?identity=${encodeURIComponent(identity)}`;
}
return '/verification-form';
}
export function hrefProfileDataStorage(): string {
return '/profile/data-storage';
}
export function hrefProfilePrivacySettings(): string {
return '/profile/privacy-settings';
}
export function hrefProfileDeletion(): string {
return '/profile/account-deletion';
}

View File

@@ -18,7 +18,7 @@ import {
type AppColors, type AppColors,
} from '../../theme'; } from '../../theme';
import * as hrefs from '../../navigation/hrefs'; import * as hrefs from '../../navigation/hrefs';
import { Text, ResponsiveContainer } from '../../components/common'; import { Text } from '../../components/common';
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive'; import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
type AppItem = { type AppItem = {
@@ -37,8 +37,18 @@ const APP_ENTRIES: AppItem[] = [
icon: 'calendar-week', icon: 'calendar-week',
href: hrefs.hrefSchedule(), href: hrefs.hrefSchedule(),
}, },
{
id: 'materials',
title: '学习资料',
subtitle: '按学科分类的学习资源网盘',
icon: 'folder-multiple',
href: hrefs.hrefMaterials(),
},
]; ];
// 应用卡片最大宽度设置
const APP_CARD_MAX_WIDTH = 720;
export const AppsScreen: React.FC = () => { export const AppsScreen: React.FC = () => {
const colors = useAppColors(); const colors = useAppColors();
const resolvedScheme = useResolvedColorScheme(); const resolvedScheme = useResolvedColorScheme();
@@ -56,6 +66,9 @@ export const AppsScreen: React.FC = () => {
shadowOpacity: 0.06, shadowOpacity: 0.06,
shadowRadius: 8, shadowRadius: 8,
elevation: 2, elevation: 2,
maxWidth: APP_CARD_MAX_WIDTH,
alignSelf: 'center' as const,
width: '100%' as const,
}), }),
[colors] [colors]
); );
@@ -129,17 +142,6 @@ export const AppsScreen: React.FC = () => {
</View> </View>
</View> </View>
{isWideScreen ? (
<ResponsiveContainer maxWidth={720}>
<ScrollView
style={styles.scroll}
contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}
showsVerticalScrollIndicator={false}
>
{renderTiles()}
</ScrollView>
</ResponsiveContainer>
) : (
<ScrollView <ScrollView
style={styles.scroll} style={styles.scroll}
contentContainerStyle={[ contentContainerStyle={[
@@ -150,7 +152,6 @@ export const AppsScreen: React.FC = () => {
> >
{renderTiles()} {renderTiles()}
</ScrollView> </ScrollView>
)}
</SafeAreaView> </SafeAreaView>
); );
}; };

View File

@@ -1,4 +1,15 @@
import React, { useEffect, useMemo, useState } from 'react'; /**
* 忘记密码页 ForgotPasswordScreen简洁表单样式
* 胡萝卜BBS - 找回密码
*
* 设计风格:
* - 纯白背景,扁平化设计
* - 增大间距
* - 输入框带灰色背景填充
* - 橙色圆角按钮
*/
import React, { useEffect, useMemo, useState, useRef } from 'react';
import { import {
View, View,
Text, Text,
@@ -9,123 +20,28 @@ import {
Platform, Platform,
ScrollView, ScrollView,
ActivityIndicator, ActivityIndicator,
Animated,
StatusBar,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { useAppColors, type AppColors } from '../../theme';
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
import { authService, resolveAuthApiError } from '../../services/authService'; import { authService, resolveAuthApiError } from '../../services/authService';
import { showPrompt } from '../../services/promptService'; import { showPrompt } from '../../services/promptService';
function createForgotPasswordStyles(colors: AppColors) { // 胡萝卜橙主题色
return StyleSheet.create({ const THEME_COLORS = {
container: { flex: 1 }, primary: '#FF6B35',
gradient: { flex: 1 }, primaryLight: '#FF8C5A',
keyboardView: { flex: 1 }, primaryDark: '#E55A2B',
scrollContent: { };
flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: spacing.xl,
paddingVertical: spacing['2xl'],
},
formCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius['2xl'],
padding: spacing.xl,
...shadows.md,
},
title: {
fontSize: fontSizes['2xl'],
fontWeight: '700',
color: colors.text.primary,
textAlign: 'center',
marginBottom: spacing.xs,
},
subtitle: {
fontSize: fontSizes.sm,
color: colors.text.secondary,
textAlign: 'center',
marginBottom: spacing.lg,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
borderRadius: borderRadius.lg,
borderWidth: 1.5,
borderColor: colors.divider,
paddingHorizontal: spacing.md,
height: 52,
marginBottom: spacing.md,
},
inputIcon: {
marginRight: spacing.sm,
},
input: {
flex: 1,
fontSize: fontSizes.md,
color: colors.text.primary,
},
codeRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.md,
},
codeInput: {
flex: 1,
marginBottom: 0,
},
sendCodeButton: {
height: 52,
minWidth: 110,
borderRadius: borderRadius.lg,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.md,
},
sendCodeButtonDisabled: {
opacity: 0.6,
},
sendCodeButtonText: {
color: '#fff',
fontSize: fontSizes.sm,
fontWeight: '600',
},
submitButton: {
height: 50,
borderRadius: borderRadius.lg,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginTop: spacing.sm,
},
submitButtonDisabled: {
opacity: 0.6,
},
submitButtonText: {
color: '#fff',
fontSize: fontSizes.lg,
fontWeight: '700',
},
backButton: {
alignItems: 'center',
marginTop: spacing.md,
},
backButtonText: {
color: colors.primary.main,
fontSize: fontSizes.md,
fontWeight: '500',
},
});
}
export const ForgotPasswordScreen: React.FC = () => { export const ForgotPasswordScreen: React.FC = () => {
const colors = useAppColors(); const colors = useAppColors();
const styles = useMemo(() => createForgotPasswordStyles(colors), [colors]); const styles = useMemo(() => createForgotPasswordStyles(colors), [colors]);
const router = useRouter(); const router = useRouter();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [verificationCode, setVerificationCode] = useState(''); const [verificationCode, setVerificationCode] = useState('');
const [newPassword, setNewPassword] = useState(''); const [newPassword, setNewPassword] = useState('');
@@ -136,6 +52,26 @@ export const ForgotPasswordScreen: React.FC = () => {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [countdown, setCountdown] = useState(0); const [countdown, setCountdown] = useState(0);
// 动画值
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(20)).current;
// 启动入场动画
useEffect(() => {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 400,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 400,
useNativeDriver: true,
}),
]).start();
}, []);
useEffect(() => { useEffect(() => {
if (countdown <= 0) { if (countdown <= 0) {
return; return;
@@ -179,8 +115,20 @@ export const ForgotPasswordScreen: React.FC = () => {
showPrompt({ title: '提示', message: '请输入验证码', type: 'warning' }); showPrompt({ title: '提示', message: '请输入验证码', type: 'warning' });
return; return;
} }
if (newPassword.length < 6) { if (newPassword.length < 8) {
showPrompt({ title: '提示', message: '新密码至少 6 位', type: 'warning' }); showPrompt({ title: '提示', message: '新密码至少 8 位', type: 'warning' });
return;
}
if (!/[A-Z]/.test(newPassword)) {
showPrompt({ title: '提示', message: '新密码必须包含大写字母', type: 'warning' });
return;
}
if (!/[a-z]/.test(newPassword)) {
showPrompt({ title: '提示', message: '新密码必须包含小写字母', type: 'warning' });
return;
}
if (!/[0-9]/.test(newPassword)) {
showPrompt({ title: '提示', message: '新密码必须包含数字', type: 'warning' });
return; return;
} }
if (newPassword !== confirmPassword) { if (newPassword !== confirmPassword) {
@@ -208,32 +156,61 @@ export const ForgotPasswordScreen: React.FC = () => {
return ( return (
<SafeAreaView style={styles.container}> <SafeAreaView style={styles.container}>
<LinearGradient colors={['#FF6B35', '#FF8F66', '#FFB088']} style={styles.gradient}> <StatusBar barStyle="dark-content" backgroundColor="#fff" />
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.keyboardView}> <KeyboardAvoidingView
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled"> behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
<View style={styles.formCard}> style={styles.keyboardView}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<Animated.View
style={[
styles.content,
{
opacity: fadeAnim,
transform: [{ translateY: slideAnim }],
},
]}
>
{/* 标题区域 */}
<View style={styles.titleSection}>
<Text style={styles.title}></Text> <Text style={styles.title}></Text>
<Text style={styles.subtitle}></Text> <View style={styles.underline} />
<Text style={styles.subtitle}></Text>
</View>
{/* 表单区域 */}
<View style={styles.formSection}>
{/* 邮箱输入框 */}
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}></Text>
<View style={styles.inputWrapper}> <View style={styles.inputWrapper}>
<MaterialCommunityIcons name="email-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="邮箱" placeholder="请输入邮箱地址"
placeholderTextColor={colors.text.hint} placeholderTextColor={colors.text.hint}
value={email} value={email}
onChangeText={setEmail} onChangeText={setEmail}
autoCapitalize="none" autoCapitalize="none"
keyboardType="email-address" keyboardType="email-address"
/> />
{email.length > 0 && validateEmail(email) && (
<MaterialCommunityIcons name="check-circle" size={20} color={THEME_COLORS.primary} style={styles.checkIcon} />
)}
</View>
</View> </View>
{/* 验证码 */}
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}></Text>
<View style={styles.codeRow}> <View style={styles.codeRow}>
<View style={[styles.inputWrapper, styles.codeInput]}> <View style={[styles.inputWrapper, styles.codeInputWrapper]}>
<MaterialCommunityIcons name="shield-key-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="验证码" placeholder="6位验证码"
placeholderTextColor={colors.text.hint} placeholderTextColor={colors.text.hint}
value={verificationCode} value={verificationCode}
onChangeText={setVerificationCode} onChangeText={setVerificationCode}
@@ -245,64 +222,242 @@ export const ForgotPasswordScreen: React.FC = () => {
style={[styles.sendCodeButton, (sendingCode || countdown > 0) && styles.sendCodeButtonDisabled]} style={[styles.sendCodeButton, (sendingCode || countdown > 0) && styles.sendCodeButtonDisabled]}
onPress={handleSendCode} onPress={handleSendCode}
disabled={sendingCode || countdown > 0} disabled={sendingCode || countdown > 0}
activeOpacity={0.8}
> >
{sendingCode ? ( {sendingCode ? (
<ActivityIndicator size="small" color="#fff" /> <ActivityIndicator size="small" color={THEME_COLORS.primary} />
) : ( ) : (
<Text style={styles.sendCodeButtonText}>{countdown > 0 ? `${countdown}s` : '发送验证码'}</Text> <Text style={styles.sendCodeButtonText}>{countdown > 0 ? `${countdown}s` : '获取验证码'}</Text>
)} )}
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View>
{/* 新密码输入框 */}
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}></Text>
<View style={styles.inputWrapper}> <View style={styles.inputWrapper}>
<MaterialCommunityIcons name="lock-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="新密码(至少6位" placeholder="至少6位"
placeholderTextColor={colors.text.hint} placeholderTextColor={colors.text.hint}
value={newPassword} value={newPassword}
onChangeText={setNewPassword} onChangeText={setNewPassword}
secureTextEntry={!showPassword} secureTextEntry={!showPassword}
autoCapitalize="none" autoCapitalize="none"
/> />
<TouchableOpacity onPress={() => setShowPassword((v) => !v)}> <TouchableOpacity
<MaterialCommunityIcons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={20} color={colors.text.hint} /> onPress={() => setShowPassword((v) => !v)}
style={styles.eyeButton}
>
<MaterialCommunityIcons
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={colors.text.hint}
/>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View>
{/* 确认密码输入框 */}
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}></Text>
<View style={styles.inputWrapper}> <View style={styles.inputWrapper}>
<MaterialCommunityIcons name="lock-check-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="确认新密码" placeholder="再次输入新密码"
placeholderTextColor={colors.text.hint} placeholderTextColor={colors.text.hint}
value={confirmPassword} value={confirmPassword}
onChangeText={setConfirmPassword} onChangeText={setConfirmPassword}
secureTextEntry={!showConfirmPassword} secureTextEntry={!showConfirmPassword}
autoCapitalize="none" autoCapitalize="none"
returnKeyType="done"
onSubmitEditing={handleResetPassword}
/>
<TouchableOpacity
onPress={() => setShowConfirmPassword((v) => !v)}
style={styles.eyeButton}
>
<MaterialCommunityIcons
name={showConfirmPassword ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={colors.text.hint}
/> />
<TouchableOpacity onPress={() => setShowConfirmPassword((v) => !v)}>
<MaterialCommunityIcons name={showConfirmPassword ? 'eye-off-outline' : 'eye-outline'} size={20} color={colors.text.hint} />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</View>
{/* 重置密码按钮 */}
<TouchableOpacity <TouchableOpacity
style={[styles.submitButton, loading && styles.submitButtonDisabled]} style={[styles.submitButton, loading && styles.submitButtonDisabled]}
onPress={handleResetPassword} onPress={handleResetPassword}
disabled={loading} disabled={loading}
activeOpacity={0.9}
> >
{loading ? <ActivityIndicator size="small" color="#fff" /> : <Text style={styles.submitButtonText}></Text>} {loading ? (
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.submitButtonText}></Text>
)}
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}> {/* 返回登录 */}
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
activeOpacity={0.8}
>
<MaterialCommunityIcons name="arrow-left" size={18} color={THEME_COLORS.primary} style={{ marginRight: 4 }} />
<Text style={styles.backButtonText}></Text> <Text style={styles.backButtonText}></Text>
</TouchableOpacity> </TouchableOpacity>
</View> </View>
</Animated.View>
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</LinearGradient>
</SafeAreaView> </SafeAreaView>
); );
}; };
function createForgotPasswordStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
keyboardView: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
paddingHorizontal: 28,
paddingTop: 50,
paddingBottom: 40,
},
content: {
flex: 1,
maxWidth: 400,
width: '100%',
alignSelf: 'center',
},
// 标题区域
titleSection: {
marginBottom: 40,
marginTop: 20,
},
title: {
fontSize: 32,
fontWeight: '700',
color: colors.text.primary,
lineHeight: 40,
},
underline: {
width: 40,
height: 4,
backgroundColor: THEME_COLORS.primary,
borderRadius: 2,
marginTop: 12,
marginBottom: 16,
},
subtitle: {
fontSize: 16,
color: colors.text.secondary,
},
// 表单区域
formSection: {
marginBottom: 24,
},
// 输入框
inputContainer: {
marginBottom: 20,
},
inputLabel: {
fontSize: 14,
fontWeight: '500',
color: colors.text.secondary,
marginBottom: 10,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#F5F5F7',
borderRadius: 14,
paddingHorizontal: 18,
height: 56,
},
input: {
flex: 1,
fontSize: 16,
color: colors.text.primary,
height: 56,
},
checkIcon: {
marginLeft: 8,
},
eyeButton: {
padding: 4,
marginLeft: 4,
},
// 验证码行
codeRow: {
flexDirection: 'row',
gap: 12,
},
codeInputWrapper: {
flex: 1,
},
sendCodeButton: {
height: 56,
paddingHorizontal: 16,
backgroundColor: 'rgba(255, 107, 53, 0.1)',
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
minWidth: 100,
},
sendCodeButtonDisabled: {
opacity: 0.5,
},
sendCodeButtonText: {
fontSize: 14,
fontWeight: '600',
color: THEME_COLORS.primary,
},
// 提交按钮
submitButton: {
height: 56,
borderRadius: 14,
backgroundColor: THEME_COLORS.primary,
alignItems: 'center',
justifyContent: 'center',
marginTop: 8,
},
submitButtonDisabled: {
opacity: 0.7,
},
submitButtonText: {
color: '#fff',
fontSize: 17,
fontWeight: '600',
},
// 返回按钮
backButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 24,
},
backButtonText: {
color: THEME_COLORS.primary,
fontSize: 15,
fontWeight: '500',
},
});
}
export default ForgotPasswordScreen; export default ForgotPasswordScreen;

View File

@@ -1,10 +1,12 @@
/** /**
* 登录页 LoginScreen响应式适配 - 分栏布局 * 登录页 LoginScreen简洁表单样式
* 胡萝卜BBS - 用户登录 * 胡萝卜BBS - 用户登录
* *
* 布局规则 * 设计风格
* - 屏幕宽度 < 768px单栏布局橙色渐变背景 * - 纯白背景,扁平化设计
* - 屏幕宽度 >= 768px分栏布局左侧橙色右侧中性灰 * - 增大间距,避免页面太空
* - 输入框带灰色背景填充
* - 橙色圆角按钮
*/ */
import React, { useState, useEffect, useRef, useMemo } from 'react'; import React, { useState, useEffect, useRef, useMemo } from 'react';
@@ -24,15 +26,18 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient'; import { useAppColors, type AppColors } from '../../theme';
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
import { useAuthStore } from '../../stores'; import { useAuthStore } from '../../stores';
import * as hrefs from '../../navigation/hrefs'; import * as hrefs from '../../navigation/hrefs';
import { useResponsive, useResponsiveValue } from '../../hooks'; import { useResponsive } from '../../hooks';
import { showPrompt } from '../../services/promptService'; import { showPrompt } from '../../services/promptService';
// 分栏布局断点 // 胡萝卜橙主题色
const SPLIT_BREAKPOINT = 768; const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
export const LoginScreen: React.FC = () => { export const LoginScreen: React.FC = () => {
const colors = useAppColors(); const colors = useAppColors();
@@ -40,67 +45,30 @@ export const LoginScreen: React.FC = () => {
const router = useRouter(); const router = useRouter();
const login = useAuthStore((state) => state.login); const login = useAuthStore((state) => state.login);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated); const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const storeError = useAuthStore((state) => state.error);
const setStoreError = useAuthStore((state) => state.setError); const setStoreError = useAuthStore((state) => state.setError);
// 响应式布局 const { isLandscape } = useResponsive();
const { isLandscape, width } = useResponsive();
// 是否使用分栏布局
const isSplitLayout = width >= SPLIT_BREAKPOINT;
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null); const [agreedToTerms, setAgreedToTerms] = useState(false);
// 动画值 // 动画值
const fadeAnim = useRef(new Animated.Value(0)).current; const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(50)).current; const slideAnim = useRef(new Animated.Value(20)).current;
const scaleAnim = useRef(new Animated.Value(0.9)).current;
const inputFadeAnim = useRef(new Animated.Value(0)).current;
// 响应式值 - 大屏模式单独放大Logo尺寸更显大气
const logoSize = useResponsiveValue({
xs: 64, sm: 72, md: 80,
lg: isSplitLayout ? 140 : 96, // 大屏模式Logo图标进一步放大
xl: isSplitLayout ? 160 : 110
});
const logoContainerSize = useResponsiveValue({
xs: 110, sm: 120, md: 130,
lg: isSplitLayout ? 200 : 150, // 大屏模式Logo容器进一步放大
xl: isSplitLayout ? 220 : 170
});
const appNameSize = useResponsiveValue({
xs: 28, sm: 30, md: 32,
lg: isSplitLayout ? 60 : 36, // 大屏模式标题文字大幅放大
xl: isSplitLayout ? 72 : 40
});
const formMaxWidth = isSplitLayout ? 420 : undefined;
// 启动入场动画 // 启动入场动画
useEffect(() => { useEffect(() => {
Animated.sequence([
Animated.parallel([ Animated.parallel([
Animated.timing(fadeAnim, { Animated.timing(fadeAnim, {
toValue: 1, toValue: 1,
duration: 600, duration: 400,
useNativeDriver: true, useNativeDriver: true,
}), }),
Animated.timing(scaleAnim, {
toValue: 1,
duration: 600,
useNativeDriver: true,
}),
]),
Animated.timing(slideAnim, { Animated.timing(slideAnim, {
toValue: 0, toValue: 0,
duration: 500,
useNativeDriver: true,
}),
Animated.timing(inputFadeAnim, {
toValue: 1,
duration: 400, duration: 400,
useNativeDriver: true, useNativeDriver: true,
}), }),
@@ -117,20 +85,13 @@ export const LoginScreen: React.FC = () => {
showPrompt({ title: '提示', message: '请输入密码', type: 'warning' }); showPrompt({ title: '提示', message: '请输入密码', type: 'warning' });
return false; return false;
} }
if (password.length < 6) { if (!agreedToTerms) {
showPrompt({ title: '提示', message: '密码长度不能少于6位', type: 'warning' }); showPrompt({ title: '提示', message: '请同意用户协议和隐私政策', type: 'warning' });
return false; return false;
} }
return true; return true;
}; };
// store error 同步到本地
useEffect(() => {
if (storeError) {
setErrorMsg(storeError);
}
}, [storeError]);
useEffect(() => { useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {
router.replace(hrefs.hrefHome()); router.replace(hrefs.hrefHome());
@@ -138,7 +99,6 @@ export const LoginScreen: React.FC = () => {
}, [isAuthenticated, router]); }, [isAuthenticated, router]);
const clearError = () => { const clearError = () => {
setErrorMsg(null);
setStoreError(null); setStoreError(null);
}; };
@@ -152,11 +112,11 @@ export const LoginScreen: React.FC = () => {
const success = await login({ username, password }); const success = await login({ username, password });
if (!success) { if (!success) {
if (!useAuthStore.getState().error) { if (!useAuthStore.getState().error) {
setErrorMsg('登录失败,请稍后重试'); showPrompt({ title: '登录失败', message: '请检查用户名和密码', type: 'error' });
} }
} }
} catch (error: any) { } catch (error: any) {
setErrorMsg(error.message || '登录失败,请检查用户名和密码'); showPrompt({ title: '登录失败', message: error.message || '请检查用户名和密码', type: 'error' });
} finally { } finally {
setLoading(false); setLoading(false);
} }
@@ -167,86 +127,46 @@ export const LoginScreen: React.FC = () => {
router.push(hrefs.hrefAuthRegister()); router.push(hrefs.hrefAuthRegister());
}; };
// 渲染左侧面板Logo和品牌信息- 优化大屏布局 return (
const renderLeftPanel = () => ( <SafeAreaView style={styles.container}>
<StatusBar barStyle="dark-content" backgroundColor="#fff" />
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<ScrollView
contentContainerStyle={[
styles.scrollContent,
isLandscape && styles.scrollContentLandscape,
]}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<Animated.View <Animated.View
style={[ style={[
styles.leftPanel, styles.content,
isSplitLayout && styles.splitLeftPanel, // 大屏模式专用布局
{ {
opacity: fadeAnim, opacity: fadeAnim,
transform: [{ scale: scaleAnim }],
},
]}
>
{/* 胡萝卜图标 - 靠上显示 */}
<View style={[
styles.logoContainer,
styles.logoContainerTop,
isSplitLayout && styles.splitLogoContainer, // 大屏模式Logo容器样式
{ width: logoContainerSize, height: logoContainerSize, borderRadius: logoContainerSize / 2 }
]}>
<MaterialCommunityIcons
name="carrot"
size={logoSize}
color="#FFF"
/>
</View>
{/* 品牌名称 - 优化大屏排版 */}
<Text style={[styles.appName, isSplitLayout && styles.splitAppName]}></Text>
<Text style={[styles.subtitle, isSplitLayout && styles.splitSubtitle]}></Text>
{/* 装饰元素 - 大屏模式改为水平排列 + 大幅放大 */}
<View style={[styles.leftPanelDecorBottom, isSplitLayout && styles.splitLeftPanelDecorBottom]}>
{isSplitLayout ? (
// 大屏模式:水平排列 + 大幅放大图标
<View style={styles.splitDecorRow}>
<MaterialCommunityIcons name="chat-outline" size={80} color="rgba(255,255,255,0.4)" style={styles.splitDecorIcon} />
<MaterialCommunityIcons name="account-group-outline" size={90} color="rgba(255,255,255,0.45)" style={styles.splitDecorIcon} />
<MaterialCommunityIcons name="bell-outline" size={85} color="rgba(255,255,255,0.35)" style={styles.splitDecorIcon} />
</View>
) : (
// 小屏模式:保持原有横向排列
<View style={styles.decorRow}>
<MaterialCommunityIcons name="chat-outline" size={48} color="rgba(255,255,255,0.35)" />
<MaterialCommunityIcons name="account-group-outline" size={44} color="rgba(255,255,255,0.3)" />
<MaterialCommunityIcons name="bell-outline" size={40} color="rgba(255,255,255,0.25)" />
</View>
)}
</View>
</Animated.View>
);
// 渲染表单内容
const renderFormContent = () => (
<View style={isSplitLayout ? styles.splitFormWrapper : styles.singleFormWrapper}>
<Animated.View
style={[
styles.formCard,
// 核心修改:大屏模式使用专用的阴影样式,小屏用原有阴影
isSplitLayout ? styles.splitFormCardShadow : styles.normalFormCardShadow,
formMaxWidth && { maxWidth: formMaxWidth, width: '100%' },
{
opacity: inputFadeAnim,
transform: [{ translateY: slideAnim }], transform: [{ translateY: slideAnim }],
}, },
]} ]}
> >
<Text style={styles.formTitle}></Text> {/* 标题区域 - 增大间距 */}
<View style={styles.titleSection}>
<Text style={styles.title}></Text>
<View style={styles.underline} />
<Text style={styles.subtitle}></Text>
</View>
{/* 表单区域 */}
<View style={styles.formSection}>
{/* 用户名输入框 */} {/* 用户名输入框 */}
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
<Text style={styles.inputLabel}></Text>
<View style={styles.inputWrapper}> <View style={styles.inputWrapper}>
<MaterialCommunityIcons
name="account-outline"
size={22}
color={colors.primary.main}
style={styles.inputIcon}
/>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="用户名 / 邮箱 / 手机号" placeholder="请输入用户名"
placeholderTextColor={colors.text.hint} placeholderTextColor={colors.text.hint}
value={username} value={username}
onChangeText={(v) => { setUsername(v); clearError(); }} onChangeText={(v) => { setUsername(v); clearError(); }}
@@ -255,29 +175,15 @@ export const LoginScreen: React.FC = () => {
returnKeyType="next" returnKeyType="next"
/> />
{username.length > 0 && ( {username.length > 0 && (
<TouchableOpacity <MaterialCommunityIcons name="check-circle" size={20} color={THEME_COLORS.primary} style={styles.checkIcon} />
onPress={() => setUsername('')}
style={styles.clearButton}
>
<MaterialCommunityIcons
name="close-circle"
size={20}
color={colors.text.hint}
/>
</TouchableOpacity>
)} )}
</View> </View>
</View> </View>
{/* 密码输入框 */} {/* 密码输入框 */}
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
<Text style={styles.inputLabel}></Text>
<View style={styles.inputWrapper}> <View style={styles.inputWrapper}>
<MaterialCommunityIcons
name="lock-outline"
size={22}
color={colors.primary.main}
style={styles.inputIcon}
/>
<TextInput <TextInput
style={styles.input} style={styles.input}
placeholder="请输入密码" placeholder="请输入密码"
@@ -291,7 +197,7 @@ export const LoginScreen: React.FC = () => {
/> />
<TouchableOpacity <TouchableOpacity
onPress={() => setShowPassword(!showPassword)} onPress={() => setShowPassword(!showPassword)}
style={styles.clearButton} style={styles.eyeButton}
> >
<MaterialCommunityIcons <MaterialCommunityIcons
name={showPassword ? 'eye-off-outline' : 'eye-outline'} name={showPassword ? 'eye-off-outline' : 'eye-outline'}
@@ -310,343 +216,171 @@ export const LoginScreen: React.FC = () => {
<Text style={styles.forgotPasswordText}></Text> <Text style={styles.forgotPasswordText}></Text>
</TouchableOpacity> </TouchableOpacity>
{/* 内联错误提示 */} {/* 服务条款勾选 */}
{errorMsg && ( <View style={styles.termsContainer}>
<View style={styles.errorBox}> <TouchableOpacity
onPress={() => setAgreedToTerms(!agreedToTerms)}
activeOpacity={0.8}
style={styles.checkboxWrapper}
>
<MaterialCommunityIcons <MaterialCommunityIcons
name="alert-circle-outline" name={agreedToTerms ? 'checkbox-marked' : 'checkbox-blank-outline'}
size={16} size={22}
color={colors.error.dark} color={agreedToTerms ? THEME_COLORS.primary : colors.text.hint}
style={{ marginRight: 6, marginTop: 1 }}
/> />
<Text style={styles.errorText}>{errorMsg}</Text> </TouchableOpacity>
<Text style={styles.termsText}>
<Text
style={styles.termsLink}
onPress={() => router.push(hrefs.hrefProfileTerms())}
>
</Text>
<Text
style={styles.termsLink}
onPress={() => router.push(hrefs.hrefProfilePrivacy())}
>
</Text>
</Text>
</View> </View>
)}
{/* 登录按钮 */} {/* 登录按钮 */}
<TouchableOpacity <TouchableOpacity
style={[styles.loginButton, loading && styles.loginButtonDisabled]} style={[styles.loginButton, loading && styles.loginButtonDisabled]}
onPress={handleLogin} onPress={handleLogin}
disabled={loading} disabled={loading}
activeOpacity={0.8} activeOpacity={0.9}
>
<LinearGradient
colors={[colors.primary.main, colors.primary.light]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.loginButtonGradient}
> >
{loading ? ( {loading ? (
<ActivityIndicator size="small" color="#FFF" /> <ActivityIndicator size="small" color="#FFF" />
) : ( ) : (
<Text style={styles.loginButtonText}> </Text> <Text style={styles.loginButtonText}> </Text>
)} )}
</LinearGradient>
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </View>
{/* 底部注册提示 */} {/* 底部注册提示 */}
<Animated.View <View style={styles.footer}>
style={[
styles.footerSection,
{ opacity: inputFadeAnim },
]}
>
<Text style={styles.footerText}></Text> <Text style={styles.footerText}></Text>
<TouchableOpacity onPress={handleGoToRegister}> <TouchableOpacity onPress={handleGoToRegister}>
<Text style={styles.registerLink}></Text> <Text style={styles.registerLink}></Text>
</TouchableOpacity> </TouchableOpacity>
</View>
</Animated.View> </Animated.View>
</View>
);
// 渲染分栏布局
const renderSplitLayout = () => (
<View style={styles.splitContainer}>
{/* 左侧橙色面板 */}
<LinearGradient
colors={[colors.primary.main, colors.primary.light, '#FFB088']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.leftPanelContainer}
>
{/* 装饰性背景元素 */}
<View style={styles.decorCircle1} />
<View style={styles.decorCircle2} />
<StatusBar barStyle="light-content" />
<SafeAreaView style={styles.leftPanelSafeArea}>
{renderLeftPanel()}
</SafeAreaView>
</LinearGradient>
{/* 右侧中性灰面板 */}
<View style={styles.rightPanelContainer}>
<StatusBar barStyle="dark-content" />
<SafeAreaView style={styles.rightPanelSafeArea}>
{/* 核心修复用View包裹确保垂直+水平居中 */}
<View style={styles.splitRightContentWrapper}>
{renderFormContent()}
</View>
</SafeAreaView>
</View>
</View>
);
// 渲染单栏布局
const renderSingleLayout = () => (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<LinearGradient
colors={[colors.primary.main, colors.primary.light, '#FFB088']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.gradient}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<ScrollView
contentContainerStyle={[
styles.scrollContent,
isLandscape && styles.scrollContentLandscape,
]}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{/* 装饰性背景元素 */}
<View style={styles.decorCircle1} />
<View style={styles.decorCircle2} />
{/* 头部 */}
<Animated.View
style={[
styles.headerSection,
isLandscape && styles.headerSectionLandscape,
{
opacity: fadeAnim,
transform: [{ scale: scaleAnim }],
},
]}
>
<View style={[
styles.logoContainer,
{ width: logoContainerSize, height: logoContainerSize, borderRadius: logoContainerSize / 2 }
]}>
<MaterialCommunityIcons
name="carrot"
size={logoSize}
color="#FFF"
/>
</View>
<Text style={[styles.appName, { fontSize: appNameSize }]}></Text>
<Text style={styles.subtitle}></Text>
</Animated.View>
{/* 表单 */}
{renderFormContent()}
</ScrollView> </ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</LinearGradient>
</SafeAreaView> </SafeAreaView>
); );
return isSplitLayout ? renderSplitLayout() : renderSingleLayout();
}; };
function createLoginStyles(colors: AppColors) { function createLoginStyles(colors: AppColors) {
return StyleSheet.create({ return StyleSheet.create({
container: { container: {
flex: 1, flex: 1,
}, backgroundColor: '#fff',
gradient: {
flex: 1,
}, },
keyboardView: { keyboardView: {
flex: 1, flex: 1,
}, },
// 单栏布局内容
scrollContent: { scrollContent: {
flexGrow: 1, flexGrow: 1,
paddingHorizontal: spacing.xl, paddingHorizontal: 28,
paddingVertical: spacing['2xl'], paddingTop: 60,
}, paddingBottom: 40,
scrollContentCentered: {
alignItems: 'center',
justifyContent: 'center',
flex: 1,
}, },
scrollContentLandscape: { scrollContentLandscape: {
paddingVertical: spacing.lg, paddingTop: 30,
}, },
// 装饰性背景元素 content: {
decorCircle1: { flex: 1,
position: 'absolute', maxWidth: 400,
top: -100, width: '100%',
right: -100, alignSelf: 'center',
width: 300,
height: 300,
borderRadius: 150,
backgroundColor: 'rgba(255,255,255,0.1)',
}, },
decorCircle2: {
position: 'absolute', // 标题区域 - 增大间距
bottom: 100, titleSection: {
left: -150, marginBottom: 48,
width: 250, marginTop: 20,
height: 250,
borderRadius: 125,
backgroundColor: 'rgba(255,255,255,0.08)',
}, },
// 头部区域 title: {
headerSection: { fontSize: 32,
alignItems: 'center',
marginTop: spacing['3xl'],
marginBottom: spacing['3xl'],
},
headerSectionLandscape: {
marginTop: spacing.lg,
marginBottom: spacing.lg,
},
logoContainer: {
backgroundColor: 'rgba(255,255,255,0.25)',
alignItems: 'center',
justifyContent: 'center',
marginBottom: spacing.lg,
borderWidth: 2,
borderColor: 'rgba(255,255,255,0.4)',
...shadows.md,
},
// 大屏模式Logo容器样式 - 增加阴影和内边距,更立体
splitLogoContainer: {
borderWidth: 3,
borderColor: 'rgba(255,255,255,0.5)',
...shadows.lg,
},
logoContainerTop: {
marginTop: spacing['3xl'],
},
appName: {
fontWeight: '800',
color: '#FFFFFF',
marginBottom: spacing.sm,
textShadowColor: 'rgba(0,0,0,0.1)',
textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4,
},
// 大屏模式标题样式 - 大幅放大,增加间距和文字效果
splitAppName: {
fontSize: 72, // 大幅放大主标题
marginBottom: spacing['2xl'],
textShadowOffset: { width: 0, height: 4 },
textShadowRadius: 8,
letterSpacing: 4, // 增加字间距,更显大气
fontWeight: '900',
},
subtitle: {
fontSize: fontSizes.md,
color: 'rgba(255,255,255,0.9)',
textAlign: 'center',
paddingHorizontal: spacing.xl,
},
// 大屏模式副标题样式 - 大幅放大
splitSubtitle: {
fontSize: 24, // 放大副标题
marginBottom: spacing['6xl'], // 增加底部间距
lineHeight: 36,
paddingHorizontal: spacing['4xl'],
letterSpacing: 1.5,
fontWeight: '500',
},
// 表单卡片(基础样式,无阴影)
formCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius['2xl'],
padding: spacing['2xl'],
marginBottom: spacing.xl,
},
// 小屏模式:原有默认阴影
normalFormCardShadow: {
...shadows.md,
},
// 大屏模式:专用全向阴影
splitFormCardShadow: {
// iOS 全向柔和阴影
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.12,
shadowRadius: 12,
// Android 高程 + 轻微边框(模拟顶部阴影)
elevation: 8,
// 新增:极细微的顶部边框,强化区分度
borderTopWidth: 1,
borderTopColor: 'rgba(0,0,0,0.05)',
},
formTitle: {
fontSize: fontSizes['2xl'],
fontWeight: '700', fontWeight: '700',
color: colors.text.primary, color: colors.text.primary,
marginBottom: spacing.xl, lineHeight: 40,
textAlign: 'center',
}, },
underline: {
width: 40,
height: 4,
backgroundColor: THEME_COLORS.primary,
borderRadius: 2,
marginTop: 12,
marginBottom: 16,
},
subtitle: {
fontSize: 16,
color: colors.text.secondary,
},
// 表单区域
formSection: {
marginBottom: 32,
},
// 输入框
inputContainer: { inputContainer: {
marginBottom: spacing.lg, marginBottom: 24,
},
inputLabel: {
fontSize: 14,
fontWeight: '500',
color: colors.text.secondary,
marginBottom: 10,
}, },
inputWrapper: { inputWrapper: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
backgroundColor: colors.background.default, backgroundColor: '#F5F5F7',
borderRadius: borderRadius.lg, borderRadius: 14,
borderWidth: 1.5, paddingHorizontal: 18,
borderColor: colors.divider,
paddingHorizontal: spacing.md,
height: 56, height: 56,
}, },
inputIcon: {
marginRight: spacing.sm,
},
input: { input: {
flex: 1, flex: 1,
fontSize: fontSizes.md, fontSize: 16,
color: colors.text.primary, color: colors.text.primary,
height: 56,
}, },
clearButton: { checkIcon: {
padding: spacing.xs, marginLeft: 8,
}, },
eyeButton: {
padding: 4,
marginLeft: 4,
},
// 忘记密码
forgotPassword: { forgotPassword: {
alignSelf: 'flex-end', alignSelf: 'flex-end',
marginBottom: spacing.md, marginBottom: 28,
marginTop: -8,
}, },
forgotPasswordText: { forgotPasswordText: {
fontSize: fontSizes.sm, fontSize: 14,
color: colors.primary.main, color: THEME_COLORS.primary,
fontWeight: '600', fontWeight: '500',
},
errorBox: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: colors.accent.light,
borderRadius: borderRadius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
marginBottom: spacing.md,
borderLeftWidth: 3,
borderLeftColor: colors.accent.main,
},
errorText: {
flex: 1,
fontSize: fontSizes.sm,
color: colors.accent.dark,
lineHeight: 20,
}, },
// 登录按钮
loginButton: { loginButton: {
borderRadius: borderRadius.lg, height: 56,
overflow: 'hidden', borderRadius: 14,
...shadows.md, backgroundColor: THEME_COLORS.primary,
},
loginButtonGradient: {
height: 52,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
@@ -654,116 +388,59 @@ function createLoginStyles(colors: AppColors) {
opacity: 0.7, opacity: 0.7,
}, },
loginButtonText: { loginButtonText: {
fontSize: fontSizes.lg, fontSize: 17,
fontWeight: '700', fontWeight: '600',
color: '#FFFFFF', color: '#FFFFFF',
}, },
// 底部区域
footerSection: { // 底部
footer: {
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
paddingTop: spacing.xl, marginTop: 40,
}, },
footerText: { footerText: {
fontSize: fontSizes.md, fontSize: 15,
color: colors.text.primary, color: colors.text.secondary,
}, },
registerLink: { registerLink: {
fontSize: fontSizes.md, fontSize: 15,
color: colors.primary.main, color: THEME_COLORS.primary,
fontWeight: '700', fontWeight: '600',
marginLeft: spacing.xs, marginLeft: 6,
textDecorationLine: 'underline',
}, },
// ========== 分栏布局样式 ========== policyFooter: {
splitContainer: { alignItems: 'center',
flex: 1, marginTop: 24,
},
policyText: {
fontSize: 13,
color: colors.text.hint,
},
policyLink: {
color: THEME_COLORS.primary,
fontWeight: '500',
},
termsContainer: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 20,
marginTop: 4,
}, },
leftPanelContainer: { checkboxWrapper: {
flex: 1, // 50% paddingTop: 2,
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
}, },
leftPanelSafeArea: { termsText: {
fontSize: 14,
color: colors.text.secondary,
marginLeft: 10,
flex: 1, flex: 1,
justifyContent: 'center', lineHeight: 22,
alignItems: 'center',
paddingHorizontal: spacing['4xl'], // 增加内边距,不贴边
}, },
leftPanel: { termsLink: {
alignItems: 'center', color: THEME_COLORS.primary,
paddingHorizontal: spacing['2xl'], fontWeight: '500',
},
// 大屏模式左侧面板布局
splitLeftPanel: {
width: '100%',
justifyContent: 'center',
paddingVertical: spacing['6xl'],
},
leftPanelDecor: {
position: 'absolute',
bottom: spacing['4xl'],
left: 0,
right: 0,
alignItems: 'center',
},
leftPanelDecorBottom: {
marginTop: spacing['2xl'],
},
// 大屏模式装饰元素布局
splitLeftPanelDecorBottom: {
marginTop: spacing['6xl'],
width: '100%',
alignItems: 'center',
},
// 大屏模式装饰元素水平排列
splitDecorRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-around',
width: '100%',
gap: spacing['2xl'], // 增加图标间距
},
// 大屏模式装饰图标样式
splitDecorIcon: {
transform: [{ scale: 1.1 }],
opacity: 0.9,
},
decorRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-around',
width: '80%',
},
rightPanelContainer: {
flex: 1, // 50%
backgroundColor: colors.background.paper,
},
rightPanelSafeArea: {
flex: 1,
// 移除左右内边距,交给子容器控制
},
// 分栏布局右侧内容包裹器(核心修复)
splitRightContentWrapper: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
// 新增:左右内边距,保证表单和边缘有间距
paddingHorizontal: spacing['2xl'],
},
// 表单内容包裹器(区分大屏/小屏)
splitFormWrapper: {
// 关键修改:设置最大宽度 + 自适应宽度,实现水平居中
maxWidth: 420,
width: '100%',
},
singleFormWrapper: {
flexGrow: 1,
width: '100%',
// 移除这里的阴影,避免重复
}, },
}); });
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,192 @@
/**
* 注册步骤1输入邮箱
* 包含邮箱输入框和获取验证码按钮
*/
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
TextInput,
TouchableOpacity,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors, type AppColors } from '../../theme';
import { RegisterStepProps } from './types';
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
export const RegisterStep1Email: React.FC<RegisterStepProps> = ({
formData,
updateFormData,
onNext,
colors: propColors,
sendingCode,
countdown,
onSendCode,
}) => {
const colors = propColors || useAppColors();
const styles = createStyles(colors);
const [emailError, setEmailError] = useState('');
const validateEmail = (email: string): boolean => {
if (!email.trim()) {
setEmailError('请输入邮箱');
return false;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setEmailError('请输入正确的邮箱地址');
return false;
}
setEmailError('');
return true;
};
const handleSendCode = async () => {
if (!validateEmail(formData.email)) {
return;
}
if (onSendCode) {
const success = await onSendCode(formData.email);
if (success) {
onNext();
}
}
};
return (
<View style={styles.container}>
<View style={styles.formSection}>
{/* 邮箱输入框 */}
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}></Text>
<View
style={[
styles.inputWrapper,
emailError ? styles.inputWrapperError : null,
]}
>
<TextInput
style={styles.input}
placeholder="请输入邮箱地址"
placeholderTextColor={colors.text?.hint || '#999'}
value={formData.email}
onChangeText={(text) => {
updateFormData({ email: text });
if (emailError) setEmailError('');
}}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
editable={!sendingCode}
/>
{formData.email.length > 0 &&
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email) && (
<MaterialCommunityIcons
name="check-circle"
size={20}
color={THEME_COLORS.primary}
style={styles.checkIcon}
/>
)}
</View>
{emailError ? (
<Text style={styles.errorText}>{emailError}</Text>
) : null}
</View>
{/* 获取验证码按钮 */}
<TouchableOpacity
style={[
styles.actionButton,
(sendingCode || (countdown ?? 0) > 0) && styles.actionButtonDisabled,
]}
onPress={handleSendCode}
disabled={sendingCode || (countdown ?? 0) > 0}
activeOpacity={0.9}
>
<Text style={styles.actionButtonText}>
{sendingCode
? '发送中...'
: (countdown ?? 0) > 0
? `${countdown}秒后重试`
: '获取验证码'}
</Text>
</TouchableOpacity>
</View>
</View>
);
}
function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
paddingTop: 20,
},
formSection: {
marginBottom: 24,
},
inputContainer: {
marginBottom: 24,
},
inputLabel: {
fontSize: 14,
fontWeight: '500',
color: colors.text?.secondary || '#666',
marginBottom: 10,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#F5F5F7',
borderRadius: 14,
paddingHorizontal: 18,
height: 56,
borderWidth: 1,
borderColor: 'transparent',
},
inputWrapperError: {
borderColor: colors.error?.main || '#FF4444',
},
input: {
flex: 1,
fontSize: 16,
color: colors.text?.primary || '#333',
height: 56,
},
checkIcon: {
marginLeft: 8,
},
errorText: {
fontSize: 12,
color: colors.error?.main || '#FF4444',
marginTop: 6,
marginLeft: 4,
},
actionButton: {
height: 56,
borderRadius: 14,
backgroundColor: '#FF6B35',
alignItems: 'center',
justifyContent: 'center',
marginTop: 8,
},
actionButtonDisabled: {
opacity: 0.6,
},
actionButtonText: {
fontSize: 17,
fontWeight: '600',
color: '#FFFFFF',
},
});
}
export default RegisterStep1Email;

View File

@@ -0,0 +1,212 @@
/**
* 注册步骤2填写验证码
* 使用 VerificationCodeInput 组件
*/
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors, type AppColors } from '../../theme';
import { VerificationCodeInput } from '../../components/common';
import { RegisterStepProps } from './types';
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
export const RegisterStep2Verification: React.FC<RegisterStepProps> = ({
formData,
updateFormData,
onNext,
onPrev,
colors: propColors,
countdown,
onSendCode,
}) => {
const colors = propColors || useAppColors();
const styles = createStyles(colors);
const [codeError, setCodeError] = useState('');
const [isVerifying, setIsVerifying] = useState(false);
const handleComplete = async (code: string) => {
if (code.length !== 6) {
setCodeError('请输入6位验证码');
return;
}
setCodeError('');
setIsVerifying(true);
// 模拟验证实际应该调用API验证验证码
// const success = await authService.verifyCode(formData.email, code);
setTimeout(() => {
setIsVerifying(false);
onNext();
}, 500);
};
const handleResendCode = async () => {
if (onSendCode && countdown === 0) {
await onSendCode(formData.email);
}
};
const formatCountdown = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes}:${secs.toString().padStart(2, '0')}`;
};
return (
<View style={styles.container}>
<View style={styles.emailInfo}>
<Text style={styles.emailText}> {formData.email}</Text>
</View>
<View style={styles.formSection}>
{/* 验证码输入 */}
<View style={styles.codeInputContainer}>
<VerificationCodeInput
value={formData.verificationCode}
onChangeValue={(value) => {
updateFormData({ verificationCode: value });
if (codeError) setCodeError('');
}}
length={6}
autoFocus={true}
onComplete={handleComplete}
disabled={isVerifying}
/>
{codeError ? (
<Text style={styles.errorText}>{codeError}</Text>
) : null}
</View>
{/* 重发倒计时 */}
<View style={styles.resendContainer}>
{(countdown ?? 0) > 0 ? (
<Text style={styles.countdownText}>
{formatCountdown(countdown!)}
</Text>
) : (
<TouchableOpacity onPress={handleResendCode} activeOpacity={0.8}>
<Text style={styles.resendText}></Text>
</TouchableOpacity>
)}
</View>
{/* 下一步按钮 */}
<TouchableOpacity
style={[
styles.actionButton,
formData.verificationCode.length !== 6 && styles.actionButtonDisabled,
isVerifying && styles.actionButtonDisabled,
]}
onPress={() => handleComplete(formData.verificationCode)}
disabled={formData.verificationCode.length !== 6 || isVerifying}
activeOpacity={0.9}
>
<Text style={styles.actionButtonText}>
{isVerifying ? '验证中...' : '下一步'}
</Text>
</TouchableOpacity>
{/* 返回按钮 */}
<TouchableOpacity
style={styles.backButton}
onPress={onPrev}
activeOpacity={0.8}
>
<MaterialCommunityIcons
name="arrow-left"
size={18}
color={colors.text?.secondary || '#666'}
/>
<Text style={styles.backButtonText}></Text>
</TouchableOpacity>
</View>
</View>
);
}
function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
paddingTop: 20,
},
emailInfo: {
marginBottom: 32,
alignItems: 'center',
},
emailText: {
fontSize: 14,
color: colors.text?.secondary || '#666',
},
formSection: {
marginBottom: 24,
alignItems: 'center',
},
codeInputContainer: {
marginBottom: 24,
width: '100%',
alignItems: 'center',
},
errorText: {
fontSize: 12,
color: colors.error?.main || '#FF4444',
marginTop: 12,
textAlign: 'center',
},
resendContainer: {
marginBottom: 32,
},
countdownText: {
fontSize: 14,
color: colors.text?.secondary || '#999',
},
resendText: {
fontSize: 14,
color: THEME_COLORS.primary,
fontWeight: '500',
},
actionButton: {
height: 56,
borderRadius: 14,
backgroundColor: '#FF6B35',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
maxWidth: 320,
},
actionButtonDisabled: {
opacity: 0.6,
},
actionButtonText: {
fontSize: 17,
fontWeight: '600',
color: '#FFFFFF',
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 20,
paddingVertical: 8,
},
backButtonText: {
fontSize: 14,
color: colors.text?.secondary || '#666',
marginLeft: 6,
},
});
}
export default RegisterStep2Verification;

View File

@@ -0,0 +1,367 @@
/**
* 注册步骤3设置用户信息
* 包含用户名、昵称、密码等
*/
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
TextInput,
TouchableOpacity,
ActivityIndicator,
} from 'react-native';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors, type AppColors } from '../../theme';
import { RegisterStepProps } from './types';
import * as hrefs from '../../navigation/hrefs';
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
export const RegisterStep3Profile: React.FC<RegisterStepProps> = ({
formData,
updateFormData,
onNext,
onPrev,
colors: propColors,
loading,
}) => {
const colors = propColors || useAppColors();
const styles = createStyles(colors);
const router = useRouter();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [agreedToTerms, setAgreedToTerms] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.username.trim()) {
newErrors.username = '请输入用户名';
} else if (formData.username.length < 3 || formData.username.length > 20) {
newErrors.username = '用户名长度需在3-20个字符之间';
} else if (!/^[a-zA-Z0-9_]+$/.test(formData.username)) {
newErrors.username = '用户名只能包含字母、数字和下划线';
}
if (!formData.nickname.trim()) {
newErrors.nickname = '请输入昵称';
} else if (formData.nickname.length < 2 || formData.nickname.length > 20) {
newErrors.nickname = '昵称长度需在2-20个字符之间';
}
if (!formData.password) {
newErrors.password = '请输入密码';
} else if (formData.password.length < 8) {
newErrors.password = '密码长度不能少于8位';
} else if (!/[A-Z]/.test(formData.password)) {
newErrors.password = '密码必须包含大写字母';
} else if (!/[a-z]/.test(formData.password)) {
newErrors.password = '密码必须包含小写字母';
} else if (!/[0-9]/.test(formData.password)) {
newErrors.password = '密码必须包含数字';
}
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = '两次输入的密码不一致';
}
if (!agreedToTerms) {
newErrors.terms = '请同意用户协议和隐私政策';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = () => {
if (validateForm()) {
onNext();
}
};
const renderInput = (
label: string,
field: keyof typeof formData,
placeholder: string,
options: {
secureTextEntry?: boolean;
keyboardType?: 'default' | 'email-address' | 'numeric' | 'phone-pad';
maxLength?: number;
autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters';
showToggle?: boolean;
toggleValue?: boolean;
onToggle?: () => void;
} = {}
) => {
const {
secureTextEntry = false,
keyboardType = 'default',
maxLength,
autoCapitalize = 'none',
showToggle,
toggleValue,
onToggle,
} = options;
const error = errors[field as string];
const value = formData[field] || '';
return (
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}>{label}</Text>
<View
style={[
styles.inputWrapper,
error ? styles.inputWrapperError : null,
]}
>
<TextInput
style={styles.input}
placeholder={placeholder}
placeholderTextColor={colors.text?.hint || '#999'}
value={value}
onChangeText={(text) => {
updateFormData({ [field]: text } as Partial<typeof formData>);
if (errors[field as string]) {
setErrors((prev) => ({ ...prev, [field as string]: '' }));
}
}}
autoCapitalize={autoCapitalize}
autoCorrect={false}
keyboardType={keyboardType}
maxLength={maxLength}
secureTextEntry={secureTextEntry && !toggleValue}
editable={!loading}
/>
{showToggle && onToggle && (
<TouchableOpacity onPress={onToggle} style={styles.eyeButton}>
<MaterialCommunityIcons
name={toggleValue ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={colors.text?.hint || '#999'}
/>
</TouchableOpacity>
)}
</View>
{error ? <Text style={styles.errorText}>{error}</Text> : null}
</View>
);
};
return (
<View style={styles.container}>
<View style={styles.formSection}>
{/* 用户名 */}
{renderInput('用户名', 'username', '3-20个字符字母数字下划线', {
maxLength: 20,
})}
{/* 昵称 */}
{renderInput('昵称', 'nickname', '2-20个字符', {
autoCapitalize: 'sentences',
maxLength: 20,
})}
{/* 手机号(选填) */}
{renderInput('手机号(选填)', 'phone', '请输入手机号', {
keyboardType: 'phone-pad',
maxLength: 11,
})}
{/* 密码 */}
{renderInput('密码', 'password', '至少8位含大小写字母和数字', {
secureTextEntry: true,
showToggle: true,
toggleValue: showPassword,
onToggle: () => setShowPassword(!showPassword),
})}
{/* 确认密码 */}
{renderInput('确认密码', 'confirmPassword', '再次输入密码', {
secureTextEntry: true,
showToggle: true,
toggleValue: showConfirmPassword,
onToggle: () => setShowConfirmPassword(!showConfirmPassword),
})}
{/* 服务条款 */}
<View style={styles.termsContainer}>
<TouchableOpacity
onPress={() => {
setAgreedToTerms(!agreedToTerms);
if (errors.terms) {
setErrors((prev) => ({ ...prev, terms: '' }));
}
}}
activeOpacity={0.8}
style={styles.checkboxWrapper}
>
<MaterialCommunityIcons
name={agreedToTerms ? 'checkbox-marked' : 'checkbox-blank-outline'}
size={22}
color={agreedToTerms ? THEME_COLORS.primary : colors.text?.hint || '#999'}
/>
</TouchableOpacity>
<Text style={styles.termsText}>
<Text
style={styles.termsLink}
onPress={() => router.push(hrefs.hrefProfileTerms())}
>
</Text>
<Text
style={styles.termsLink}
onPress={() => router.push(hrefs.hrefProfilePrivacy())}
>
</Text>
</Text>
</View>
{errors.terms ? (
<Text style={[styles.errorText, { marginTop: -12, marginBottom: 12 }]}>
{errors.terms}
</Text>
) : null}
{/* 完成注册按钮 */}
<TouchableOpacity
style={[styles.actionButton, loading && styles.actionButtonDisabled]}
onPress={handleSubmit}
disabled={loading}
activeOpacity={0.9}
>
{loading ? (
<ActivityIndicator size="small" color="#FFF" />
) : (
<Text style={styles.actionButtonText}></Text>
)}
</TouchableOpacity>
{/* 返回按钮 */}
<TouchableOpacity
style={styles.backButton}
onPress={onPrev}
activeOpacity={0.8}
disabled={loading}
>
<MaterialCommunityIcons
name="arrow-left"
size={18}
color={colors.text?.secondary || '#666'}
/>
<Text style={styles.backButtonText}></Text>
</TouchableOpacity>
</View>
</View>
);
}
function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
paddingTop: 20,
},
formSection: {
marginBottom: 24,
},
inputContainer: {
marginBottom: 16,
},
inputLabel: {
fontSize: 14,
fontWeight: '500',
color: colors.text?.secondary || '#666',
marginBottom: 8,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#F5F5F7',
borderRadius: 14,
paddingHorizontal: 18,
height: 52,
borderWidth: 1,
borderColor: 'transparent',
},
inputWrapperError: {
borderColor: colors.error?.main || '#FF4444',
},
input: {
flex: 1,
fontSize: 16,
color: colors.text?.primary || '#333',
height: 52,
},
eyeButton: {
padding: 4,
marginLeft: 4,
},
errorText: {
fontSize: 12,
color: colors.error?.main || '#FF4444',
marginTop: 6,
marginLeft: 4,
},
termsContainer: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 20,
marginTop: 4,
},
checkboxWrapper: {
paddingTop: 2,
},
termsText: {
fontSize: 14,
color: colors.text?.secondary || '#666',
marginLeft: 10,
flex: 1,
lineHeight: 22,
},
termsLink: {
color: '#FF6B35',
fontWeight: '500',
},
actionButton: {
height: 56,
borderRadius: 14,
backgroundColor: '#FF6B35',
alignItems: 'center',
justifyContent: 'center',
},
actionButtonDisabled: {
opacity: 0.7,
},
actionButtonText: {
fontSize: 17,
fontWeight: '600',
color: '#FFFFFF',
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 16,
paddingVertical: 8,
},
backButtonText: {
fontSize: 14,
color: colors.text?.secondary || '#666',
marginLeft: 6,
},
});
}
export default RegisterStep3Profile;

View File

@@ -0,0 +1,451 @@
/**
* 身份认证申请表单页面
* 用户填写认证信息并提交申请
*/
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
TextInput,
TouchableOpacity,
ScrollView,
Image,
Alert,
ActivityIndicator,
Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import { useAppColors, type AppColors } from '../../theme';
import { useVerificationStore } from '../../stores/verificationStore';
import { showPrompt } from '../../services/promptService';
import { uploadService } from '../../services/uploadService';
import type { UserIdentity } from '../../types/dto';
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
const IDENTITY_CONFIG: Record<string, { title: string; idField: string; idLabel: string }> = {
student: { title: '在校学生', idField: 'student_id', idLabel: '学号' },
teacher: { title: '教职工', idField: 'employee_id', idLabel: '工号' },
staff: { title: '工作人员', idField: 'employee_id', idLabel: '工号' },
alumni: { title: '校友', idField: 'student_id', idLabel: '学号(原)' },
};
export const VerificationFormScreen: React.FC = () => {
const colors = useAppColors();
const styles = createStyles(colors);
const router = useRouter();
const params = useLocalSearchParams<{ identity?: string }>();
const { submitVerification, isLoading } = useVerificationStore();
const [uploading, setUploading] = useState(false);
const [formData, setFormData] = useState({
real_name: '',
student_id: '',
employee_id: '',
department: '',
proof_materials: '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const identity = params.identity as UserIdentity || 'student';
const config = IDENTITY_CONFIG[identity] || IDENTITY_CONFIG.student;
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.real_name.trim()) {
newErrors.real_name = '请输入真实姓名';
}
if (config.idField === 'student_id' && !formData.student_id.trim()) {
newErrors.student_id = `请输入${config.idLabel}`;
}
if (config.idField === 'employee_id' && !formData.employee_id.trim()) {
newErrors.employee_id = `请输入${config.idLabel}`;
}
if (!formData.proof_materials) {
newErrors.proof_materials = '请上传证明材料';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handlePickImage = async () => {
try {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permissionResult.granted) {
Alert.alert('提示', '需要相册权限才能上传图片');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
setFormData({ ...formData, proof_materials: result.assets[0].uri });
if (errors.proof_materials) {
setErrors({ ...errors, proof_materials: '' });
}
}
} catch (error) {
console.error('Failed to pick image:', error);
Alert.alert('错误', '选择图片失败');
}
};
const handleSubmit = async () => {
if (!validateForm()) return;
setUploading(true);
try {
let proofMaterialsUrl = formData.proof_materials;
if (formData.proof_materials.startsWith('file://') || formData.proof_materials.startsWith('content://') || formData.proof_materials.startsWith('blob:')) {
const uploadResult = await uploadService.uploadImage({
uri: formData.proof_materials,
});
if (!uploadResult || !uploadResult.url) {
showPrompt({
title: '上传失败',
message: '证明材料图片上传失败,请重试',
type: 'error',
});
return;
}
proofMaterialsUrl = uploadResult.url;
}
const success = await submitVerification({
identity,
real_name: formData.real_name.trim(),
student_id: formData.student_id.trim() || undefined,
employee_id: formData.employee_id.trim() || undefined,
department: formData.department.trim() || undefined,
proof_materials: proofMaterialsUrl,
});
if (success) {
showPrompt({
title: '提交成功',
message: '您的认证申请已提交,请等待审核',
type: 'success',
});
router.back();
} else {
const error = useVerificationStore.getState().error;
showPrompt({
title: '提交失败',
message: error || '请稍后重试',
type: 'error',
});
}
} finally {
setUploading(false);
}
};
const renderInput = (
field: keyof typeof formData,
label: string,
placeholder: string,
options: {
required?: boolean;
keyboardType?: 'default' | 'numeric' | 'email-address';
maxLength?: number;
} = {}
) => {
const { required, keyboardType = 'default', maxLength } = options;
const error = errors[field];
return (
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}>
{label}
{required && <Text style={styles.required}> *</Text>}
</Text>
<View style={[styles.inputWrapper, error && styles.inputWrapperError]}>
<TextInput
style={styles.input}
placeholder={placeholder}
placeholderTextColor={colors.text?.hint || '#999'}
value={formData[field]}
onChangeText={(text) => {
setFormData({ ...formData, [field]: text });
if (errors[field]) {
setErrors({ ...errors, [field]: '' });
}
}}
keyboardType={keyboardType}
maxLength={maxLength}
editable={!isLoading && !uploading}
/>
</View>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
);
};
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
activeOpacity={0.8}
>
<MaterialCommunityIcons
name="arrow-left"
size={24}
color={colors.text?.primary || '#333'}
/>
</TouchableOpacity>
<Text style={styles.headerTitle}>{config.title}</Text>
<View style={styles.headerRight} />
</View>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<View style={styles.infoCard}>
<MaterialCommunityIcons
name="information-outline"
size={20}
color={THEME_COLORS.primary}
/>
<Text style={styles.infoText}>
</Text>
</View>
<View style={styles.formSection}>
{renderInput('real_name', '真实姓名', '请输入身份证上的姓名', { required: true })}
{config.idField === 'student_id' && (
renderInput('student_id', config.idLabel, `请输入${config.idLabel}`, {
required: true,
keyboardType: 'numeric',
maxLength: 20,
})
)}
{config.idField === 'employee_id' && (
renderInput('employee_id', config.idLabel, `请输入${config.idLabel}`, {
required: true,
keyboardType: 'numeric',
maxLength: 20,
})
)}
{renderInput('department', '院系/部门', '选填,如:计算机学院', {
maxLength: 50,
})}
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}>
<Text style={styles.required}> *</Text>
</Text>
<Text style={styles.hintText}>
</Text>
<TouchableOpacity
style={[styles.uploadButton, errors.proof_materials && styles.uploadButtonError]}
onPress={handlePickImage}
activeOpacity={0.8}
disabled={isLoading || uploading}
>
{formData.proof_materials ? (
<Image
source={{ uri: formData.proof_materials }}
style={styles.previewImage}
resizeMode="cover"
/>
) : (
<View style={styles.uploadPlaceholder}>
<MaterialCommunityIcons
name="camera-plus-outline"
size={40}
color={colors.text?.hint || '#999'}
/>
<Text style={styles.uploadText}></Text>
</View>
)}
</TouchableOpacity>
{errors.proof_materials && (
<Text style={styles.errorText}>{errors.proof_materials}</Text>
)}
</View>
</View>
<TouchableOpacity
style={[styles.submitButton, (isLoading || uploading) && styles.submitButtonDisabled]}
onPress={handleSubmit}
activeOpacity={0.9}
disabled={isLoading || uploading}
>
{(isLoading || uploading) ? (
<ActivityIndicator size="small" color="#FFF" />
) : (
<Text style={styles.submitButtonText}></Text>
)}
</TouchableOpacity>
</ScrollView>
</SafeAreaView>
);
};
function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider || '#E0E0E0',
},
backButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
headerTitle: {
fontSize: 18,
fontWeight: '600',
color: colors.text?.primary || '#333',
},
headerRight: {
width: 40,
},
scrollContent: {
flexGrow: 1,
padding: 24,
},
infoCard: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: '#FFF5F0',
borderRadius: 12,
padding: 16,
marginBottom: 24,
},
infoText: {
flex: 1,
fontSize: 14,
color: colors.text?.secondary || '#666',
marginLeft: 8,
lineHeight: 20,
},
formSection: {
marginBottom: 24,
},
inputContainer: {
marginBottom: 20,
},
inputLabel: {
fontSize: 15,
fontWeight: '500',
color: colors.text?.primary || '#333',
marginBottom: 8,
},
required: {
color: THEME_COLORS.primary,
},
hintText: {
fontSize: 13,
color: colors.text?.hint || '#999',
marginBottom: 8,
},
inputWrapper: {
backgroundColor: '#F5F5F7',
borderRadius: 12,
paddingHorizontal: 16,
height: 50,
borderWidth: 1,
borderColor: 'transparent',
},
inputWrapperError: {
borderColor: colors.error?.main || '#FF4444',
},
input: {
flex: 1,
fontSize: 16,
color: colors.text?.primary || '#333',
height: 50,
},
errorText: {
fontSize: 12,
color: colors.error?.main || '#FF4444',
marginTop: 6,
},
uploadButton: {
backgroundColor: '#F5F5F7',
borderRadius: 12,
overflow: 'hidden',
minHeight: 200,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: 'transparent',
},
uploadButtonError: {
borderColor: colors.error?.main || '#FF4444',
},
uploadPlaceholder: {
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
uploadText: {
fontSize: 14,
color: colors.text?.hint || '#999',
marginTop: 12,
},
previewImage: {
width: '100%',
height: 200,
},
submitButton: {
height: 56,
borderRadius: 14,
backgroundColor: THEME_COLORS.primary,
justifyContent: 'center',
alignItems: 'center',
},
submitButtonDisabled: {
opacity: 0.7,
},
submitButtonText: {
fontSize: 17,
fontWeight: '600',
color: '#FFFFFF',
},
});
}
export default VerificationFormScreen;

View File

@@ -0,0 +1,335 @@
/**
* 注册后身份认证引导页面
* 可跳过的认证界面,引导用户完成身份认证
*/
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ScrollView,
Image,
Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors, type AppColors } from '../../theme';
import * as hrefs from '../../navigation/hrefs';
const THEME_COLORS = {
primary: '#FF6B35',
primaryLight: '#FF8C5A',
primaryDark: '#E55A2B',
};
// 身份类型选项
const IDENTITY_OPTIONS = [
{
key: 'student',
title: '在校学生',
description: '需要提交学生证或校园卡照片',
icon: 'school-outline',
},
{
key: 'teacher',
title: '教职工',
description: '需要提交工作证或教师资格证',
icon: 'account-tie',
},
{
key: 'staff',
title: '工作人员',
description: '需要提交工作证或在职证明',
icon: 'badge-account-outline',
},
{
key: 'alumni',
title: '校友',
description: '需要提交毕业证或校友卡',
icon: 'account-school-outline',
},
];
export const VerificationGuideScreen: React.FC = () => {
const colors = useAppColors();
const styles = createStyles(colors);
const router = useRouter();
const [selectedIdentity, setSelectedIdentity] = useState<string | null>(null);
const handleSkip = () => {
Alert.alert(
'跳过认证',
'跳过后您将以普通用户身份使用,部分功能可能受限。您可以在设置中随时完成认证。',
[
{ text: '继续认证', style: 'cancel' },
{
text: '跳过',
style: 'default',
onPress: () => {
router.replace(hrefs.hrefHome());
},
},
]
);
};
const handleContinue = () => {
if (!selectedIdentity) {
Alert.alert('提示', '请选择您的身份类型');
return;
}
// 导航到认证表单页面
router.push(hrefs.hrefVerificationForm(selectedIdentity));
};
const renderIdentityCard = (option: typeof IDENTITY_OPTIONS[0]) => {
const isSelected = selectedIdentity === option.key;
return (
<TouchableOpacity
key={option.key}
style={[styles.identityCard, isSelected && styles.identityCardSelected]}
onPress={() => setSelectedIdentity(option.key)}
activeOpacity={0.8}
>
<View style={styles.identityIconContainer}>
<MaterialCommunityIcons
name={option.icon as any}
size={32}
color={isSelected ? THEME_COLORS.primary : colors.text?.secondary || '#666'}
/>
</View>
<View style={styles.identityContent}>
<Text style={[styles.identityTitle, isSelected && styles.identityTitleSelected]}>
{option.title}
</Text>
<Text style={styles.identityDescription}>{option.description}</Text>
</View>
<View style={styles.checkContainer}>
{isSelected && (
<MaterialCommunityIcons
name="check-circle"
size={24}
color={THEME_COLORS.primary}
/>
)}
</View>
</TouchableOpacity>
);
};
return (
<SafeAreaView style={styles.container}>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* 头部图标 */}
<View style={styles.header}>
<View style={styles.iconCircle}>
<MaterialCommunityIcons
name="shield-check-outline"
size={48}
color={THEME_COLORS.primary}
/>
</View>
<Text style={styles.title}></Text>
<Text style={styles.subtitle}>
</Text>
</View>
{/* 认证优势 */}
<View style={styles.benefitsContainer}>
<View style={styles.benefitItem}>
<MaterialCommunityIcons
name="check-decagram"
size={20}
color={THEME_COLORS.primary}
/>
<Text style={styles.benefitText}></Text>
</View>
<View style={styles.benefitItem}>
<MaterialCommunityIcons
name="lock-open-outline"
size={20}
color={THEME_COLORS.primary}
/>
<Text style={styles.benefitText}></Text>
</View>
<View style={styles.benefitItem}>
<MaterialCommunityIcons
name="account-group-outline"
size={20}
color={THEME_COLORS.primary}
/>
<Text style={styles.benefitText}></Text>
</View>
</View>
{/* 身份选择 */}
<View style={styles.sectionContainer}>
<Text style={styles.sectionTitle}></Text>
{IDENTITY_OPTIONS.map(renderIdentityCard)}
</View>
{/* 按钮区域 */}
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[styles.continueButton, !selectedIdentity && styles.continueButtonDisabled]}
onPress={handleContinue}
activeOpacity={0.9}
>
<Text style={styles.continueButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.skipButton} onPress={handleSkip} activeOpacity={0.8}>
<Text style={styles.skipButtonText}></Text>
</TouchableOpacity>
</View>
</ScrollView>
</SafeAreaView>
);
};
function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
scrollContent: {
flexGrow: 1,
paddingHorizontal: 24,
paddingTop: 40,
paddingBottom: 40,
},
header: {
alignItems: 'center',
marginBottom: 32,
},
iconCircle: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: '#FFF5F0',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 20,
},
title: {
fontSize: 28,
fontWeight: '700',
color: colors.text?.primary || '#333',
marginBottom: 8,
},
subtitle: {
fontSize: 15,
color: colors.text?.secondary || '#666',
textAlign: 'center',
},
benefitsContainer: {
flexDirection: 'row',
justifyContent: 'center',
gap: 16,
marginBottom: 40,
},
benefitItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
benefitText: {
fontSize: 13,
color: colors.text?.secondary || '#666',
},
sectionContainer: {
marginBottom: 32,
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
color: colors.text?.primary || '#333',
marginBottom: 16,
},
identityCard: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
backgroundColor: '#F5F5F7',
borderRadius: 14,
marginBottom: 12,
borderWidth: 2,
borderColor: 'transparent',
},
identityCardSelected: {
backgroundColor: '#FFF5F0',
borderColor: THEME_COLORS.primary,
},
identityIconContainer: {
width: 48,
height: 48,
justifyContent: 'center',
alignItems: 'center',
marginRight: 16,
},
identityContent: {
flex: 1,
},
identityTitle: {
fontSize: 16,
fontWeight: '600',
color: colors.text?.primary || '#333',
marginBottom: 4,
},
identityTitleSelected: {
color: THEME_COLORS.primary,
},
identityDescription: {
fontSize: 13,
color: colors.text?.secondary || '#666',
},
checkContainer: {
width: 24,
height: 24,
justifyContent: 'center',
alignItems: 'center',
},
buttonContainer: {
gap: 12,
},
continueButton: {
height: 56,
borderRadius: 14,
backgroundColor: THEME_COLORS.primary,
justifyContent: 'center',
alignItems: 'center',
},
continueButtonDisabled: {
backgroundColor: '#ccc',
},
continueButtonText: {
fontSize: 17,
fontWeight: '600',
color: '#fff',
},
skipButton: {
height: 56,
borderRadius: 14,
backgroundColor: 'transparent',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: colors.divider || '#E0E0E0',
},
skipButtonText: {
fontSize: 17,
fontWeight: '500',
color: colors.text?.secondary || '#666',
},
});
}
export default VerificationGuideScreen;

View File

@@ -0,0 +1,265 @@
/**
* 欢迎页 WelcomeScreen
* 胡萝卜BBS - 用户欢迎页
*
* 亮色模式:白色主色调 + 胡萝卜橙辅色
* 暗色模式:深色背景 + 胡萝卜橙辅色
*/
import React, { useEffect, useRef, useMemo } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Animated,
StatusBar,
Dimensions,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { useAppColors, useResolvedColorScheme, type AppColors } from '../../theme';
import * as hrefs from '../../navigation/hrefs';
const { width, height } = Dimensions.get('window');
export const WelcomeScreen: React.FC = () => {
const router = useRouter();
const colors = useAppColors();
const resolved = useResolvedColorScheme();
const styles = useMemo(() => createWelcomeStyles(colors), [colors]);
const isDark = resolved === 'dark';
// 动画值
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideUpAnim = useRef(new Animated.Value(50)).current;
const scaleAnim = useRef(new Animated.Value(0.9)).current;
useEffect(() => {
// 启动入场动画
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(slideUpAnim, {
toValue: 0,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(scaleAnim, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
]).start();
}, []);
const handleSignIn = () => {
router.push(hrefs.hrefAuthLogin());
};
const handleCreateAccount = () => {
router.push(hrefs.hrefAuthRegister());
};
return (
<View style={styles.container}>
<StatusBar barStyle={isDark ? 'light-content' : 'dark-content'} />
{/* 装饰性背景元素 */}
<View style={styles.decorCircle1} />
<View style={styles.decorCircle2} />
<View style={styles.decorCircle3} />
<SafeAreaView style={styles.safeArea}>
<Animated.View
style={[
styles.content,
{
opacity: fadeAnim,
transform: [
{ translateY: slideUpAnim },
{ scale: scaleAnim },
],
},
]}
>
{/* 品牌区域 */}
<View style={styles.brandSection}>
<Text style={styles.welcomeEn}>Welcome To</Text>
<Text style={styles.brandName}></Text>
<View style={styles.brandUnderline} />
<Text style={styles.subtitle}>
{'\n'}
</Text>
</View>
{/* 按钮区域 */}
<View style={styles.buttonSection}>
<TouchableOpacity
style={styles.signInButton}
onPress={handleSignIn}
activeOpacity={0.8}
>
<Text style={styles.signInButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.createAccountButton}
onPress={handleCreateAccount}
activeOpacity={0.8}
>
<Text style={styles.createAccountButtonText}></Text>
</TouchableOpacity>
</View>
</Animated.View>
</SafeAreaView>
</View>
);
};
function createWelcomeStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
safeArea: {
flex: 1,
},
content: {
flex: 1,
justifyContent: 'space-between',
paddingHorizontal: 32,
paddingTop: 60,
paddingBottom: 40,
},
// 装饰性背景元素 - 使用胡萝卜橙的淡色
decorCircle1: {
position: 'absolute',
top: -100,
right: -100,
width: 400,
height: 400,
borderRadius: 200,
backgroundColor: colors.primary.main + '08', // 非常淡的胡萝卜橙
},
decorCircle2: {
position: 'absolute',
top: height * 0.3,
left: -80,
width: 200,
height: 200,
borderRadius: 100,
backgroundColor: colors.primary.main + '10',
},
decorCircle3: {
position: 'absolute',
bottom: -50,
right: -50,
width: 250,
height: 250,
borderRadius: 125,
backgroundColor: colors.primary.main + '08',
},
// 品牌区域
brandSection: {
alignItems: 'center',
},
badge: {
backgroundColor: colors.primary.main + '15',
paddingHorizontal: 20,
paddingVertical: 8,
borderRadius: 20,
marginBottom: 24,
},
badgeText: {
color: colors.primary.main,
fontSize: 14,
fontWeight: '600',
letterSpacing: 1,
},
welcomeEn: {
fontSize: 24,
fontWeight: '400',
color: colors.text.secondary,
marginBottom: 4,
letterSpacing: 1,
},
brandName: {
fontSize: 42,
fontWeight: '800',
color: colors.text.primary,
letterSpacing: 4,
marginBottom: 8,
},
brandUnderline: {
width: 80,
height: 4,
backgroundColor: colors.primary.main,
borderRadius: 2,
marginBottom: 24,
},
title: {
fontSize: 48,
fontWeight: '800',
color: colors.text.primary,
marginBottom: 16,
letterSpacing: 2,
},
subtitle: {
fontSize: 16,
color: colors.text.secondary,
textAlign: 'center',
lineHeight: 24,
letterSpacing: 0.5,
},
// 按钮区域
buttonSection: {
width: '100%',
gap: 16,
},
signInButton: {
backgroundColor: colors.primary.main,
borderRadius: 16,
height: 56,
alignItems: 'center',
justifyContent: 'center',
shadowColor: colors.primary.main,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 5,
},
signInButtonText: {
color: colors.primary.contrast,
fontSize: 16,
fontWeight: '700',
letterSpacing: 1,
},
createAccountButton: {
backgroundColor: 'transparent',
borderRadius: 16,
height: 56,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: colors.primary.main,
},
createAccountButtonText: {
color: colors.primary.main,
fontSize: 16,
fontWeight: '700',
letterSpacing: 1,
},
});
}
export default WelcomeScreen;

View File

@@ -6,3 +6,12 @@
export { LoginScreen } from './LoginScreen'; export { LoginScreen } from './LoginScreen';
export { RegisterScreen } from './RegisterScreen'; export { RegisterScreen } from './RegisterScreen';
export { ForgotPasswordScreen } from './ForgotPasswordScreen'; export { ForgotPasswordScreen } from './ForgotPasswordScreen';
export { WelcomeScreen } from './WelcomeScreen';
export { VerificationGuideScreen } from './VerificationGuideScreen';
export { VerificationFormScreen } from './VerificationFormScreen';
// 注册步骤组件
export { RegisterStep1Email } from './RegisterStep1Email';
export { RegisterStep2Verification } from './RegisterStep2Verification';
export { RegisterStep3Profile } from './RegisterStep3Profile';
export type { RegisterFormData, RegisterStepProps, RegisterStep } from './types';

41
src/screens/auth/types.ts Normal file
View File

@@ -0,0 +1,41 @@
/**
* 注册流程类型定义
*/
import { AppColors } from '../../theme';
export interface RegisterFormData {
email: string;
verificationCode: string;
username: string;
nickname: string;
phone: string;
password: string;
confirmPassword: string;
}
export interface RegisterStepProps {
formData: RegisterFormData;
updateFormData: (data: Partial<RegisterFormData>) => void;
onNext: () => void;
onPrev?: () => void;
colors?: AppColors;
// 状态
loading?: boolean;
sendingCode?: boolean;
countdown?: number;
// 回调
onSendCode?: (email: string) => Promise<boolean>;
}
export type RegisterStep = 0 | 1 | 2;
export interface StepConfig {
id: RegisterStep;
title: string;
component: React.ComponentType<RegisterStepProps>;
}
// 确保文件被识别为模块
export {};

View File

@@ -388,7 +388,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
content: content.trim(), content: content.trim(),
images: imageUrls, images: imageUrls,
channel_id: selectedChannelId || undefined, channel_id: selectedChannelId || undefined,
vote_options: validOptions, vote_options: validOptions.map(opt => ({ content: opt })),
}); });
showPrompt({ showPrompt({
type: 'info', type: 'info',

View File

@@ -23,10 +23,10 @@ import {
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
import { useRouter, useFocusEffect } from 'expo-router'; import { useRouter, useFocusEffect } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { PagerView } from '../../components/common';
import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme'; import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme';
import { Post } from '../../types'; import { Post } from '../../types';
import { useUserStore, useHomeTabBarVisibilityStore } from '../../stores'; import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores';
import { useCurrentUser } from '../../stores/authStore'; import { useCurrentUser } from '../../stores/authStore';
import { channelService, postService } from '../../services'; import { channelService, postService } from '../../services';
import { PostCard, TabBar, SearchBar } from '../../components/business'; import { PostCard, TabBar, SearchBar } from '../../components/business';
@@ -42,8 +42,6 @@ import * as hrefs from '../../navigation/hrefs';
const TABS = ['关注', '最新', '热门']; const TABS = ['关注', '最新', '热门'];
const TAB_ICONS = ['account-heart-outline', 'clock-outline', 'fire']; const TAB_ICONS = ['account-heart-outline', 'clock-outline', 'fire'];
const DEFAULT_PAGE_SIZE = 20; const DEFAULT_PAGE_SIZE = 20;
const SWIPE_TRANSLATION_THRESHOLD = 40;
const SWIPE_COOLDOWN_MS = 300;
const MOBILE_TAB_BAR_HEIGHT = 64; const MOBILE_TAB_BAR_HEIGHT = 64;
const MOBILE_TAB_FLOATING_MARGIN = 12; const MOBILE_TAB_FLOATING_MARGIN = 12;
const MOBILE_FAB_GAP = 12; const MOBILE_FAB_GAP = 12;
@@ -67,7 +65,7 @@ function createHomeStyles(colors: AppColors) {
}, },
searchWrapper: { searchWrapper: {
paddingTop: spacing.lg, paddingTop: spacing.lg,
paddingBottom: spacing.sm, paddingBottom: spacing.xs,
shadowColor: 'transparent', shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 }, shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0, shadowOpacity: 0,
@@ -75,8 +73,8 @@ function createHomeStyles(colors: AppColors) {
elevation: 0, elevation: 0,
}, },
homeTabBar: { homeTabBar: {
marginTop: spacing.xs, marginTop: spacing.sm,
marginBottom: spacing.sm, marginBottom: spacing.md,
}, },
viewToggleBtn: { viewToggleBtn: {
width: 44, width: 44,
@@ -204,17 +202,31 @@ export const HomeScreen: React.FC = () => {
// 发帖弹窗状态 // 发帖弹窗状态
const [showCreatePost, setShowCreatePost] = useState(false); const [showCreatePost, setShowCreatePost] = useState(false);
// 用于跟踪当前页面显示的帖子 ID以便从 store 同步状态 useEffect(() => {
if (showCreatePost && Platform.OS === 'web') {
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
active?.blur?.();
}
}, [showCreatePost]);
// 用于跟踪当前页面显示的帖子 ID以便从 store 同步状态
const postIdsRef = useRef<Set<string>>(new Set()); const postIdsRef = useRef<Set<string>>(new Set());
const lastSwipeAtRef = useRef(0);
const isLoadingMoreRef = useRef(false); const isLoadingMoreRef = useRef(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const pagerRef = useRef<any>(null);
const setBottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.setBottomTabBarHiddenByScroll); const setBottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.setBottomTabBarHiddenByScroll);
const bottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll); const bottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
const homeTabPressCount = useHomeTabPressStore((s) => s.pressCount);
const homeListScrollYRef = useRef(0); const homeListScrollYRef = useRef(0);
const tabBarIdleRestoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const tabBarIdleRestoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// FlatList 和 ScrollView 的 ref用于滚动到顶部
const flatListRef = useRef<FlatList<Post> | null>(null);
const scrollViewRef = useRef<ScrollView | null>(null);
const clearTabBarIdleRestoreTimer = useCallback(() => { const clearTabBarIdleRestoreTimer = useCallback(() => {
if (tabBarIdleRestoreTimerRef.current != null) { if (tabBarIdleRestoreTimerRef.current != null) {
clearTimeout(tabBarIdleRestoreTimerRef.current); clearTimeout(tabBarIdleRestoreTimerRef.current);
@@ -269,6 +281,19 @@ export const HomeScreen: React.FC = () => {
} }
}, [showSearch, clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll]); }, [showSearch, clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll]);
// 监听首页 Tab 点击事件,滚动到顶部
useEffect(() => {
if (homeTabPressCount > 0) {
// 滚动 FlatList 到顶部
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
// 滚动 ScrollView 到顶部(网格模式)
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
// 重置底部 Tab 栏状态
setBottomTabBarHiddenByScroll(false);
homeListScrollYRef.current = 0;
}
}, [homeTabPressCount, setBottomTabBarHiddenByScroll]);
/** 横向胶囊条滚动位置:切换频道刷新列表时不重置 */ /** 横向胶囊条滚动位置:切换频道刷新列表时不重置 */
const capsuleHScrollRef = useRef<ScrollView | null>(null); const capsuleHScrollRef = useRef<ScrollView | null>(null);
const capsuleScrollXRef = useRef(0); const capsuleScrollXRef = useRef(0);
@@ -480,12 +505,22 @@ export const HomeScreen: React.FC = () => {
setViewMode(prev => prev === 'list' ? 'grid' : 'list'); setViewMode(prev => prev === 'list' ? 'grid' : 'list');
}; };
// 切换Tab手势/点击共用 // 切换Tab点击 TabBar 或手势滑动
const changeTab = useCallback((nextIndex: number) => { const changeTab = useCallback((nextIndex: number) => {
if (nextIndex < 0 || nextIndex >= TABS.length || nextIndex === activeIndex) { if (nextIndex < 0 || nextIndex >= TABS.length || nextIndex === activeIndex) {
return; return;
} }
setActiveIndex(nextIndex); setActiveIndex(nextIndex);
if (Platform.OS !== 'web') {
pagerRef.current?.setPage(nextIndex);
}
}, [activeIndex]);
const onPagerPageSelected = useCallback((e: { nativeEvent: { position: number } }) => {
const nextIndex = e.nativeEvent.position;
if (nextIndex !== activeIndex) {
setActiveIndex(nextIndex);
}
}, [activeIndex]); }, [activeIndex]);
useEffect(() => { useEffect(() => {
@@ -500,35 +535,6 @@ export const HomeScreen: React.FC = () => {
loadChannels(); loadChannels();
}, []); }, []);
const handleSwipeTabChange = useCallback((translationX: number) => {
setActiveIndex(prev => (
translationX < 0
? Math.min(prev + 1, TABS.length - 1)
: Math.max(prev - 1, 0)
));
}, []);
const swipeGesture = useMemo(() => (
Gesture.Pan()
.runOnJS(true)
.activeOffsetX([-15, 15])
.failOffsetY([-20, 20])
.shouldCancelWhenOutside(true)
.onEnd((event) => {
const now = Date.now();
if (now - lastSwipeAtRef.current < SWIPE_COOLDOWN_MS) {
return;
}
if (Math.abs(event.translationX) < SWIPE_TRANSLATION_THRESHOLD) {
return;
}
lastSwipeAtRef.current = now;
handleSwipeTabChange(event.translationX);
})
), [handleSwipeTabChange]);
// 跳转到搜索页(使用内嵌模式,不再依赖导航) // 跳转到搜索页(使用内嵌模式,不再依赖导航)
const handleSearchPress = () => { const handleSearchPress = () => {
setShowSearch(true); setShowSearch(true);
@@ -581,7 +587,7 @@ export const HomeScreen: React.FC = () => {
} catch (shareError) { } catch (shareError) {
console.error('上报分享次数失败:', shareError); console.error('上报分享次数失败:', shareError);
} }
const postUrl = `https://browser.littlelan.cn/posts/${encodeURIComponent(post.id)}`; const postUrl = `https://browser.littlelan.cn/post/${encodeURIComponent(post.id)}`;
Clipboard.setString(postUrl); Clipboard.setString(postUrl);
Alert.alert('已复制', '帖子链接已复制到剪贴板'); Alert.alert('已复制', '帖子链接已复制到剪贴板');
}; };
@@ -819,6 +825,7 @@ export const HomeScreen: React.FC = () => {
// 移动端使用瀑布流布局2列 // 移动端使用瀑布流布局2列
return ( return (
<ScrollView <ScrollView
ref={scrollViewRef}
style={styles.waterfallScroll} style={styles.waterfallScroll}
contentContainerStyle={[ contentContainerStyle={[
styles.waterfallContainer, styles.waterfallContainer,
@@ -899,6 +906,7 @@ export const HomeScreen: React.FC = () => {
// 移动端和宽屏都使用单列 FlatList宽屏下居中显示 // 移动端和宽屏都使用单列 FlatList宽屏下居中显示
return ( return (
<FlatList <FlatList
ref={flatListRef}
data={displayPosts} data={displayPosts}
renderItem={renderPostList} renderItem={renderPostList}
keyExtractor={keyExtractor} keyExtractor={keyExtractor}
@@ -987,32 +995,52 @@ export const HomeScreen: React.FC = () => {
</View> </View>
{/* 帖子列表 */} {/* 帖子列表 */}
{isMobile ? ( {Platform.OS === 'web' ? (
// 移动端:使用 GestureDetector 支持手势切换 Tab
<GestureDetector gesture={swipeGesture}>
<View style={styles.contentContainer}> <View style={styles.contentContainer}>
{isInitialLoading ? ( {isInitialLoading ? (
<Loading fullScreen /> <Loading fullScreen />
) : viewMode === 'list' ? ( ) : viewMode === 'list' ? (
renderListContent() renderListContent()
) : ( ) : (
// 网格模式:使用响应式网格
renderResponsiveGrid() renderResponsiveGrid()
)} )}
</View> </View>
</GestureDetector>
) : ( ) : (
// 桌面端/平板端:不使用 GestureDetector避免拦截侧边栏点击 <PagerView
<View style={styles.contentContainer}> ref={pagerRef}
{isInitialLoading ? ( style={styles.contentContainer}
initialPage={activeIndex}
onPageSelected={onPagerPageSelected}
overdrag
>
<View key="follow" collapsable={false} style={styles.contentContainer}>
{isInitialLoading && activeIndex === 0 ? (
<Loading fullScreen /> <Loading fullScreen />
) : viewMode === 'list' ? ( ) : activeIndex === 0 ? (
renderListContent() viewMode === 'list' ? renderListContent() : renderResponsiveGrid()
) : ( ) : (
// 网格模式:使用响应式网格 <View style={styles.contentContainer} />
renderResponsiveGrid()
)} )}
</View> </View>
<View key="latest" collapsable={false} style={styles.contentContainer}>
{isInitialLoading && activeIndex === 1 ? (
<Loading fullScreen />
) : activeIndex === 1 ? (
viewMode === 'list' ? renderListContent() : renderResponsiveGrid()
) : (
<View style={styles.contentContainer} />
)}
</View>
<View key="hot" collapsable={false} style={styles.contentContainer}>
{isInitialLoading && activeIndex === 2 ? (
<Loading fullScreen />
) : activeIndex === 2 ? (
viewMode === 'list' ? renderListContent() : renderResponsiveGrid()
) : (
<View style={styles.contentContainer} />
)}
</View>
</PagerView>
)} )}
{/* 漂浮发帖按钮 */} {/* 漂浮发帖按钮 */}

View File

@@ -33,13 +33,13 @@ import {
useAppColors, useAppColors,
type AppColors, type AppColors,
} from '../../theme'; } from '../../theme';
import { Post, Comment, VoteResultDTO } from '../../types'; import { Post, Comment, VoteResultDTO, VoteOptionDTO } from '../../types';
import { useUserStore } from '../../stores'; import { useUserStore } from '../../stores';
import { useCurrentUser } from '../../stores/authStore'; import { useCurrentUser } from '../../stores/authStore';
import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services'; import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services';
import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase'; import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase';
import { useCursorPagination } from '../../hooks/useCursorPagination'; import { useCursorPagination } from '../../hooks/useCursorPagination';
import { CommentItem, VoteCard } from '../../components/business'; import { CommentItem, VoteCard, ReportDialog } from '../../components/business';
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common'; import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common';
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive'; import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive';
import * as hrefs from '../../navigation/hrefs'; import * as hrefs from '../../navigation/hrefs';
@@ -177,6 +177,10 @@ export const PostDetailScreen: React.FC = () => {
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
const hasRecordedView = useRef(false); // 是否已记录浏览量 const hasRecordedView = useRef(false); // 是否已记录浏览量
// 举报相关状态
const [reportDialogVisible, setReportDialogVisible] = useState(false);
const [reportTarget, setReportTarget] = useState<{ type: 'post' | 'comment'; id: string } | null>(null);
// 投票相关状态 // 投票相关状态
const [voteResult, setVoteResult] = useState<VoteResultDTO | null>(null); const [voteResult, setVoteResult] = useState<VoteResultDTO | null>(null);
const [isVoteLoading, setIsVoteLoading] = useState(false); const [isVoteLoading, setIsVoteLoading] = useState(false);
@@ -532,7 +536,7 @@ export const PostDetailScreen: React.FC = () => {
} catch (error) { } catch (error) {
console.error('上报分享次数失败:', error); console.error('上报分享次数失败:', error);
} }
const postUrl = `https://browser.littlelan.cn/posts/${encodeURIComponent(post.id)}`; const postUrl = `https://browser.littlelan.cn/post/${encodeURIComponent(post.id)}`;
Clipboard.setString(postUrl); Clipboard.setString(postUrl);
Alert.alert('已复制', '帖子链接已复制到剪贴板'); Alert.alert('已复制', '帖子链接已复制到剪贴板');
}, [post?.id]); }, [post?.id]);
@@ -545,14 +549,14 @@ export const PostDetailScreen: React.FC = () => {
const oldVoteResult = { ...voteResult }; const oldVoteResult = { ...voteResult };
// 乐观更新 // 乐观更新
setVoteResult(prev => { setVoteResult((prev: VoteResultDTO | null) => {
if (!prev) return null; if (!prev) return null;
return { return {
...prev, ...prev,
has_voted: true, has_voted: true,
voted_option_id: optionId, voted_option_id: optionId,
total_votes: prev.total_votes + 1, total_votes: prev.total_votes + 1,
options: prev.options.map(opt => options: prev.options.map((opt: VoteOptionDTO) =>
opt.id === optionId opt.id === optionId
? { ...opt, votes_count: opt.votes_count + 1 } ? { ...opt, votes_count: opt.votes_count + 1 }
: opt : opt
@@ -587,14 +591,14 @@ export const PostDetailScreen: React.FC = () => {
const oldVoteResult = { ...voteResult }; const oldVoteResult = { ...voteResult };
// 乐观更新 // 乐观更新
setVoteResult(prev => { setVoteResult((prev: VoteResultDTO | null) => {
if (!prev) return null; if (!prev) return null;
return { return {
...prev, ...prev,
has_voted: false, has_voted: false,
voted_option_id: undefined, voted_option_id: undefined,
total_votes: Math.max(0, prev.total_votes - 1), total_votes: Math.max(0, prev.total_votes - 1),
options: prev.options.map(opt => options: prev.options.map((opt: VoteOptionDTO) =>
opt.id === votedOptionId opt.id === votedOptionId
? { ...opt, votes_count: Math.max(0, opt.votes_count - 1) } ? { ...opt, votes_count: Math.max(0, opt.votes_count - 1) }
: opt : opt
@@ -895,61 +899,48 @@ export const PostDetailScreen: React.FC = () => {
} }
}; };
// 点赞评论(包括回复) // 点赞评论(包括回复)- 优化版
const handleLikeComment = async (comment: Comment) => { const handleLikeComment = async (comment: Comment) => {
// 先保存旧状态用于回滚 const { id, is_liked, likes_count } = comment;
const oldIsLiked = comment.is_liked;
const oldLikesCount = comment.likes_count;
// 乐观更新本地状态 - 辅助函数用于更新评论或回复 // 乐观更新:切换点赞状态
const updateCommentLike = (c: Comment): Comment => { const newIsLiked = !is_liked;
// 如果是目标评论/回复 const newLikesCount = newIsLiked ? likes_count + 1 : Math.max(0, likes_count - 1);
if (c.id === comment.id) {
return { // 递归更新评论树中的点赞状态
...c, const updateLikeInTree = (c: Comment): Comment => {
is_liked: !c.is_liked, if (c.id === id) {
likes_count: c.is_liked ? c.likes_count - 1 : c.likes_count + 1 return { ...c, is_liked: newIsLiked, likes_count: newLikesCount };
};
} }
// 如果有回复,递归查找 if (c.replies?.length) {
if (c.replies && c.replies.length > 0) { return { ...c, replies: c.replies.map(r => updateLikeInTree(r)) };
return {
...c,
replies: c.replies.map(r => updateCommentLike(r))
};
} }
return c; return c;
}; };
// 更新本地状态 setComments(prev => prev.map(c => updateLikeInTree(c)));
setComments(prev => prev.map(c => updateCommentLike(c)));
try { try {
if (oldIsLiked) { const success = newIsLiked
await commentService.unlikeComment(comment.id); ? await commentService.likeComment(id)
} else { : await commentService.unlikeComment(id);
await commentService.likeComment(comment.id);
if (!success) {
throw new Error('点赞操作失败');
} }
} catch (error) { } catch (error) {
console.error('评论点赞操作失败:', error); console.error('评论点赞操作失败:', error);
// 失败时回滚状态 // 回滚状态
const rollbackCommentLike = (c: Comment): Comment => { const rollbackLikeInTree = (c: Comment): Comment => {
if (c.id === comment.id) { if (c.id === id) {
return { return { ...c, is_liked, likes_count };
...c,
is_liked: oldIsLiked,
likes_count: oldLikesCount
};
} }
if (c.replies && c.replies.length > 0) { if (c.replies?.length) {
return { return { ...c, replies: c.replies.map(r => rollbackLikeInTree(r)) };
...c,
replies: c.replies.map(r => rollbackCommentLike(r))
};
} }
return c; return c;
}; };
setComments(prev => prev.map(c => rollbackCommentLike(c))); setComments(prev => prev.map(c => rollbackLikeInTree(c)));
} }
}; };
@@ -1256,6 +1247,25 @@ export const PostDetailScreen: React.FC = () => {
</TouchableOpacity> </TouchableOpacity>
</View> </View>
)} )}
{/* 举报按钮 - 只对非帖子作者显示 */}
{currentUser?.id !== post.author?.id && (
<TouchableOpacity
style={styles.reportButtonInline}
onPress={() => {
setReportTarget({ type: 'post', id: post.id });
setReportDialogVisible(true);
}}
>
<MaterialCommunityIcons
name="flag-outline"
size={14}
color={colors.text.hint}
/>
<Text variant="caption" color={colors.text.hint} style={styles.reportButtonText}>
</Text>
</TouchableOpacity>
)}
</View> </View>
{/* 底部操作栏 - QQ频道风格 */} {/* 底部操作栏 - QQ频道风格 */}
@@ -1360,6 +1370,12 @@ export const PostDetailScreen: React.FC = () => {
} }
}; };
// 处理举报评论
const handleReportComment = useCallback((comment: Comment) => {
setReportTarget({ type: 'comment', id: comment.id });
setReportDialogVisible(true);
}, []);
// 渲染评论 - 带楼层号 // 渲染评论 - 带楼层号
const commentKeyExtractor = useCallback((item: Comment) => item.id, []); const commentKeyExtractor = useCallback((item: Comment) => item.id, []);
@@ -1373,7 +1389,7 @@ export const PostDetailScreen: React.FC = () => {
return ( return (
<CommentItem <CommentItem
comment={item} comment={item}
onLike={() => handleLikeComment(item)} onLike={handleLikeComment}
onReply={() => handleReply(item)} onReply={() => handleReply(item)}
onUserPress={() => authorId && handleUserPress(authorId)} onUserPress={() => authorId && handleUserPress(authorId)}
floorNumber={index + 1} floorNumber={index + 1}
@@ -1385,9 +1401,10 @@ export const PostDetailScreen: React.FC = () => {
onDelete={handleDeleteComment} onDelete={handleDeleteComment}
onImagePress={handleImagePress} onImagePress={handleImagePress}
currentUserId={currentUser?.id} currentUserId={currentUser?.id}
onReport={handleReportComment}
/> />
); );
}, [currentUser?.id, handleDeleteComment, handleImagePress, handleLikeComment, handleLoadMoreReplies, post?.user_id]); }, [currentUser?.id, handleDeleteComment, handleImagePress, handleLikeComment, handleLoadMoreReplies, post?.user_id, handleReportComment]);
// 渲染空评论 - 现代化设计 // 渲染空评论 - 现代化设计
const renderEmptyComments = useCallback(() => ( const renderEmptyComments = useCallback(() => (
@@ -1709,6 +1726,20 @@ export const PostDetailScreen: React.FC = () => {
onClose={() => setShowImageModal(false)} onClose={() => setShowImageModal(false)}
enableSave enableSave
/> />
{/* 举报对话框 */}
<ReportDialog
visible={reportDialogVisible}
targetType={reportTarget?.type || 'post'}
targetId={reportTarget?.id || ''}
onClose={() => {
setReportDialogVisible(false);
setReportTarget(null);
}}
onSuccess={() => {
setReportTarget(null);
}}
/>
</SafeAreaView> </SafeAreaView>
); );
}; };
@@ -1875,6 +1906,16 @@ function createPostDetailStyles(colors: AppColors) {
marginLeft: 2, marginLeft: 2,
fontSize: fontSizes.sm, fontSize: fontSizes.sm,
}, },
reportButtonInline: {
flexDirection: 'row',
alignItems: 'center',
marginLeft: spacing.sm,
padding: spacing.xs,
},
reportButtonText: {
marginLeft: 2,
fontSize: fontSizes.sm,
},
imagesContainer: { imagesContainer: {
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap', flexWrap: 'wrap',

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