PC端的部分适配。登录,注册,首页导航栏竞态条件,搜索栏,系统消息解耦,群聊部分界面 #2
8
.idea/.gitignore
generated
vendored
Normal file
8
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
9
.idea/frontend.iml
generated
Normal file
9
.idea/frontend.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
448
.idea/gradle.xml
generated
Normal file
448
.idea/gradle.xml
generated
Normal file
@@ -0,0 +1,448 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="GradleSettings">
|
||||
<option name="linkedExternalProjectsSettings">
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@expo/dom-webview/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/@expo/dom-webview/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@expo/log-box/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/@expo/log-box/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@react-native-async-storage/async-storage/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/@react-native-async-storage/async-storage/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/@react-native/gradle-plugin" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/@react-native/gradle-plugin" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-application/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-application/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-background-fetch/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-background-fetch/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-constants/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-constants/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-constants/scripts" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-constants/scripts" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-client/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-dev-client/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-launcher/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-dev-launcher/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-launcher/expo-dev-launcher-gradle-plugin" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-dev-launcher/expo-dev-launcher-gradle-plugin" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-menu-interface/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-dev-menu-interface/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-dev-menu/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-dev-menu/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-eas-client/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-eas-client/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-file-system/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-file-system/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-font/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-font/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-haptics/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-haptics/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-image-loader/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-image-loader/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-image-picker/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-image-picker/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-image/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-image/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-json-utils/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-json-utils/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-linear-gradient/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-linear-gradient/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-linking/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-linking/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-manifests/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-manifests/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-media-library/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-media-library/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-modules-autolinking/android/expo-gradle-plugin" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-modules-autolinking/android/expo-gradle-plugin" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-modules-core/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-modules-core/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-modules-core/expo-module-gradle-plugin" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-modules-core/expo-module-gradle-plugin" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-notifications/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-notifications/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-router/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-router/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-sqlite/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-sqlite/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-structured-headers/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-structured-headers/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-system-ui/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-system-ui/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-task-manager/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-task-manager/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-updates-interface/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-updates-interface/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-updates/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-updates/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-updates/expo-updates-gradle-plugin" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-updates/expo-updates-gradle-plugin" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo-video/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo-video/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-asset/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-asset/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-keep-awake/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/expo/node_modules/expo-keep-awake/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/react-native" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-gesture-handler/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/react-native-gesture-handler/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-pager-view/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/react-native-pager-view/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-reanimated/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/react-native-reanimated/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-safe-area-context/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/react-native-safe-area-context/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-screens/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/react-native-screens/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/react-native-worklets/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/react-native-worklets/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
<GradleProjectSettings>
|
||||
<option name="externalProjectPath" value="$PROJECT_DIR$/node_modules/unimodules-app-loader/android" />
|
||||
<option name="gradleJvm" value="ms-21" />
|
||||
<option name="modules">
|
||||
<set>
|
||||
<option value="$PROJECT_DIR$/node_modules/unimodules-app-loader/android" />
|
||||
</set>
|
||||
</option>
|
||||
</GradleProjectSettings>
|
||||
</option>
|
||||
</component>
|
||||
</project>
|
||||
7
.idea/misc.xml
generated
Normal file
7
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||
<component name="ProjectRootManager">
|
||||
<output url="file://$PROJECT_DIR$/out" />
|
||||
</component>
|
||||
</project>
|
||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/frontend.iml" filepath="$PROJECT_DIR$/.idea/frontend.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
30
App.tsx
30
App.tsx
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { AppState, AppStateStatus } from 'react-native';
|
||||
import { AppState, AppStateStatus, Platform, StyleSheet } from 'react-native';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
@@ -42,6 +42,34 @@ const queryClient = new QueryClient({
|
||||
|
||||
installAlertOverride();
|
||||
|
||||
// 注入全局CSS来移除React Native Web在浏览器中的默认focus outline
|
||||
if (Platform.OS === 'web') {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
/* 移除TextInput focus时的浏览器默认outline */
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none !important;
|
||||
outline-width: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* 移除focus-visible时的默认outline */
|
||||
input:focus-visible, textarea:focus-visible {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* 移除所有:focus相关的默认样式 */
|
||||
*:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
*:focus-visible {
|
||||
outline: none !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const appState = useRef<AppStateStatus>(AppState.currentState);
|
||||
const notificationResponseListener = useRef<Notifications.EventSubscription | null>(null);
|
||||
|
||||
BIN
screenshots/after-fix.png
Normal file
BIN
screenshots/after-fix.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 124 KiB |
BIN
screenshots/test-navigation-fix.png
Normal file
BIN
screenshots/test-navigation-fix.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
@@ -59,6 +59,9 @@ const SearchBar: React.FC<SearchBarProps> = ({
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
autoFocus={autoFocus}
|
||||
// 确保光标可见
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
<TouchableOpacity
|
||||
@@ -87,18 +90,22 @@ const styles = StyleSheet.create({
|
||||
height: 46,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E7E7E7',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.09,
|
||||
shadowRadius: 6,
|
||||
elevation: 2,
|
||||
// 减少常态阴影,避免看起来像"黑框"
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
containerFocused: {
|
||||
borderColor: `${colors.primary.main}66`,
|
||||
shadowColor: colors.primary.main,
|
||||
shadowOpacity: 0.18,
|
||||
shadowRadius: 10,
|
||||
elevation: 4,
|
||||
// 焦点时使用更柔和的边框颜色
|
||||
borderColor: colors.primary.main,
|
||||
// 不添加额外的阴影,保持简洁
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
searchIconWrap: {
|
||||
width: 30,
|
||||
|
||||
@@ -189,6 +189,99 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
||||
requestStatus === 'pending' &&
|
||||
!!onRequestAction;
|
||||
|
||||
// 使用固定的响应式值,兼容移动端和Web端
|
||||
const containerPadding = spacing.md;
|
||||
const avatarSize = 44;
|
||||
const iconSize = 22;
|
||||
const contentFontSize = 14;
|
||||
const titleFontSize = 14;
|
||||
const timeFontSize = 12;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
padding: containerPadding,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
iconContainer: {
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
iconWrapper: {
|
||||
width: avatarSize,
|
||||
height: avatarSize,
|
||||
borderRadius: avatarSize / 2,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
minWidth: 0,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
title: {
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
marginRight: spacing.sm,
|
||||
fontSize: titleFontSize,
|
||||
},
|
||||
time: {
|
||||
fontSize: timeFontSize,
|
||||
},
|
||||
messageContent: {
|
||||
lineHeight: 20,
|
||||
fontSize: contentFontSize,
|
||||
},
|
||||
extraInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xs,
|
||||
backgroundColor: colors.background.default,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.sm,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
extraText: {
|
||||
marginLeft: spacing.xs,
|
||||
flex: 1,
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: 'row',
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
actionBtn: {
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
rejectBtn: {
|
||||
borderColor: colors.error.light,
|
||||
backgroundColor: colors.error.light + '18',
|
||||
},
|
||||
approveBtn: {
|
||||
borderColor: colors.success.light,
|
||||
backgroundColor: colors.success.light + '18',
|
||||
},
|
||||
arrowContainer: {
|
||||
marginLeft: spacing.sm,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: 20,
|
||||
width: 24,
|
||||
},
|
||||
});
|
||||
|
||||
// 判断是否显示操作者头像
|
||||
const showOperatorAvatar = ['follow', 'like_post', 'like_comment', 'like_reply', 'favorite_post', 'comment', 'reply', 'mention', 'group_join_apply'].includes(
|
||||
message.system_type
|
||||
@@ -207,19 +300,19 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
||||
{showGroupAvatar ? (
|
||||
<Avatar
|
||||
source={groupAvatar || ''}
|
||||
size={44}
|
||||
size={avatarSize}
|
||||
name={extra_data?.group_name || '群聊'}
|
||||
/>
|
||||
) : showOperatorAvatar ? (
|
||||
<Avatar
|
||||
source={operatorAvatar || ''}
|
||||
size={44}
|
||||
size={avatarSize}
|
||||
name={operatorName}
|
||||
onPress={onAvatarPress}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.iconWrapper, { backgroundColor: bgColor }]}>
|
||||
<MaterialCommunityIcons name={icon} size={22} color={color} />
|
||||
<MaterialCommunityIcons name={icon} size={iconSize} color={color} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -231,7 +324,7 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
||||
<Text variant="body" style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.time}>
|
||||
{formatTime(message.created_at)}
|
||||
</Text>
|
||||
</View>
|
||||
@@ -288,82 +381,4 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
padding: spacing.lg,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
iconContainer: {
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
iconWrapper: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
title: {
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
messageContent: {
|
||||
lineHeight: 18,
|
||||
},
|
||||
extraInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xs,
|
||||
backgroundColor: colors.background.default,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.sm,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
extraText: {
|
||||
marginLeft: spacing.xs,
|
||||
flex: 1,
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: 'row',
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
actionBtn: {
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
rejectBtn: {
|
||||
borderColor: colors.error.light,
|
||||
backgroundColor: colors.error.light + '18',
|
||||
},
|
||||
approveBtn: {
|
||||
borderColor: colors.success.light,
|
||||
backgroundColor: colors.success.light + '18',
|
||||
},
|
||||
arrowContainer: {
|
||||
marginLeft: spacing.sm,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: 20,
|
||||
},
|
||||
});
|
||||
|
||||
export default SystemMessageItem;
|
||||
|
||||
@@ -105,6 +105,9 @@ const Input: React.FC<InputProps> = ({
|
||||
autoCapitalize={autoCapitalize}
|
||||
keyboardType={keyboardType}
|
||||
autoCorrect={autoCorrect}
|
||||
// 确保光标可见
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<TouchableOpacity
|
||||
@@ -143,6 +146,8 @@ const styles = StyleSheet.create({
|
||||
borderWidth: 1,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
// 减少非焦点状态的边框明显度
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
|
||||
@@ -35,6 +35,7 @@ import { colors, shadows } from '../theme';
|
||||
import { useAuthStore, useTotalUnreadCount } from '../stores';
|
||||
import { messageManager } from '../stores';
|
||||
import { useResponsive } from '../hooks';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type {
|
||||
RootStackParamList,
|
||||
MainTabParamList,
|
||||
@@ -44,6 +45,7 @@ import type {
|
||||
ProfileStackParamList,
|
||||
AuthStackParamList,
|
||||
} from './types';
|
||||
import { SimpleMobileTabNavigator } from './SimpleMobileTabNavigator';
|
||||
|
||||
// ==================== 导入屏幕组件 ====================
|
||||
import { HomeScreen, PostDetailScreen, SearchScreen } from '../screens/home';
|
||||
@@ -81,6 +83,9 @@ const SIDEBAR_WIDTH_TABLET = 200;
|
||||
const SIDEBAR_COLLAPSED_WIDTH = 72;
|
||||
const MOBILE_TAB_FLOATING_MARGIN = 12;
|
||||
|
||||
// ==================== 持久化存储键 ====================
|
||||
const LAST_ACTIVE_TAB_KEY = 'last_active_tab';
|
||||
|
||||
// ==================== 导航项类型 ====================
|
||||
type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab';
|
||||
|
||||
@@ -306,6 +311,18 @@ function Sidebar({
|
||||
}: SidebarProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// 调试日志
|
||||
useEffect(() => {
|
||||
console.log('[Debug Sidebar]', {
|
||||
width,
|
||||
isCollapsed,
|
||||
activeTab,
|
||||
unreadCount,
|
||||
insetsTop: insets.top,
|
||||
insetsBottom: insets.bottom,
|
||||
});
|
||||
}, [width, isCollapsed, activeTab, unreadCount, insets]);
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ name: 'HomeTab', label: '首页', icon: 'home', iconOutline: 'home-outline' },
|
||||
{ name: 'MessageTab', label: '消息', icon: 'message-text', iconOutline: 'message-text-outline', badge: unreadCount },
|
||||
@@ -461,12 +478,13 @@ interface ResponsiveTabNavigatorProps {
|
||||
function ResponsiveTabNavigator({ children }: ResponsiveTabNavigatorProps) {
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
const route = useRoute<RouteProp<RootStackParamList, 'Main'>>();
|
||||
const { isMobile, isTablet, isDesktop } = useResponsive();
|
||||
const { isMobile, isTablet, isDesktop, width, height } = useResponsive();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [activeTab, setActiveTab] = useState<TabName>('HomeTab');
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
const messageUnreadCount = useTotalUnreadCount();
|
||||
|
||||
|
||||
// 【新架构】MessageManager 自动处理 WebSocket 消息和未读数更新
|
||||
useEffect(() => {
|
||||
messageManager.initialize();
|
||||
@@ -483,54 +501,62 @@ function ResponsiveTabNavigator({ children }: ResponsiveTabNavigatorProps) {
|
||||
|
||||
// 计算侧边栏宽度
|
||||
const sidebarWidth = useCallback(() => {
|
||||
if (isMobile) return 0; // 移动端不显示侧边栏
|
||||
if (isCollapsed) return SIDEBAR_COLLAPSED_WIDTH;
|
||||
if (isDesktop) return SIDEBAR_WIDTH_DESKTOP;
|
||||
if (isTablet) return SIDEBAR_WIDTH_TABLET;
|
||||
return 0;
|
||||
}, [isCollapsed, isDesktop, isTablet]);
|
||||
// 默认返回桌面宽度,避免返回 0 导致导航栏消失
|
||||
return SIDEBAR_WIDTH_DESKTOP;
|
||||
}, [isCollapsed, isDesktop, isTablet, isMobile]);
|
||||
|
||||
// 切换 Tab
|
||||
const handleTabChange = useCallback((tab: TabName) => {
|
||||
setActiveTab(tab);
|
||||
if (Platform.OS === 'web') {
|
||||
// 在大屏幕模式下,通过导航切换 Tab
|
||||
if (!isMobile) {
|
||||
let tabTarget: NavigatorScreenParams<MainTabParamList>;
|
||||
if (tab === 'HomeTab') {
|
||||
tabTarget = { screen: 'HomeTab', params: { screen: 'Home' } };
|
||||
} else if (tab === 'MessageTab') {
|
||||
tabTarget = { screen: 'MessageTab', params: { screen: 'MessageList' } };
|
||||
} else if (tab === 'ScheduleTab') {
|
||||
tabTarget = { screen: 'ScheduleTab', params: { screen: 'Schedule' } };
|
||||
} else {
|
||||
tabTarget = { screen: 'ProfileTab', params: { screen: 'Profile' } };
|
||||
}
|
||||
navigation.navigate('Main', tabTarget);
|
||||
}
|
||||
}, [navigation]);
|
||||
}, [navigation, isMobile]);
|
||||
|
||||
// 切换折叠状态
|
||||
const handleToggleCollapse = useCallback(() => {
|
||||
setIsCollapsed(prev => !prev);
|
||||
}, []);
|
||||
|
||||
// 渲染当前 Tab 的内容
|
||||
// 移动端:使用底部 Tab 导航
|
||||
if (isMobile) {
|
||||
// 使用简化的移动端导航,避免 React Navigation 状态恢复问题
|
||||
return <SimpleMobileTabNavigator />;
|
||||
}
|
||||
|
||||
// 渲染当前 Tab 的内容(仅在大屏模式下使用)
|
||||
// 注意:直接渲染屏幕组件,不使用 Stack Navigators
|
||||
// 这样可以避免 React Navigation 状态恢复问题
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'HomeTab':
|
||||
return <HomeStackNavigator />;
|
||||
return <HomeScreen />;
|
||||
case 'MessageTab':
|
||||
return <MessageStackNavigator />;
|
||||
return <MessageListScreen />;
|
||||
case 'ScheduleTab':
|
||||
return <ScheduleStackNavigator />;
|
||||
return <ScheduleScreen />;
|
||||
case 'ProfileTab':
|
||||
return <ProfileStackNavigator />;
|
||||
return <ProfileScreen />;
|
||||
default:
|
||||
return <HomeStackNavigator />;
|
||||
return <HomeScreen />;
|
||||
}
|
||||
};
|
||||
|
||||
// 移动端:使用底部 Tab 导航
|
||||
if (isMobile) {
|
||||
return <MainTabNavigator />;
|
||||
}
|
||||
|
||||
// 平板/桌面端:使用侧边导航
|
||||
const currentSidebarWidth = sidebarWidth();
|
||||
|
||||
|
||||
1126
src/navigation/MainNavigator.tsx.bak
Normal file
1126
src/navigation/MainNavigator.tsx.bak
Normal file
File diff suppressed because it is too large
Load Diff
545
src/navigation/MobileTabNavigatorWithDelay.tsx
Normal file
545
src/navigation/MobileTabNavigatorWithDelay.tsx
Normal file
@@ -0,0 +1,545 @@
|
||||
/**
|
||||
* 移动端 Tab Navigator(完全独立版本)
|
||||
*
|
||||
* 这个组件完全独立于 MainNavigator.tsx 中的其他导航组件,
|
||||
* 用于解决从大屏切换到小屏时的白屏问题。
|
||||
*
|
||||
* 问题根源:
|
||||
* - React Navigation 的 TabNavigator 在初始化时需要完整的 navigation state
|
||||
* - 从大屏(Sidebar)切换到小屏(BottomTab)时,state 可能还没有准备好
|
||||
* - 导致 TabRouter 报错:"Cannot read properties of undefined (reading 'filter')"
|
||||
*
|
||||
* 解决方案:
|
||||
* - 完全独立的组件,不依赖外部 navigation state
|
||||
* - 使用延迟初始化,确保 React Navigation 内部状态就绪
|
||||
* - 错误边界和重试机制
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
AppState,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { colors, shadows } from '../theme';
|
||||
import { useTotalUnreadCount } from '../stores';
|
||||
import { messageManager } from '../stores';
|
||||
|
||||
// ==================== 导入屏幕组件 ====================
|
||||
import { HomeScreen, PostDetailScreen, SearchScreen } from '../screens/home';
|
||||
import { ScheduleScreen, CourseDetailScreen } from '../screens/schedule';
|
||||
import {
|
||||
MessageListScreen,
|
||||
ChatScreen,
|
||||
NotificationsScreen,
|
||||
CreateGroupScreen,
|
||||
JoinGroupScreen,
|
||||
GroupRequestDetailScreen,
|
||||
GroupInviteDetailScreen,
|
||||
GroupInfoScreen,
|
||||
GroupMembersScreen,
|
||||
PrivateChatInfoScreen
|
||||
} from '../screens/message';
|
||||
import { ProfileScreen, SettingsScreen, EditProfileScreen, NotificationSettingsScreen, BlockedUsersScreen, AccountSecurityScreen } from '../screens/profile';
|
||||
import { CreatePostScreen } from '../screens/create';
|
||||
import { UserScreen } from '../screens/profile';
|
||||
import FollowListScreen from '../screens/profile/FollowListScreen';
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
export type MainTabParamList = {
|
||||
HomeTab: undefined;
|
||||
MessageTab: undefined;
|
||||
ScheduleTab: undefined;
|
||||
ProfileTab: undefined;
|
||||
};
|
||||
|
||||
export type HomeStackParamList = {
|
||||
Home: undefined;
|
||||
Search: undefined;
|
||||
};
|
||||
|
||||
export type MessageStackParamList = {
|
||||
MessageList: undefined;
|
||||
Notifications: undefined;
|
||||
PrivateChatInfo: { conversationId: string; userId: string };
|
||||
};
|
||||
|
||||
export type ScheduleStackParamList = {
|
||||
Schedule: undefined;
|
||||
CourseDetail: { courseId: string };
|
||||
};
|
||||
|
||||
export type ProfileStackParamList = {
|
||||
Profile: undefined;
|
||||
Settings: undefined;
|
||||
EditProfile: undefined;
|
||||
AccountSecurity: undefined;
|
||||
MyPosts: undefined;
|
||||
Bookmarks: undefined;
|
||||
NotificationSettings: undefined;
|
||||
BlockedUsers: undefined;
|
||||
};
|
||||
|
||||
// ==================== Stack Navigators ====================
|
||||
const HomeStack = createNativeStackNavigator<HomeStackParamList>();
|
||||
const MessageStack = createNativeStackNavigator<MessageStackParamList>();
|
||||
const ScheduleStack = createNativeStackNavigator<ScheduleStackParamList>();
|
||||
const ProfileStack = createNativeStackNavigator<ProfileStackParamList>();
|
||||
const Tab = createBottomTabNavigator<MainTabParamList>();
|
||||
|
||||
// ==================== 常量 ====================
|
||||
const MOBILE_TAB_FLOATING_MARGIN = 12;
|
||||
const INITIALIZATION_DELAY = 500; // ms,增加延迟时间确保 React Navigation 完全初始化
|
||||
const MAX_RETRY_ATTEMPTS = 5;
|
||||
const RETRY_DELAY = 500; // ms
|
||||
|
||||
// ==================== Stack Navigator 组件 ====================
|
||||
|
||||
function HomeStackNavigatorComponent() {
|
||||
return (
|
||||
<HomeStack.Navigator
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
headerTintColor: colors.text.primary,
|
||||
headerTitleStyle: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
headerBackTitle: '',
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
>
|
||||
<HomeStack.Screen
|
||||
name="Home"
|
||||
component={HomeScreen}
|
||||
options={{
|
||||
title: '首页',
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<HomeStack.Screen
|
||||
name="Search"
|
||||
component={SearchScreen}
|
||||
options={{
|
||||
title: '搜索',
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
</HomeStack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageStackNavigatorComponent() {
|
||||
return (
|
||||
<MessageStack.Navigator
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
headerTintColor: colors.text.primary,
|
||||
headerTitleStyle: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
headerBackTitle: '',
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
>
|
||||
<MessageStack.Screen
|
||||
name="MessageList"
|
||||
component={MessageListScreen}
|
||||
options={{
|
||||
title: '消息',
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<MessageStack.Screen
|
||||
name="Notifications"
|
||||
component={NotificationsScreen}
|
||||
options={{
|
||||
title: '通知',
|
||||
}}
|
||||
/>
|
||||
<MessageStack.Screen
|
||||
name="PrivateChatInfo"
|
||||
component={PrivateChatInfoScreen}
|
||||
options={{
|
||||
title: '聊天信息',
|
||||
}}
|
||||
/>
|
||||
</MessageStack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
function ScheduleStackNavigatorComponent() {
|
||||
return (
|
||||
<ScheduleStack.Navigator
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<ScheduleStack.Screen
|
||||
name="Schedule"
|
||||
component={ScheduleScreen}
|
||||
options={{
|
||||
title: '课表',
|
||||
}}
|
||||
/>
|
||||
<ScheduleStack.Screen
|
||||
name="CourseDetail"
|
||||
component={CourseDetailScreen}
|
||||
options={{
|
||||
headerShown: false,
|
||||
presentation: 'transparentModal',
|
||||
animation: 'fade',
|
||||
}}
|
||||
/>
|
||||
</ScheduleStack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileStackNavigatorComponent() {
|
||||
return (
|
||||
<ProfileStack.Navigator
|
||||
screenOptions={{
|
||||
headerStyle: {
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
headerTintColor: colors.text.primary,
|
||||
headerTitleStyle: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
headerBackTitle: '',
|
||||
headerShadowVisible: false,
|
||||
}}
|
||||
>
|
||||
<ProfileStack.Screen
|
||||
name="Profile"
|
||||
component={ProfileScreen}
|
||||
options={{
|
||||
headerShown: false,
|
||||
}}
|
||||
/>
|
||||
<ProfileStack.Screen
|
||||
name="Settings"
|
||||
component={SettingsScreen}
|
||||
options={{
|
||||
title: '设置',
|
||||
}}
|
||||
/>
|
||||
<ProfileStack.Screen
|
||||
name="EditProfile"
|
||||
component={EditProfileScreen}
|
||||
options={{
|
||||
title: '编辑资料',
|
||||
}}
|
||||
/>
|
||||
<ProfileStack.Screen
|
||||
name="AccountSecurity"
|
||||
component={AccountSecurityScreen}
|
||||
options={{
|
||||
title: '账号安全',
|
||||
}}
|
||||
/>
|
||||
<ProfileStack.Screen
|
||||
name="MyPosts"
|
||||
component={ProfileScreen}
|
||||
options={{
|
||||
title: '我的帖子',
|
||||
}}
|
||||
/>
|
||||
<ProfileStack.Screen
|
||||
name="Bookmarks"
|
||||
component={ProfileScreen}
|
||||
options={{
|
||||
title: '收藏',
|
||||
}}
|
||||
/>
|
||||
<ProfileStack.Screen
|
||||
name="NotificationSettings"
|
||||
component={NotificationSettingsScreen}
|
||||
options={{
|
||||
title: '通知设置',
|
||||
}}
|
||||
/>
|
||||
<ProfileStack.Screen
|
||||
name="BlockedUsers"
|
||||
component={BlockedUsersScreen}
|
||||
options={{
|
||||
title: '黑名单',
|
||||
}}
|
||||
/>
|
||||
</ProfileStack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 主组件:MobileTabNavigatorWithDelay ====================
|
||||
|
||||
export function MobileTabNavigatorWithDelay() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const messageUnreadCount = useTotalUnreadCount();
|
||||
const [isReady, setIsReady] = useState(false);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
const initAttemptRef = useRef(0);
|
||||
|
||||
// 初始化 MessageManager
|
||||
useEffect(() => {
|
||||
messageManager.initialize();
|
||||
}, []);
|
||||
|
||||
// 延迟初始化
|
||||
useEffect(() => {
|
||||
let timeoutId: NodeJS.Timeout;
|
||||
let isMounted = true;
|
||||
|
||||
const initializeNavigator = async () => {
|
||||
try {
|
||||
initAttemptRef.current += 1;
|
||||
console.log(
|
||||
`[MobileTabNavigator] Initialization attempt ${initAttemptRef.current}/${MAX_RETRY_ATTEMPTS}`
|
||||
);
|
||||
|
||||
// 延迟初始化
|
||||
await new Promise((resolve) => {
|
||||
timeoutId = setTimeout(resolve, INITIALIZATION_DELAY);
|
||||
});
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
// 后台检测
|
||||
if (Platform.OS !== 'web' && AppState.currentState === 'background') {
|
||||
console.log('[MobileTabNavigator] App in background, delaying...');
|
||||
await new Promise((resolve) => {
|
||||
timeoutId = setTimeout(resolve, 300);
|
||||
});
|
||||
}
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
setIsReady(true);
|
||||
setHasError(false);
|
||||
console.log('[MobileTabNavigator] Initialization successful');
|
||||
} catch (error) {
|
||||
console.error('[MobileTabNavigator] Initialization error:', error);
|
||||
|
||||
if (!isMounted) return;
|
||||
|
||||
if (initAttemptRef.current < MAX_RETRY_ATTEMPTS) {
|
||||
console.log('[MobileTabNavigator] Retrying...');
|
||||
timeoutId = setTimeout(initializeNavigator, RETRY_DELAY);
|
||||
} else {
|
||||
setHasError(true);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
initializeNavigator();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (timeoutId) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 渲染加载状态
|
||||
if (!isReady && !hasError) {
|
||||
return (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染错误状态(带重试按钮)
|
||||
if (hasError) {
|
||||
return (
|
||||
<View style={styles.errorContainer}>
|
||||
<MaterialCommunityIcons
|
||||
name="alert-circle-outline"
|
||||
size={48}
|
||||
color={colors.error.main}
|
||||
/>
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={colors.primary.main}
|
||||
style={styles.retryIndicator}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 渲染 Tab Navigator
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={{
|
||||
tabBarActiveTintColor: colors.primary.main,
|
||||
tabBarInactiveTintColor: colors.text.secondary,
|
||||
tabBarHideOnKeyboard: true,
|
||||
tabBarStyle: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: `${colors.divider}88`,
|
||||
borderRadius: 24,
|
||||
marginHorizontal: 14,
|
||||
marginBottom: MOBILE_TAB_FLOATING_MARGIN + insets.bottom,
|
||||
height: 64,
|
||||
paddingBottom: 6,
|
||||
paddingTop: 8,
|
||||
paddingHorizontal: 8,
|
||||
position: 'absolute',
|
||||
...shadows.lg,
|
||||
},
|
||||
tabBarItemStyle: {
|
||||
borderRadius: 18,
|
||||
paddingVertical: 1,
|
||||
marginHorizontal: 2,
|
||||
},
|
||||
tabBarLabelStyle: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginTop: -2,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
tabBarBadgeStyle: {
|
||||
backgroundColor: colors.error.main,
|
||||
color: colors.primary.contrast,
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
top: 4,
|
||||
},
|
||||
headerShown: false,
|
||||
}}
|
||||
>
|
||||
<Tab.Screen
|
||||
name="HomeTab"
|
||||
component={HomeStackNavigatorComponent}
|
||||
options={{
|
||||
tabBarLabel: '首页',
|
||||
tabBarIcon: ({ color, size, focused }) => (
|
||||
<View
|
||||
style={[
|
||||
styles.tabIconContainer,
|
||||
focused && styles.tabIconActive,
|
||||
]}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={focused ? 'home' : 'home-outline'}
|
||||
size={focused ? 26 : 24}
|
||||
color={color}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="MessageTab"
|
||||
component={MessageStackNavigatorComponent}
|
||||
options={{
|
||||
tabBarLabel: '消息',
|
||||
tabBarBadge:
|
||||
messageUnreadCount > 0
|
||||
? messageUnreadCount > 99
|
||||
? '99+'
|
||||
: messageUnreadCount
|
||||
: undefined,
|
||||
tabBarIcon: ({ color, size, focused }) => (
|
||||
<View
|
||||
style={[
|
||||
styles.tabIconContainer,
|
||||
focused && styles.tabIconActive,
|
||||
]}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={focused ? 'message-text' : 'message-text-outline'}
|
||||
size={focused ? 26 : 24}
|
||||
color={color}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="ScheduleTab"
|
||||
component={ScheduleStackNavigatorComponent}
|
||||
options={{
|
||||
tabBarLabel: '课表',
|
||||
tabBarIcon: ({ color, size, focused }) => (
|
||||
<View
|
||||
style={[
|
||||
styles.tabIconContainer,
|
||||
focused && styles.tabIconActive,
|
||||
]}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={focused ? 'calendar-today' : 'calendar-today'}
|
||||
size={focused ? 26 : 24}
|
||||
color={color}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tab.Screen
|
||||
name="ProfileTab"
|
||||
component={ProfileStackNavigatorComponent}
|
||||
options={{
|
||||
tabBarLabel: '我的',
|
||||
tabBarIcon: ({ color, size, focused }) => (
|
||||
<View
|
||||
style={[
|
||||
styles.tabIconContainer,
|
||||
focused && styles.tabIconActive,
|
||||
]}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={focused ? 'account' : 'account-outline'}
|
||||
size={focused ? 26 : 24}
|
||||
color={color}
|
||||
/>
|
||||
</View>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tab.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 样式 ====================
|
||||
const styles = StyleSheet.create({
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
errorContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
padding: 20,
|
||||
},
|
||||
retryIndicator: {
|
||||
marginTop: 16,
|
||||
},
|
||||
tabIconContainer: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: 38,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
},
|
||||
tabIconActive: {
|
||||
backgroundColor: `${colors.primary.main}20`,
|
||||
transform: [{ translateY: -1 }],
|
||||
},
|
||||
});
|
||||
282
src/navigation/SimpleMobileTabNavigator.tsx
Normal file
282
src/navigation/SimpleMobileTabNavigator.tsx
Normal file
@@ -0,0 +1,282 @@
|
||||
/**
|
||||
* 简单的移动端 Tab Navigator
|
||||
*
|
||||
* 不使用 React Navigation 的 Tab Navigator,而是使用一个简单的自定义实现
|
||||
* 这样可以完全避免 React Navigation 状态恢复的问题
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { colors, shadows } from '../theme';
|
||||
import { useTotalUnreadCount } from '../stores';
|
||||
import { messageManager } from '../stores';
|
||||
|
||||
// ==================== 导入屏幕组件 ====================
|
||||
import { HomeScreen, SearchScreen } from '../screens/home';
|
||||
import { ScheduleScreen, CourseDetailScreen } from '../screens/schedule';
|
||||
import {
|
||||
MessageListScreen,
|
||||
NotificationsScreen,
|
||||
PrivateChatInfoScreen,
|
||||
} from '../screens/message';
|
||||
import { ProfileScreen, SettingsScreen, EditProfileScreen, NotificationSettingsScreen, BlockedUsersScreen, AccountSecurityScreen } from '../screens/profile';
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab';
|
||||
|
||||
// ==================== 常量 ====================
|
||||
const TAB_BAR_HEIGHT = 64;
|
||||
const TAB_BAR_MARGIN = 14;
|
||||
const TAB_BAR_FLOATING_MARGIN = 12;
|
||||
|
||||
// ==================== 组件 ====================
|
||||
|
||||
/**
|
||||
* 简化的 Stack Navigator 包装
|
||||
*/
|
||||
function SimpleStackNavigator({
|
||||
children,
|
||||
screenName
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
screenName: string;
|
||||
}) {
|
||||
return (
|
||||
<View style={styles.stackContainer}>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 主组件:SimpleMobileTabNavigator
|
||||
*/
|
||||
export function SimpleMobileTabNavigator() {
|
||||
const insets = useSafeAreaInsets();
|
||||
const messageUnreadCount = useTotalUnreadCount();
|
||||
const [activeTab, setActiveTab] = useState<TabName>('HomeTab');
|
||||
|
||||
// 初始化 MessageManager
|
||||
useEffect(() => {
|
||||
messageManager.initialize();
|
||||
}, []);
|
||||
|
||||
// 处理 Tab 切换
|
||||
const handleTabPress = useCallback((tabName: TabName) => {
|
||||
setActiveTab(tabName);
|
||||
}, []);
|
||||
|
||||
// 渲染当前 Tab 的内容
|
||||
const renderTabContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'HomeTab':
|
||||
return <HomeScreen />;
|
||||
case 'MessageTab':
|
||||
return <MessageListScreen />;
|
||||
case 'ScheduleTab':
|
||||
return <ScheduleScreen />;
|
||||
case 'ProfileTab':
|
||||
return <ProfileScreen />;
|
||||
default:
|
||||
return <HomeScreen />;
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染 Tab Bar 图标
|
||||
const renderTabIcon = (tabName: TabName, isActive: boolean) => {
|
||||
const iconColor = isActive ? colors.primary.main : colors.text.secondary;
|
||||
const iconSize = isActive ? 26 : 24;
|
||||
|
||||
switch (tabName) {
|
||||
case 'HomeTab':
|
||||
return (
|
||||
<MaterialCommunityIcons
|
||||
name={isActive ? 'home' : 'home-outline'}
|
||||
size={iconSize}
|
||||
color={iconColor}
|
||||
/>
|
||||
);
|
||||
case 'MessageTab':
|
||||
return (
|
||||
<MaterialCommunityIcons
|
||||
name={isActive ? 'message-text' : 'message-text-outline'}
|
||||
size={iconSize}
|
||||
color={iconColor}
|
||||
/>
|
||||
);
|
||||
case 'ScheduleTab':
|
||||
return (
|
||||
<MaterialCommunityIcons
|
||||
name={isActive ? 'calendar-today' : 'calendar-today'}
|
||||
size={iconSize}
|
||||
color={iconColor}
|
||||
/>
|
||||
);
|
||||
case 'ProfileTab':
|
||||
return (
|
||||
<MaterialCommunityIcons
|
||||
name={isActive ? 'account' : 'account-outline'}
|
||||
size={iconSize}
|
||||
color={iconColor}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染 Tab Bar 标签
|
||||
const renderTabLabel = (tabName: TabName, isActive: boolean) => {
|
||||
const labels: Record<TabName, string> = {
|
||||
HomeTab: '首页',
|
||||
MessageTab: '消息',
|
||||
ScheduleTab: '课表',
|
||||
ProfileTab: '我的',
|
||||
};
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
styles.tabLabel,
|
||||
isActive && styles.tabLabelActive,
|
||||
]}
|
||||
>
|
||||
{labels[tabName]}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 内容区域 */}
|
||||
<View style={styles.content}>
|
||||
{renderTabContent()}
|
||||
</View>
|
||||
|
||||
{/* Tab Bar */}
|
||||
<View
|
||||
style={[
|
||||
styles.tabBar,
|
||||
{
|
||||
bottom: TAB_BAR_FLOATING_MARGIN + insets.bottom,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{(['HomeTab', 'MessageTab', 'ScheduleTab', 'ProfileTab'] as TabName[]).map(
|
||||
(tabName) => {
|
||||
const isActive = activeTab === tabName;
|
||||
const showBadge = tabName === 'MessageTab' && messageUnreadCount > 0;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={tabName}
|
||||
style={[
|
||||
styles.tabItem,
|
||||
isActive && styles.tabItemActive,
|
||||
]}
|
||||
onPress={() => handleTabPress(tabName)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.tabIconContainer}>
|
||||
{renderTabIcon(tabName, isActive)}
|
||||
{showBadge && (
|
||||
<View style={styles.badge}>
|
||||
<Text style={styles.badgeText}>
|
||||
{messageUnreadCount > 99 ? '99+' : messageUnreadCount}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{renderTabLabel(tabName, isActive)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 样式 ====================
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
stackContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
marginBottom: TAB_BAR_HEIGHT + TAB_BAR_FLOATING_MARGIN * 2,
|
||||
},
|
||||
tabBar: {
|
||||
position: 'absolute',
|
||||
left: TAB_BAR_MARGIN,
|
||||
right: TAB_BAR_MARGIN,
|
||||
height: TAB_BAR_HEIGHT,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: 24,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: `${colors.divider}88`,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-around',
|
||||
paddingHorizontal: 8,
|
||||
...shadows.lg,
|
||||
},
|
||||
tabItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 6,
|
||||
borderRadius: 18,
|
||||
},
|
||||
tabItemActive: {
|
||||
backgroundColor: `${colors.primary.main}15`,
|
||||
},
|
||||
tabIconContainer: {
|
||||
position: 'relative',
|
||||
width: 38,
|
||||
height: 30,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 15,
|
||||
},
|
||||
tabLabel: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
marginTop: -2,
|
||||
letterSpacing: 0.2,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
tabLabelActive: {
|
||||
color: colors.primary.main,
|
||||
},
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
right: -2,
|
||||
backgroundColor: colors.error.main,
|
||||
borderRadius: 10,
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
badgeText: {
|
||||
color: colors.primary.contrast,
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* 登录页 LoginScreen(响应式适配)
|
||||
* 登录页 LoginScreen(响应式适配 - 分栏布局)
|
||||
* 胡萝卜BBS - 用户登录
|
||||
* 现代化设计 - 渐变背景 + 动画效果
|
||||
* 登录表单在桌面端居中显示,限制最大宽度
|
||||
* 支持横屏模式下的布局调整
|
||||
*
|
||||
* 布局规则:
|
||||
* - 屏幕宽度 < 768px:单栏布局,橙色渐变背景
|
||||
* - 屏幕宽度 >= 768px:分栏布局,左侧橙色右侧中性灰
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
@@ -28,11 +30,13 @@ import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme';
|
||||
import { useAuthStore } from '../../stores';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { useResponsive, useResponsiveValue } from '../../hooks';
|
||||
import { ResponsiveContainer } from '../../components/common';
|
||||
import { showPrompt } from '../../services/promptService';
|
||||
|
||||
type LoginNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>;
|
||||
|
||||
// 分栏布局断点
|
||||
const SPLIT_BREAKPOINT = 768;
|
||||
|
||||
export const LoginScreen: React.FC = () => {
|
||||
const navigation = useNavigation<LoginNavigationProp>();
|
||||
const login = useAuthStore((state) => state.login);
|
||||
@@ -40,7 +44,10 @@ export const LoginScreen: React.FC = () => {
|
||||
const setStoreError = useAuthStore((state) => state.setError);
|
||||
|
||||
// 响应式布局
|
||||
const { isWideScreen, isLandscape, isDesktop, width } = useResponsive();
|
||||
const { isLandscape, width } = useResponsive();
|
||||
|
||||
// 是否使用分栏布局
|
||||
const isSplitLayout = width >= SPLIT_BREAKPOINT;
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -54,11 +61,23 @@ export const LoginScreen: React.FC = () => {
|
||||
const scaleAnim = useRef(new Animated.Value(0.9)).current;
|
||||
const inputFadeAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
// 响应式值
|
||||
const logoSize = useResponsiveValue({ xs: 64, sm: 72, md: 80, lg: 96, xl: 110 });
|
||||
const logoContainerSize = useResponsiveValue({ xs: 110, sm: 120, md: 130, lg: 150, xl: 170 });
|
||||
const appNameSize = useResponsiveValue({ xs: 28, sm: 30, md: 32, lg: 36, xl: 40 });
|
||||
const formMaxWidth = isDesktop ? 480 : isWideScreen ? 420 : undefined;
|
||||
// 响应式值 - 大屏模式单独放大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(() => {
|
||||
@@ -105,7 +124,7 @@ export const LoginScreen: React.FC = () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
// store error 同步到本地,方便在输入时清除
|
||||
// store error 同步到本地
|
||||
useEffect(() => {
|
||||
if (storeError) {
|
||||
setErrorMsg(storeError);
|
||||
@@ -126,8 +145,6 @@ export const LoginScreen: React.FC = () => {
|
||||
try {
|
||||
const success = await login({ username, password });
|
||||
if (!success) {
|
||||
// authStore 已将错误写入 storeError,useEffect 会同步到 errorMsg
|
||||
// 若 storeError 为空则显示通用提示
|
||||
if (!useAuthStore.getState().error) {
|
||||
setErrorMsg('登录失败,请稍后重试');
|
||||
}
|
||||
@@ -144,22 +161,23 @@ export const LoginScreen: React.FC = () => {
|
||||
navigation.navigate('Register' as any);
|
||||
};
|
||||
|
||||
// 渲染表单内容
|
||||
const renderFormContent = () => (
|
||||
<>
|
||||
{/* Logo和标题区域 */}
|
||||
// 渲染左侧面板(Logo和品牌信息)- 优化大屏布局
|
||||
const renderLeftPanel = () => (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.headerSection,
|
||||
isLandscape && styles.headerSectionLandscape,
|
||||
styles.leftPanel,
|
||||
isSplitLayout && styles.splitLeftPanel, // 大屏模式专用布局
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
transform: [{ scale: scaleAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* 胡萝卜图标 - 靠上显示 */}
|
||||
<View style={[
|
||||
styles.logoContainer,
|
||||
styles.logoContainerTop,
|
||||
isSplitLayout && styles.splitLogoContainer, // 大屏模式Logo容器样式
|
||||
{ width: logoContainerSize, height: logoContainerSize, borderRadius: logoContainerSize / 2 }
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
@@ -168,15 +186,41 @@ export const LoginScreen: React.FC = () => {
|
||||
color="#FFF"
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.appName, { fontSize: appNameSize }]}>胡萝卜</Text>
|
||||
<Text style={styles.subtitle}>发现有趣的内容,结识志同道合的朋友</Text>
|
||||
</Animated.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,
|
||||
formMaxWidth && { maxWidth: formMaxWidth, alignSelf: 'center', width: '100%' },
|
||||
// 核心修改:大屏模式使用专用的阴影样式,小屏用原有阴影
|
||||
isSplitLayout ? styles.splitFormCardShadow : styles.normalFormCardShadow,
|
||||
formMaxWidth && { maxWidth: formMaxWidth, width: '100%' },
|
||||
{
|
||||
opacity: inputFadeAnim,
|
||||
transform: [{ translateY: slideAnim }],
|
||||
@@ -187,10 +231,7 @@ export const LoginScreen: React.FC = () => {
|
||||
|
||||
{/* 用户名输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
]}>
|
||||
<View style={styles.inputWrapper}>
|
||||
<MaterialCommunityIcons
|
||||
name="account-outline"
|
||||
size={22}
|
||||
@@ -198,10 +239,7 @@ export const LoginScreen: React.FC = () => {
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
]}
|
||||
style={styles.input}
|
||||
placeholder="用户名 / 邮箱 / 手机号"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={username}
|
||||
@@ -227,10 +265,7 @@ export const LoginScreen: React.FC = () => {
|
||||
|
||||
{/* 密码输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
]}>
|
||||
<View style={styles.inputWrapper}>
|
||||
<MaterialCommunityIcons
|
||||
name="lock-outline"
|
||||
size={22}
|
||||
@@ -238,10 +273,7 @@ export const LoginScreen: React.FC = () => {
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
]}
|
||||
style={styles.input}
|
||||
placeholder="请输入密码"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={password}
|
||||
@@ -275,7 +307,7 @@ export const LoginScreen: React.FC = () => {
|
||||
<MaterialCommunityIcons
|
||||
name="alert-circle-outline"
|
||||
size={16}
|
||||
color={colors.error?.main ?? '#D32F2F'}
|
||||
color={colors.error.dark}
|
||||
style={{ marginRight: 6, marginTop: 1 }}
|
||||
/>
|
||||
<Text style={styles.errorText}>{errorMsg}</Text>
|
||||
@@ -290,13 +322,10 @@ export const LoginScreen: React.FC = () => {
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#FF6B35', '#FF8F66']}
|
||||
colors={[colors.primary.main, colors.primary.light]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[
|
||||
styles.loginButtonGradient,
|
||||
isWideScreen && styles.loginButtonGradientWide,
|
||||
]}
|
||||
style={styles.loginButtonGradient}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#FFF" />
|
||||
@@ -319,13 +348,48 @@ export const LoginScreen: React.FC = () => {
|
||||
<Text style={styles.registerLink}>立即注册</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
// 渲染分栏布局
|
||||
const renderSplitLayout = () => (
|
||||
<View style={styles.splitContainer}>
|
||||
{/* 左侧橙色面板 */}
|
||||
<LinearGradient
|
||||
colors={['#FF6B35', '#FF8F66', '#FFB088']}
|
||||
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}
|
||||
@@ -334,8 +398,6 @@ export const LoginScreen: React.FC = () => {
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.keyboardView}
|
||||
>
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={600}>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
@@ -348,29 +410,40 @@ export const LoginScreen: React.FC = () => {
|
||||
<View style={styles.decorCircle1} />
|
||||
<View style={styles.decorCircle2} />
|
||||
|
||||
{renderFormContent()}
|
||||
</ScrollView>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
isLandscape && styles.scrollContentLandscape,
|
||||
{/* 头部 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.headerSection,
|
||||
isLandscape && styles.headerSectionLandscape,
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
transform: [{ scale: scaleAnim }],
|
||||
},
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* 装饰性背景元素 */}
|
||||
<View style={styles.decorCircle1} />
|
||||
<View style={styles.decorCircle2} />
|
||||
<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>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
</LinearGradient>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
return isSplitLayout ? renderSplitLayout() : renderSingleLayout();
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
@@ -383,11 +456,17 @@ const styles = StyleSheet.create({
|
||||
keyboardView: {
|
||||
flex: 1,
|
||||
},
|
||||
// 单栏布局内容
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing['2xl'],
|
||||
},
|
||||
scrollContentCentered: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
scrollContentLandscape: {
|
||||
paddingVertical: spacing.lg,
|
||||
},
|
||||
@@ -429,6 +508,15 @@ const styles = StyleSheet.create({
|
||||
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',
|
||||
@@ -437,20 +525,54 @@ const styles = StyleSheet.create({
|
||||
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',
|
||||
@@ -471,9 +593,6 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.md,
|
||||
height: 56,
|
||||
},
|
||||
inputWrapperWide: {
|
||||
height: 60,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
@@ -482,9 +601,6 @@ const styles = StyleSheet.create({
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
inputWide: {
|
||||
fontSize: fontSizes.lg,
|
||||
},
|
||||
clearButton: {
|
||||
padding: spacing.xs,
|
||||
},
|
||||
@@ -495,23 +611,23 @@ const styles = StyleSheet.create({
|
||||
forgotPasswordText: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.primary.main,
|
||||
fontWeight: '500',
|
||||
fontWeight: '600',
|
||||
},
|
||||
errorBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
backgroundColor: '#FFEBEE',
|
||||
backgroundColor: colors.accent.light,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: '#D32F2F',
|
||||
borderLeftColor: colors.accent.main,
|
||||
},
|
||||
errorText: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.sm,
|
||||
color: '#D32F2F',
|
||||
color: colors.accent.dark,
|
||||
lineHeight: 20,
|
||||
},
|
||||
loginButton: {
|
||||
@@ -524,9 +640,6 @@ const styles = StyleSheet.create({
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
loginButtonGradientWide: {
|
||||
height: 56,
|
||||
},
|
||||
loginButtonDisabled: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
@@ -540,20 +653,108 @@ const styles = StyleSheet.create({
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: 'auto',
|
||||
paddingTop: spacing.xl,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: fontSizes.md,
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
registerLink: {
|
||||
fontSize: fontSizes.md,
|
||||
color: '#FFFFFF',
|
||||
color: colors.primary.main,
|
||||
fontWeight: '700',
|
||||
marginLeft: spacing.xs,
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
// ========== 分栏布局样式 ==========
|
||||
splitContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
leftPanelContainer: {
|
||||
flex: 1, // 50%
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
leftPanelSafeArea: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing['4xl'], // 增加内边距,不贴边
|
||||
},
|
||||
leftPanel: {
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing['2xl'],
|
||||
},
|
||||
// 大屏模式左侧面板布局
|
||||
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%',
|
||||
// 移除这里的阴影,避免重复
|
||||
},
|
||||
});
|
||||
|
||||
export default LoginScreen;
|
||||
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* 注册页 RegisterScreen(响应式适配)
|
||||
* 注册页 RegisterScreen(响应式适配 - 分栏布局)
|
||||
* 胡萝卜BBS - 用户注册
|
||||
* 现代化设计 - 渐变背景 + 动画效果
|
||||
* 注册表单在桌面端居中显示,限制最大宽度
|
||||
* 支持横屏模式下的布局调整
|
||||
*
|
||||
* 布局规则:
|
||||
* - 屏幕宽度 < 768px:单栏布局,橙色渐变背景
|
||||
* - 屏幕宽度 >= 768px:分栏布局,左侧橙色右侧中性灰
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
@@ -29,17 +31,22 @@ import { authService, resolveAuthApiError } from '../../services/authService';
|
||||
import { useAuthStore } from '../../stores';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { useResponsive, useResponsiveValue } from '../../hooks';
|
||||
import { ResponsiveContainer } from '../../components/common';
|
||||
import { showPrompt } from '../../services/promptService';
|
||||
|
||||
type RegisterNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>;
|
||||
|
||||
// 分栏布局断点
|
||||
const SPLIT_BREAKPOINT = 768;
|
||||
|
||||
export const RegisterScreen: React.FC = () => {
|
||||
const navigation = useNavigation<RegisterNavigationProp>();
|
||||
const register = useAuthStore((state) => state.register);
|
||||
|
||||
// 响应式布局
|
||||
const { isWideScreen, isLandscape, isDesktop } = useResponsive();
|
||||
const { isLandscape, width } = useResponsive();
|
||||
|
||||
// 是否使用分栏布局
|
||||
const isSplitLayout = width >= SPLIT_BREAKPOINT;
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [nickname, setNickname] = useState('');
|
||||
@@ -61,11 +68,23 @@ export const RegisterScreen: React.FC = () => {
|
||||
const scaleAnim = useRef(new Animated.Value(0.95)).current;
|
||||
const inputFadeAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
// 响应式值
|
||||
const iconSize = useResponsiveValue({ xs: 48, sm: 52, md: 56, lg: 64, xl: 72 });
|
||||
const iconContainerSize = useResponsiveValue({ xs: 80, sm: 88, md: 96, lg: 108, xl: 120 });
|
||||
const titleSize = useResponsiveValue({ xs: 24, sm: 26, md: 28, lg: 30, xl: 32 });
|
||||
const formMaxWidth = isDesktop ? 520 : isWideScreen ? 480 : undefined;
|
||||
// 响应式值 - 大屏模式单独放大图标尺寸,更显大气
|
||||
const iconSize = useResponsiveValue({
|
||||
xs: 48, sm: 52, md: 56,
|
||||
lg: isSplitLayout ? 120 : 64, // 大屏模式图标进一步放大
|
||||
xl: isSplitLayout ? 140 : 72
|
||||
});
|
||||
const iconContainerSize = useResponsiveValue({
|
||||
xs: 80, sm: 88, md: 96,
|
||||
lg: isSplitLayout ? 180 : 108, // 大屏模式图标容器进一步放大
|
||||
xl: isSplitLayout ? 200 : 120
|
||||
});
|
||||
const titleSize = useResponsiveValue({
|
||||
xs: 24, sm: 26, md: 28,
|
||||
lg: isSplitLayout ? 60 : 30, // 大屏模式标题文字大幅放大
|
||||
xl: isSplitLayout ? 72 : 32
|
||||
});
|
||||
const formMaxWidth = isSplitLayout ? 480 : undefined;
|
||||
|
||||
// 启动入场动画
|
||||
useEffect(() => {
|
||||
@@ -219,25 +238,26 @@ export const RegisterScreen: React.FC = () => {
|
||||
|
||||
// 跳转到登录页
|
||||
const handleGoToLogin = () => {
|
||||
navigation.goBack();
|
||||
navigation.navigate('Login' as any);
|
||||
};
|
||||
|
||||
// 渲染表单内容
|
||||
const renderFormContent = () => (
|
||||
<>
|
||||
{/* 标题区域 */}
|
||||
// 渲染左侧面板(Logo和品牌信息)- 优化大屏布局
|
||||
const renderLeftPanel = () => (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.headerSection,
|
||||
isLandscape && styles.headerSectionLandscape,
|
||||
styles.leftPanel,
|
||||
isSplitLayout && styles.splitLeftPanel, // 大屏模式专用布局
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
transform: [{ scale: scaleAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* 用户图标 - 靠上显示 */}
|
||||
<View style={[
|
||||
styles.iconContainer,
|
||||
styles.iconContainerTop,
|
||||
isSplitLayout && styles.splitIconContainer, // 大屏模式图标容器样式
|
||||
{ width: iconContainerSize, height: iconContainerSize, borderRadius: iconContainerSize / 2 }
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
@@ -246,26 +266,54 @@ export const RegisterScreen: React.FC = () => {
|
||||
color="#FFF"
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.title, { fontSize: titleSize }]}>创建账号</Text>
|
||||
<Text style={styles.subtitle}>加入胡萝卜,开始你的旅程</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* 表单卡片 */}
|
||||
{/* 品牌名称 - 优化大屏排版 */}
|
||||
<Text style={[styles.title, isSplitLayout && styles.splitTitle]}>创建账号</Text>
|
||||
<Text style={[styles.subtitle, isSplitLayout && styles.splitSubtitle]}>加入胡萝卜,开始你的旅程</Text>
|
||||
|
||||
{/* 装饰元素 - 大屏模式改为水平排列 + 大幅放大 */}
|
||||
<View style={[styles.leftPanelDecorBottom, isSplitLayout && styles.splitLeftPanelDecorBottom]}>
|
||||
{isSplitLayout ? (
|
||||
// 大屏模式:水平排列 + 大幅放大图标
|
||||
<View style={styles.splitDecorRow}>
|
||||
<MaterialCommunityIcons name="account-plus-outline" size={80} color="rgba(255,255,255,0.4)" style={styles.splitDecorIcon} />
|
||||
<MaterialCommunityIcons name="email-check-outline" size={90} color="rgba(255,255,255,0.45)" style={styles.splitDecorIcon} />
|
||||
<MaterialCommunityIcons name="shield-check-outline" size={85} color="rgba(255,255,255,0.35)" style={styles.splitDecorIcon} />
|
||||
</View>
|
||||
) : (
|
||||
// 小屏模式:保持原有横向排列
|
||||
<View style={styles.decorRow}>
|
||||
<MaterialCommunityIcons name="account-plus-outline" size={48} color="rgba(255,255,255,0.35)" />
|
||||
<MaterialCommunityIcons name="email-check-outline" size={44} color="rgba(255,255,255,0.3)" />
|
||||
<MaterialCommunityIcons name="shield-check-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,
|
||||
formMaxWidth && { maxWidth: formMaxWidth, alignSelf: 'center', width: '100%' },
|
||||
// 核心修改:大屏模式使用专用的阴影样式,小屏用原有阴影
|
||||
isSplitLayout ? styles.splitFormCardShadow : styles.normalFormCardShadow,
|
||||
formMaxWidth && { maxWidth: formMaxWidth, width: '100%' },
|
||||
{
|
||||
opacity: inputFadeAnim,
|
||||
transform: [{ translateY: slideAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={styles.formTitle}>注册账号</Text>
|
||||
|
||||
{/* 用户名输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
isSplitLayout && styles.inputWrapperWide,
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="account-outline"
|
||||
@@ -276,7 +324,7 @@ export const RegisterScreen: React.FC = () => {
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
isSplitLayout && styles.inputWide,
|
||||
]}
|
||||
placeholder="用户名(3-20个字符)"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
@@ -305,7 +353,7 @@ export const RegisterScreen: React.FC = () => {
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
isSplitLayout && styles.inputWrapperWide,
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="emoticon-outline"
|
||||
@@ -316,7 +364,7 @@ export const RegisterScreen: React.FC = () => {
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
isSplitLayout && styles.inputWide,
|
||||
]}
|
||||
placeholder="昵称(2-20个字符)"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
@@ -343,7 +391,7 @@ export const RegisterScreen: React.FC = () => {
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
isSplitLayout && styles.inputWrapperWide,
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="email-outline"
|
||||
@@ -354,7 +402,7 @@ export const RegisterScreen: React.FC = () => {
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
isSplitLayout && styles.inputWide,
|
||||
]}
|
||||
placeholder="邮箱(必填)"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
@@ -382,7 +430,7 @@ export const RegisterScreen: React.FC = () => {
|
||||
{/* 邮箱验证码 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={styles.codeRow}>
|
||||
<View style={[styles.inputWrapper, styles.codeInputWrapper, isWideScreen && styles.inputWrapperWide]}>
|
||||
<View style={[styles.inputWrapper, styles.codeInputWrapper, isSplitLayout && styles.inputWrapperWide]}>
|
||||
<MaterialCommunityIcons
|
||||
name="shield-key-outline"
|
||||
size={22}
|
||||
@@ -390,7 +438,7 @@ export const RegisterScreen: React.FC = () => {
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[styles.input, isWideScreen && styles.inputWide]}
|
||||
style={[styles.input, isSplitLayout && styles.inputWide]}
|
||||
placeholder="邮箱验证码"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={verificationCode}
|
||||
@@ -419,7 +467,7 @@ export const RegisterScreen: React.FC = () => {
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
isSplitLayout && styles.inputWrapperWide,
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="phone-outline"
|
||||
@@ -430,7 +478,7 @@ export const RegisterScreen: React.FC = () => {
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
isSplitLayout && styles.inputWide,
|
||||
]}
|
||||
placeholder="手机号(选填)"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
@@ -458,7 +506,7 @@ export const RegisterScreen: React.FC = () => {
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
isSplitLayout && styles.inputWrapperWide,
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="lock-outline"
|
||||
@@ -469,7 +517,7 @@ export const RegisterScreen: React.FC = () => {
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
isSplitLayout && styles.inputWide,
|
||||
]}
|
||||
placeholder="密码(至少6位)"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
@@ -495,7 +543,7 @@ export const RegisterScreen: React.FC = () => {
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
isSplitLayout && styles.inputWrapperWide,
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="lock-check-outline"
|
||||
@@ -506,7 +554,7 @@ export const RegisterScreen: React.FC = () => {
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
isSplitLayout && styles.inputWide,
|
||||
]}
|
||||
placeholder="确认密码"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
@@ -559,12 +607,12 @@ export const RegisterScreen: React.FC = () => {
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#FF6B35', '#FF8F66']}
|
||||
colors={[colors.primary.main, colors.primary.light]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[
|
||||
styles.registerButtonGradient,
|
||||
isWideScreen && styles.registerButtonGradientWide,
|
||||
isSplitLayout && styles.registerButtonGradientWide,
|
||||
]}
|
||||
>
|
||||
{loading ? (
|
||||
@@ -580,21 +628,57 @@ export const RegisterScreen: React.FC = () => {
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.footerSection,
|
||||
isSplitLayout && styles.splitFooterSection,
|
||||
{ opacity: inputFadeAnim },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.footerText}>已有账号?</Text>
|
||||
<Text style={[styles.footerText, isSplitLayout && styles.splitFooterText]}>已有账号?</Text>
|
||||
<TouchableOpacity onPress={handleGoToLogin}>
|
||||
<Text style={styles.loginLink}>立即登录</Text>
|
||||
<Text style={[styles.loginLink, isSplitLayout && styles.splitLoginLink]}>立即登录</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</>
|
||||
</View>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
// 渲染分栏布局
|
||||
const renderSplitLayout = () => (
|
||||
<View style={styles.splitContainer}>
|
||||
{/* 左侧橙色面板 */}
|
||||
<LinearGradient
|
||||
colors={['#FF6B35', '#FF8F66', '#FFB088']}
|
||||
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}
|
||||
@@ -603,8 +687,6 @@ export const RegisterScreen: React.FC = () => {
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.keyboardView}
|
||||
>
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={600}>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
@@ -617,29 +699,40 @@ export const RegisterScreen: React.FC = () => {
|
||||
<View style={styles.decorCircle1} />
|
||||
<View style={styles.decorCircle2} />
|
||||
|
||||
{renderFormContent()}
|
||||
</ScrollView>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
isLandscape && styles.scrollContentLandscape,
|
||||
{/* 头部 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.headerSection,
|
||||
isLandscape && styles.headerSectionLandscape,
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
transform: [{ scale: scaleAnim }],
|
||||
},
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* 装饰性背景元素 */}
|
||||
<View style={styles.decorCircle1} />
|
||||
<View style={styles.decorCircle2} />
|
||||
<View style={[
|
||||
styles.iconContainer,
|
||||
{ width: iconContainerSize, height: iconContainerSize, borderRadius: iconContainerSize / 2 }
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="account-plus"
|
||||
size={iconSize}
|
||||
color="#FFF"
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.title, { fontSize: titleSize }]}>创建账号</Text>
|
||||
<Text style={styles.subtitle}>加入胡萝卜,开始你的旅程</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* 表单 */}
|
||||
{renderFormContent()}
|
||||
</ScrollView>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
</LinearGradient>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
return isSplitLayout ? renderSplitLayout() : renderSingleLayout();
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
@@ -827,10 +920,16 @@ const styles = StyleSheet.create({
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
splitFooterSection: {
|
||||
paddingTop: spacing.xl,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: fontSizes.md,
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
},
|
||||
splitFooterText: {
|
||||
color: colors.text.primary,
|
||||
},
|
||||
loginLink: {
|
||||
fontSize: fontSizes.md,
|
||||
color: '#FFFFFF',
|
||||
@@ -838,6 +937,141 @@ const styles = StyleSheet.create({
|
||||
marginLeft: spacing.xs,
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
splitLoginLink: {
|
||||
color: colors.primary.main,
|
||||
},
|
||||
// ========== 分栏布局样式 ==========
|
||||
splitContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
leftPanelContainer: {
|
||||
flex: 1, // 50%
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
leftPanelSafeArea: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing['4xl'], // 增加内边距,不贴边
|
||||
},
|
||||
leftPanel: {
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing['2xl'],
|
||||
},
|
||||
// 大屏模式左侧面板布局
|
||||
splitLeftPanel: {
|
||||
width: '100%',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 120,
|
||||
},
|
||||
// 大屏模式图标容器样式 - 增加阴影和内边距,更立体
|
||||
splitIconContainer: {
|
||||
borderWidth: 3,
|
||||
borderColor: 'rgba(255,255,255,0.5)',
|
||||
...shadows.lg,
|
||||
},
|
||||
iconContainerTop: {
|
||||
marginTop: spacing['3xl'],
|
||||
},
|
||||
// 大屏模式标题样式 - 大幅放大,增加间距和文字效果
|
||||
splitTitle: {
|
||||
fontSize: 72, // 大幅放大主标题
|
||||
marginBottom: 48,
|
||||
textShadowOffset: { width: 0, height: 4 },
|
||||
textShadowRadius: 8,
|
||||
letterSpacing: 4, // 增加字间距,更显大气
|
||||
fontWeight: '900',
|
||||
},
|
||||
// 大屏模式副标题样式 - 大幅放大
|
||||
splitSubtitle: {
|
||||
fontSize: 24, // 放大副标题
|
||||
marginBottom: 120, // 增加底部间距
|
||||
lineHeight: 36,
|
||||
paddingHorizontal: spacing['4xl'],
|
||||
letterSpacing: 1.5,
|
||||
fontWeight: '500',
|
||||
},
|
||||
leftPanelDecorBottom: {
|
||||
marginTop: spacing['2xl'],
|
||||
},
|
||||
// 大屏模式装饰元素布局
|
||||
splitLeftPanelDecorBottom: {
|
||||
marginTop: 120,
|
||||
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: 480,
|
||||
width: '100%',
|
||||
},
|
||||
singleFormWrapper: {
|
||||
flexGrow: 1,
|
||||
width: '100%',
|
||||
},
|
||||
// 小屏模式:原有默认阴影
|
||||
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',
|
||||
color: colors.text.primary,
|
||||
marginBottom: spacing.xl,
|
||||
textAlign: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default RegisterScreen;
|
||||
|
||||
@@ -32,6 +32,7 @@ import { PostCard, TabBar, SearchBar } from '../../components/business';
|
||||
import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common';
|
||||
import { HomeStackParamList, RootStackParamList } from '../../navigation/types';
|
||||
import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive';
|
||||
import { SearchScreen } from './SearchScreen';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'Home'> & NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
@@ -84,6 +85,9 @@ export const HomeScreen: React.FC = () => {
|
||||
const [postImages, setPostImages] = useState<ImageGridItem[]>([]);
|
||||
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
||||
|
||||
// 搜索显示状态(用于内嵌搜索页面)
|
||||
const [showSearch, setShowSearch] = useState(false);
|
||||
|
||||
// 用于跟踪当前页面显示的帖子 ID,以便从 store 同步状态
|
||||
const postIdsRef = React.useRef<Set<string>>(new Set());
|
||||
const inFlightRequestKeysRef = React.useRef<Set<string>>(new Set());
|
||||
@@ -325,9 +329,9 @@ export const HomeScreen: React.FC = () => {
|
||||
})
|
||||
), [handleSwipeTabChange]);
|
||||
|
||||
// 跳转到搜索页
|
||||
// 跳转到搜索页(使用内嵌模式,不再依赖导航)
|
||||
const handleSearchPress = () => {
|
||||
navigation.navigate('Search');
|
||||
setShowSearch(true);
|
||||
};
|
||||
|
||||
// 跳转到帖子详情
|
||||
@@ -630,6 +634,19 @@ export const HomeScreen: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// 搜索页面内嵌模式
|
||||
if (showSearch) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<StatusBar barStyle="dark-content" backgroundColor={colors.background.paper} />
|
||||
<SearchScreen
|
||||
onBack={() => setShowSearch(false)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
// 正常首页内容
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<StatusBar barStyle="dark-content" backgroundColor={colors.background.paper} />
|
||||
@@ -667,6 +684,8 @@ export const HomeScreen: React.FC = () => {
|
||||
</View>
|
||||
|
||||
{/* 帖子列表 */}
|
||||
{isMobile ? (
|
||||
// 移动端:使用 GestureDetector 支持手势切换 Tab
|
||||
<GestureDetector gesture={swipeGesture}>
|
||||
<View style={styles.contentContainer}>
|
||||
{viewMode === 'list' ? (
|
||||
@@ -677,6 +696,17 @@ export const HomeScreen: React.FC = () => {
|
||||
)}
|
||||
</View>
|
||||
</GestureDetector>
|
||||
) : (
|
||||
// 桌面端/平板端:不使用 GestureDetector,避免拦截侧边栏点击
|
||||
<View style={styles.contentContainer}>
|
||||
{viewMode === 'list' ? (
|
||||
renderListContent()
|
||||
) : (
|
||||
// 网格模式:使用响应式网格
|
||||
renderResponsiveGrid()
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 漂浮发帖按钮 */}
|
||||
<TouchableOpacity
|
||||
@@ -717,6 +747,12 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
searchWrapper: {
|
||||
paddingBottom: spacing.sm,
|
||||
// 移除阴影效果
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
viewToggleBtn: {
|
||||
width: 44,
|
||||
|
||||
@@ -31,8 +31,14 @@ const TABS = ['帖子', '用户'];
|
||||
|
||||
type SearchType = 'posts' | 'users';
|
||||
|
||||
export const SearchScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
interface SearchScreenProps {
|
||||
onBack?: () => void;
|
||||
navigation?: NavigationProp;
|
||||
}
|
||||
|
||||
export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation: propNavigation }) => {
|
||||
// 如果传入了 navigation 则使用传入的,否则使用 hook 获取的
|
||||
const navigation = propNavigation || useNavigation<NavigationProp>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { searchHistory: history, addSearchHistory, clearSearchHistory } = useUserStore();
|
||||
|
||||
@@ -424,7 +430,7 @@ export const SearchScreen: React.FC = () => {
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.cancelButton, { marginLeft: responsiveGap }]}
|
||||
onPress={() => navigation.goBack()}
|
||||
onPress={() => onBack ? onBack() : navigation.goBack()}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text
|
||||
@@ -476,6 +482,12 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: `${colors.primary.main}08`,
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: spacing.xs,
|
||||
// 移除阴影效果
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: `${colors.primary.main}14`,
|
||||
|
||||
@@ -45,17 +45,37 @@ import {
|
||||
ChatHeader,
|
||||
MessageBubble,
|
||||
ChatInput,
|
||||
GroupInfoPanel,
|
||||
PANEL_HEIGHTS,
|
||||
} from './components/ChatScreen';
|
||||
|
||||
export const ChatScreen: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
const navigation = useNavigation<any>();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// 响应式布局
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
const styles = baseStyles;
|
||||
|
||||
// 监听屏幕宽度变化,当变为大屏幕时自动跳转到web端首页
|
||||
useEffect(() => {
|
||||
if (isWideScreen) {
|
||||
// 导航到大屏幕模式的主界面首页
|
||||
navigation.reset({
|
||||
index: 0,
|
||||
routes: [
|
||||
{
|
||||
name: 'Main',
|
||||
params: {
|
||||
screen: 'HomeTab',
|
||||
params: { screen: 'Home' }
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
}, [isWideScreen, navigation]);
|
||||
|
||||
// 输入框区域高度(用于定位浮动 mention 面板)
|
||||
const [inputWrapperHeight, setInputWrapperHeight] = useState(60);
|
||||
|
||||
@@ -93,6 +113,19 @@ export const ChatScreen: React.FC = () => {
|
||||
setShowImageViewer(false);
|
||||
};
|
||||
|
||||
// 群信息面板状态
|
||||
const [showGroupInfoPanel, setShowGroupInfoPanel] = useState(false);
|
||||
|
||||
// 打开群信息面板
|
||||
const handleGroupInfoPress = useCallback(() => {
|
||||
setShowGroupInfoPanel(true);
|
||||
}, []);
|
||||
|
||||
// 关闭群信息面板
|
||||
const handleCloseGroupInfoPanel = useCallback(() => {
|
||||
setShowGroupInfoPanel(false);
|
||||
}, []);
|
||||
|
||||
const {
|
||||
// 状态
|
||||
messages,
|
||||
@@ -125,6 +158,7 @@ export const ChatScreen: React.FC = () => {
|
||||
messageMap,
|
||||
loadingMore,
|
||||
hasMoreHistory,
|
||||
conversationId,
|
||||
|
||||
// Refs
|
||||
flatListRef,
|
||||
@@ -222,6 +256,8 @@ export const ChatScreen: React.FC = () => {
|
||||
onBack={() => navigation.goBack()}
|
||||
onTitlePress={navigateToInfo}
|
||||
onMorePress={navigateToChatSettings}
|
||||
onGroupInfoPress={handleGroupInfoPress}
|
||||
isWideScreen={isWideScreen}
|
||||
/>
|
||||
|
||||
{/* 消息列表 */}
|
||||
@@ -369,6 +405,33 @@ export const ChatScreen: React.FC = () => {
|
||||
onClose={handleCloseImageViewer}
|
||||
enableSave
|
||||
/>
|
||||
|
||||
{/* 群信息侧边栏面板 - 仅大屏幕显示 */}
|
||||
<GroupInfoPanel
|
||||
visible={showGroupInfoPanel}
|
||||
groupId={routeGroupId ? String(routeGroupId) : undefined}
|
||||
groupInfo={groupInfo as any}
|
||||
conversationId={conversationId || undefined}
|
||||
currentUserId={currentUserId}
|
||||
isPinned={false}
|
||||
onClose={handleCloseGroupInfoPanel}
|
||||
onTogglePin={(pinned) => {
|
||||
// TODO: 实现置顶功能
|
||||
console.log('Toggle pin:', pinned);
|
||||
}}
|
||||
onLeaveGroup={() => {
|
||||
// TODO: 实现退出群聊功能
|
||||
console.log('Leave group');
|
||||
}}
|
||||
onInviteMembers={() => {
|
||||
// TODO: 实现邀请新成员功能
|
||||
console.log('Invite members');
|
||||
}}
|
||||
onViewAllMembers={() => {
|
||||
// TODO: 实现查看全部成员功能
|
||||
console.log('View all members');
|
||||
}}
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -215,6 +215,8 @@ const CreateGroupScreen: React.FC = () => {
|
||||
placeholder="请输入群名称"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
maxLength={50}
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.charCount}>
|
||||
{groupName.length}/50
|
||||
@@ -238,6 +240,8 @@ const CreateGroupScreen: React.FC = () => {
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.textAreaCharCount}>
|
||||
{groupDescription.length}/500
|
||||
|
||||
@@ -110,6 +110,8 @@ const JoinGroupScreen: React.FC = () => {
|
||||
style={styles.input}
|
||||
editable={!searching && !joining}
|
||||
autoCapitalize="none"
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.searchBtn, (!keyword.trim() || searching || joining) && styles.submitBtnDisabled]}
|
||||
|
||||
@@ -40,6 +40,8 @@ import { RootStackParamList, MessageStackParamList } from '../../navigation/type
|
||||
import { getUserCache } from '../../services/database';
|
||||
// 导入 EmbeddedChat 组件用于桌面端双栏布局
|
||||
import { EmbeddedChat } from './components/EmbeddedChat';
|
||||
// 导入 NotificationsScreen 用于在内部显示
|
||||
import { NotificationsScreen } from './NotificationsScreen';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
type MessageNavProp = NativeStackNavigationProp<MessageStackParamList>;
|
||||
@@ -140,7 +142,15 @@ export const MessageListScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp & MessageNavProp>();
|
||||
const isFocused = useIsFocused();
|
||||
const insets = useSafeAreaInsets();
|
||||
const tabBarHeight = useBottomTabBarHeight();
|
||||
// 在大屏幕模式下不使用 Bottom Tab Navigator,所以不需要 tabBarHeight
|
||||
const { isMobile } = useResponsive();
|
||||
let tabBarHeight = 0;
|
||||
try {
|
||||
tabBarHeight = useBottomTabBarHeight();
|
||||
} catch (e) {
|
||||
// 大屏幕模式下不在 Bottom Tab Navigator 中,会抛出错误,忽略即可
|
||||
tabBarHeight = 0;
|
||||
}
|
||||
const currentUserId = useAuthStore(state => state.currentUser?.id);
|
||||
const setMessageUnreadCount = useUserStore(state => state.setMessageUnreadCount);
|
||||
|
||||
@@ -173,6 +183,30 @@ export const MessageListScreen: React.FC = () => {
|
||||
const [activeSearchTab, setActiveSearchTab] = useState<'chat' | 'user'>('chat');
|
||||
const [actionMenuVisible, setActionMenuVisible] = useState(false);
|
||||
|
||||
// 系统通知显示状态 - 用于在移动端显示通知页面
|
||||
const [showNotifications, setShowNotifications] = useState(false);
|
||||
|
||||
// 系统消息会话对象(用于选中状态)
|
||||
const systemMessageConversation: ConversationResponse = useMemo(() => ({
|
||||
id: SYSTEM_MESSAGE_CHANNEL_ID,
|
||||
type: 'private',
|
||||
last_seq: 0,
|
||||
last_message: {
|
||||
id: '0',
|
||||
conversation_id: SYSTEM_MESSAGE_CHANNEL_ID,
|
||||
sender_id: 'system',
|
||||
seq: 0,
|
||||
segments: [{ type: 'text', data: { text: '系统通知' } }],
|
||||
status: 'normal',
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
last_message_at: new Date().toISOString(),
|
||||
unread_count: systemUnreadCount,
|
||||
participants: [],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
}), [systemUnreadCount]);
|
||||
|
||||
// 动画值
|
||||
const [scaleAnims] = useState(() =>
|
||||
Array.from({ length: 20 }, () => new Animated.Value(1))
|
||||
@@ -272,19 +306,19 @@ export const MessageListScreen: React.FC = () => {
|
||||
}),
|
||||
]).start(() => {
|
||||
if (conversation.id === SYSTEM_MESSAGE_CHANNEL_ID) {
|
||||
// 跳转到系统通知页面 - 使用正确的导航路径
|
||||
// 系统通知 - 根据屏幕宽度决定如何显示
|
||||
if (isWideScreen) {
|
||||
// 宽屏下直接导航到通知页面
|
||||
navigation.navigate('Notifications' as never);
|
||||
// 宽屏下使用内部状态显示通知页面,同时设置系统消息为选中状态
|
||||
setSelectedConversation(systemMessageConversation);
|
||||
setShowNotifications(true);
|
||||
} else {
|
||||
// 窄屏下使用 Tab 导航
|
||||
(navigation as any).navigate('Main', {
|
||||
screen: 'MessageTab',
|
||||
params: { screen: 'Notifications' }
|
||||
});
|
||||
// 窄屏下也使用内部状态显示通知页面
|
||||
setShowNotifications(true);
|
||||
}
|
||||
} else if (isWideScreen) {
|
||||
// 【桌面端双栏布局】宽屏下设置选中会话,在右侧显示聊天
|
||||
// 同时关闭系统通知页面
|
||||
setShowNotifications(false);
|
||||
setSelectedConversation(conversation);
|
||||
} else if (conversation.type === 'group' && conversation.group) {
|
||||
// 群聊 - 窄屏下导航
|
||||
@@ -765,6 +799,9 @@ export const MessageListScreen: React.FC = () => {
|
||||
onChangeText={setSearchText}
|
||||
autoFocus
|
||||
returnKeyType="search"
|
||||
// 确保光标可见
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
{searchText.length > 0 && (
|
||||
<TouchableOpacity onPress={() => setSearchText('')}>
|
||||
@@ -903,7 +940,10 @@ export const MessageListScreen: React.FC = () => {
|
||||
|
||||
{/* 右侧内容区域 - 使用 flex:1 填充剩余空间 */}
|
||||
<View style={styles.chatArea}>
|
||||
{selectedConversation ? (
|
||||
{showNotifications ? (
|
||||
// 显示系统通知页面
|
||||
<NotificationsScreen onBack={() => setShowNotifications(false)} />
|
||||
) : selectedConversation ? (
|
||||
// 显示选中会话的聊天内容
|
||||
<EmbeddedChat
|
||||
conversation={selectedConversation}
|
||||
@@ -927,16 +967,17 @@ export const MessageListScreen: React.FC = () => {
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{isSearchMode ? (
|
||||
{showNotifications ? (
|
||||
// 显示系统通知页面,传入 onBack 回调
|
||||
<NotificationsScreen onBack={() => setShowNotifications(false)} />
|
||||
) : isSearchMode ? (
|
||||
renderSearchMode()
|
||||
) : (
|
||||
isWideScreen ? (
|
||||
) : isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={800}>
|
||||
{renderConversationList()}
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
renderConversationList()
|
||||
)
|
||||
)}
|
||||
{renderActionMenu()}
|
||||
</SafeAreaView>
|
||||
@@ -1101,7 +1142,7 @@ const styles = StyleSheet.create({
|
||||
...shadows.sm,
|
||||
},
|
||||
conversationItemSelected: {
|
||||
backgroundColor: '#E3F2FD',
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
borderColor: colors.primary.main,
|
||||
borderWidth: 1,
|
||||
},
|
||||
|
||||
@@ -13,18 +13,19 @@ import {
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
ActivityIndicator,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useIsFocused } from '@react-navigation/native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing } from '../../theme';
|
||||
import { SystemMessageResponse } from '../../types/dto';
|
||||
import { messageService } from '../../services/messageService';
|
||||
import { commentService } from '../../services/commentService';
|
||||
import { SystemMessageItem } from '../../components/business';
|
||||
import { Text, EmptyState, ResponsiveContainer } from '../../components/common';
|
||||
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { useMessageManagerSystemUnreadCount, useUserStore } from '../../stores';
|
||||
|
||||
@@ -41,15 +42,33 @@ const MESSAGE_TYPES = [
|
||||
const LIKE_SYSTEM_TYPES = new Set(['like_post', 'like_comment', 'like_reply', 'favorite_post']);
|
||||
const GROUP_SYSTEM_TYPES = new Set(['group_invite', 'group_join_apply', 'group_join_approved', 'group_join_rejected']);
|
||||
|
||||
export const NotificationsScreen: React.FC = () => {
|
||||
export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack }) => {
|
||||
const isFocused = useIsFocused();
|
||||
const fetchMessageUnreadCount = useUserStore(state => state.fetchMessageUnreadCount);
|
||||
const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount();
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
|
||||
// 响应式布局
|
||||
const { isDesktop, isTablet } = useResponsive();
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
// 修复竞态条件:使用本地状态管理窗口尺寸,避免 hydration 问题
|
||||
const [windowSize, setWindowSize] = useState({ width: 0, height: 0 });
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 延迟设置窗口尺寸,确保在客户端完全加载后再获取
|
||||
const timer = setTimeout(() => {
|
||||
const { width, height } = Dimensions.get('window');
|
||||
setWindowSize({ width, height });
|
||||
setIsHydrated(true);
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
// 使用本地尺寸状态计算断点 (lg = 768)
|
||||
const isWideScreen = windowSize.width >= 768;
|
||||
const isDesktop = windowSize.width >= 1024;
|
||||
const isTablet = windowSize.width >= 768 && windowSize.width < 1024;
|
||||
|
||||
// Web端使用更大的容器宽度
|
||||
const containerMaxWidth = isDesktop ? 1200 : isTablet ? 1000 : 900;
|
||||
|
||||
const [messages, setMessages] = useState<SystemMessageResponse[]>([]);
|
||||
const [activeType, setActiveType] = useState('all');
|
||||
@@ -148,16 +167,12 @@ export const NotificationsScreen: React.FC = () => {
|
||||
}
|
||||
}, [isFocused, fetchMessages, fetchUnreadCount, handleMarkAllRead]);
|
||||
|
||||
// 屏幕失去焦点时返回消息列表
|
||||
// 屏幕失去焦点时,如果有 onBack 回调则调用它(用于内嵌模式)
|
||||
useEffect(() => {
|
||||
if (!isFocused) {
|
||||
// 使用 setTimeout 确保在导航状态稳定后再执行
|
||||
const timer = setTimeout(() => {
|
||||
navigation.goBack();
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
if (!isFocused && onBack) {
|
||||
onBack();
|
||||
}
|
||||
}, [isFocused, navigation]);
|
||||
}, [isFocused, onBack]);
|
||||
|
||||
// 筛选消息
|
||||
const filteredMessages = activeType === 'all'
|
||||
@@ -317,21 +332,62 @@ export const NotificationsScreen: React.FC = () => {
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染头部(包含返回按钮)
|
||||
const renderHeader = () => {
|
||||
// 大屏模式(>= lg 断点)隐藏返回按钮
|
||||
const shouldShowBackButton = onBack && !isWideScreen;
|
||||
|
||||
return (
|
||||
<View style={[styles.header, isWideScreen ? styles.headerWide : null]}>
|
||||
{shouldShowBackButton ? (
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={onBack}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="arrow-left" size={24} color={colors.text.primary} />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.backButtonPlaceholder} />
|
||||
)}
|
||||
<Text style={[styles.headerTitle, ...(isWideScreen ? [styles.headerTitleWide] : [])]}>
|
||||
系统通知
|
||||
</Text>
|
||||
<View style={styles.backButtonPlaceholder} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmpty = () => (
|
||||
<View style={[styles.emptyContainer, isWideScreen && styles.emptyContainerWeb]}>
|
||||
<EmptyState
|
||||
title="暂无通知"
|
||||
description="关注一些用户来获取通知吧"
|
||||
icon="bell-outline"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 如果还未hydrated,显示加载占位符,避免竞态条件导致的渲染问题
|
||||
if (!isHydrated) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={[]}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={[]}>
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={900}>
|
||||
<ResponsiveContainer maxWidth={containerMaxWidth}>
|
||||
{/* 头部 - 大屏模式下隐藏返回按钮 */}
|
||||
{renderHeader()}
|
||||
{/* 分类筛选 */}
|
||||
<View style={[styles.filterContainer, isWideScreen && styles.filterContainerWide]}>
|
||||
<View style={[styles.filterContainer, isWideScreen && styles.filterContainerWideWeb]}>
|
||||
{MESSAGE_TYPES.map(type => {
|
||||
const count = type.key === 'all'
|
||||
? displayMessages.length
|
||||
@@ -403,6 +459,8 @@ export const NotificationsScreen: React.FC = () => {
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<>
|
||||
{/* 头部 - 小屏模式下显示返回按钮 */}
|
||||
{renderHeader()}
|
||||
{/* 分类筛选 */}
|
||||
<View style={styles.filterContainer}>
|
||||
{MESSAGE_TYPES.map(type => {
|
||||
@@ -491,10 +549,11 @@ const styles = StyleSheet.create({
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
filterContainerWide: {
|
||||
filterContainerWideWeb: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.md,
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
filterTag: {
|
||||
flexDirection: 'row',
|
||||
@@ -516,6 +575,38 @@ const styles = StyleSheet.create({
|
||||
filterCount: {
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
// Header 样式
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
headerWide: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.lg,
|
||||
},
|
||||
backButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
backButtonPlaceholder: {
|
||||
width: 40,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
headerTitleWide: {
|
||||
fontSize: 20,
|
||||
},
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
@@ -537,4 +628,14 @@ const styles = StyleSheet.create({
|
||||
loadingMoreText: {
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
emptyContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
emptyContainerWeb: {
|
||||
minHeight: 500,
|
||||
justifyContent: 'center',
|
||||
paddingTop: 100,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -22,10 +22,15 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
onBack,
|
||||
onTitlePress,
|
||||
onMorePress,
|
||||
onGroupInfoPress,
|
||||
isWideScreen: propIsWideScreen,
|
||||
}) => {
|
||||
// 响应式布局
|
||||
const { width } = useResponsive();
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
// 使用 768px 作为大屏幕和小屏幕的分界线
|
||||
const isWideScreenHook = useBreakpointGTE('lg');
|
||||
// 优先使用 props 传入的 isWideScreen,否则使用 hook
|
||||
const isWideScreen = propIsWideScreen !== undefined ? propIsWideScreen : isWideScreenHook;
|
||||
|
||||
// 合并样式
|
||||
const styles = useMemo(() => {
|
||||
@@ -67,12 +72,17 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
return (
|
||||
<SafeAreaView edges={['top']} style={styles.headerContainer}>
|
||||
<View style={styles.header}>
|
||||
{/* 大屏幕(>= 768px)时隐藏返回按钮 */}
|
||||
{!isWideScreen ? (
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={onBack}
|
||||
>
|
||||
<MaterialCommunityIcons name="arrow-left" size={22} color={colors.primary.dark} />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.backButton} />
|
||||
)}
|
||||
|
||||
<View style={styles.headerCenter}>
|
||||
<TouchableOpacity
|
||||
@@ -115,12 +125,22 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 大屏幕 + 群聊时显示群信息按钮,否则显示三点菜单 */}
|
||||
{isWideScreen && isGroupChat && onGroupInfoPress ? (
|
||||
<TouchableOpacity
|
||||
style={styles.moreButton}
|
||||
onPress={onGroupInfoPress}
|
||||
>
|
||||
<MaterialCommunityIcons name="dots-horizontal" size={22} color="#667085" />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={styles.moreButton}
|
||||
onPress={onMorePress}
|
||||
>
|
||||
<MaterialCommunityIcons name="dots-horizontal" size={22} color="#667085" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
@@ -134,6 +134,9 @@ export const ChatInput: React.FC<ChatInputProps & {
|
||||
blurOnSubmit={false}
|
||||
editable={!isDisabled}
|
||||
onFocus={onFocus}
|
||||
// 确保光标可见
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
</View>
|
||||
|
||||
|
||||
593
src/screens/message/components/ChatScreen/GroupInfoPanel.tsx
Normal file
593
src/screens/message/components/ChatScreen/GroupInfoPanel.tsx
Normal file
@@ -0,0 +1,593 @@
|
||||
/**
|
||||
* GroupInfoPanel.tsx
|
||||
* 群信息侧边栏组件
|
||||
* 大屏幕模式下从右侧滑入显示
|
||||
* 实现手机端群信息界面的核心功能
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
Dimensions,
|
||||
Animated,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Avatar, Text } from '../../../../components/common';
|
||||
import { colors, spacing, fontSizes, shadows } from '../../../../theme';
|
||||
import { GroupMemberResponse, GroupResponse, GroupAnnouncementResponse } from '../../../../types/dto';
|
||||
import { useBreakpointGTE } from '../../../../hooks/useResponsive';
|
||||
import { groupManager } from '../../../../stores/groupManager';
|
||||
import { groupService } from '../../../../services/groupService';
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
const PANEL_WIDTH = 360;
|
||||
|
||||
interface GroupInfoPanelProps {
|
||||
visible: boolean;
|
||||
groupId?: string;
|
||||
groupInfo: GroupResponse | null;
|
||||
conversationId?: string;
|
||||
isPinned?: boolean;
|
||||
currentUserId?: string;
|
||||
onClose: () => void;
|
||||
onTogglePin?: (pinned: boolean) => void;
|
||||
onLeaveGroup?: () => void;
|
||||
onInviteMembers?: () => void;
|
||||
onViewAllMembers?: () => void;
|
||||
}
|
||||
|
||||
export const GroupInfoPanel: React.FC<GroupInfoPanelProps> = ({
|
||||
visible,
|
||||
groupId,
|
||||
groupInfo,
|
||||
conversationId,
|
||||
isPinned,
|
||||
currentUserId,
|
||||
onClose,
|
||||
onTogglePin,
|
||||
onLeaveGroup,
|
||||
onInviteMembers,
|
||||
onViewAllMembers,
|
||||
}) => {
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
const slideAnim = React.useRef(new Animated.Value(PANEL_WIDTH)).current;
|
||||
const fadeAnim = React.useRef(new Animated.Value(0)).current;
|
||||
const [members, setMembers] = useState<GroupMemberResponse[]>([]);
|
||||
const [announcements, setAnnouncements] = useState<GroupAnnouncementResponse[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showAllMembers, setShowAllMembers] = useState(false);
|
||||
const [allMembers, setAllMembers] = useState<GroupMemberResponse[]>([]);
|
||||
const [loadingMoreMembers, setLoadingMoreMembers] = useState(false);
|
||||
|
||||
// 加载群成员(初始只加载10个)
|
||||
const loadMembers = useCallback(async () => {
|
||||
if (!groupId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await groupManager.getMembers(groupId, 1, 10);
|
||||
setMembers(response.list || []);
|
||||
} catch (error) {
|
||||
console.error('[GroupInfoPanel] 加载成员失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [groupId]);
|
||||
|
||||
// 加载全部群成员
|
||||
const loadAllMembers = useCallback(async () => {
|
||||
if (!groupId) return;
|
||||
try {
|
||||
setLoadingMoreMembers(true);
|
||||
const response = await groupManager.getMembers(groupId, 1, 100);
|
||||
setAllMembers(response.list || []);
|
||||
setShowAllMembers(true);
|
||||
} catch (error) {
|
||||
console.error('[GroupInfoPanel] 加载全部成员失败:', error);
|
||||
} finally {
|
||||
setLoadingMoreMembers(false);
|
||||
}
|
||||
}, [groupId]);
|
||||
|
||||
// 加载群公告
|
||||
const loadAnnouncements = useCallback(async () => {
|
||||
if (!groupId) return;
|
||||
try {
|
||||
const response = await groupService.getAnnouncements(groupId, 1, 10);
|
||||
setAnnouncements(response.list || []);
|
||||
} catch (error) {
|
||||
console.error('[GroupInfoPanel] 加载公告失败:', error);
|
||||
}
|
||||
}, [groupId]);
|
||||
|
||||
// 每次打开面板时重置状态
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setShowAllMembers(false);
|
||||
setAllMembers([]);
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && groupId) {
|
||||
loadMembers();
|
||||
loadAnnouncements();
|
||||
}
|
||||
}, [visible, groupId, loadMembers, loadAnnouncements]);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: PANEL_WIDTH,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, slideAnim, fadeAnim]);
|
||||
|
||||
if (!isWideScreen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 获取加群方式文本
|
||||
const getJoinTypeText = (joinType?: number): string => {
|
||||
switch (joinType) {
|
||||
case 0:
|
||||
return '允许任何人加入';
|
||||
case 1:
|
||||
return '需要管理员审批';
|
||||
case 2:
|
||||
return '不允许任何人加入';
|
||||
default:
|
||||
return '未知';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 遮罩层 */}
|
||||
{visible && (
|
||||
<Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
|
||||
<TouchableOpacity
|
||||
style={styles.overlayTouchable}
|
||||
onPress={onClose}
|
||||
activeOpacity={1}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* 侧边栏面板 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.panel,
|
||||
{
|
||||
transform: [{ translateX: slideAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* 头部 */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerTitle}>群信息</Text>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<MaterialCommunityIcons name="close" size={24} color="#333" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||
{/* 群头像和名称 */}
|
||||
<View style={styles.groupInfoSection}>
|
||||
<Avatar
|
||||
source={groupInfo?.avatar}
|
||||
size={80}
|
||||
name={groupInfo?.name || '群聊'}
|
||||
/>
|
||||
<Text style={styles.groupName}>{groupInfo?.name || '群聊'}</Text>
|
||||
<Text style={styles.memberCount}>
|
||||
{groupInfo?.member_count || members.length || 0} 位成员
|
||||
</Text>
|
||||
{groupInfo?.join_type !== undefined && (
|
||||
<Text style={styles.joinType}>
|
||||
{getJoinTypeText(groupInfo.join_type)}
|
||||
</Text>
|
||||
)}
|
||||
{/* 邀请新成员按钮 */}
|
||||
{onInviteMembers && (
|
||||
<TouchableOpacity
|
||||
style={styles.inviteButton}
|
||||
onPress={onInviteMembers}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="account-plus" size={18} color={colors.primary.main} />
|
||||
<Text style={styles.inviteButtonText}>邀请新成员</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 分割线 */}
|
||||
<View style={styles.divider} />
|
||||
|
||||
{/* 群公告 */}
|
||||
{announcements.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialCommunityIcons name="bullhorn" size={18} color={colors.warning.main} />
|
||||
<Text style={styles.sectionTitle}>群公告</Text>
|
||||
</View>
|
||||
<View style={styles.noticeBox}>
|
||||
<Text style={styles.noticeText}>{announcements[0].content}</Text>
|
||||
<Text style={styles.noticeTime}>
|
||||
{new Date(announcements[0].created_at).toLocaleDateString()}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 群简介 */}
|
||||
{groupInfo?.description && (
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>群简介</Text>
|
||||
<View style={styles.descriptionBox}>
|
||||
<Text style={styles.description}>{groupInfo.description}</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 群成员列表 */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.memberListHeader}>
|
||||
<Text style={styles.sectionTitle}>成员列表 ({groupInfo?.member_count || members.length})</Text>
|
||||
{onViewAllMembers && !showAllMembers && members.length >= 10 && (
|
||||
<TouchableOpacity onPress={loadAllMembers} style={styles.viewAllButton} disabled={loadingMoreMembers}>
|
||||
<Text style={styles.viewAllText}>
|
||||
{loadingMoreMembers ? '加载中...' : '查看全部'}
|
||||
</Text>
|
||||
<MaterialCommunityIcons name="chevron-right" size={16} color={colors.primary.main} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{onViewAllMembers && showAllMembers && (
|
||||
<TouchableOpacity onPress={() => setShowAllMembers(false)} style={styles.viewAllButton}>
|
||||
<Text style={styles.viewAllText}>收起</Text>
|
||||
<MaterialCommunityIcons name="chevron-up" size={16} color={colors.primary.main} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.memberList}>
|
||||
{(showAllMembers ? allMembers : members).map((member) => (
|
||||
<View key={member.id || member.user_id} style={styles.memberItem}>
|
||||
<Avatar
|
||||
source={member.user?.avatar}
|
||||
size={44}
|
||||
name={member.nickname || member.user?.nickname || '用户'}
|
||||
/>
|
||||
<View style={styles.memberInfo}>
|
||||
<Text style={styles.memberName} numberOfLines={1}>
|
||||
{member.nickname || member.user?.nickname || '用户'}
|
||||
</Text>
|
||||
{member.role === 'owner' && (
|
||||
<Text style={styles.ownerBadge}>群主</Text>
|
||||
)}
|
||||
{member.role === 'admin' && (
|
||||
<Text style={styles.adminBadge}>管理员</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 群信息 */}
|
||||
<View style={styles.section}>
|
||||
<Text style={styles.sectionTitle}>群信息</Text>
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.infoLabel}>群号</Text>
|
||||
<Text style={styles.infoValue}>{groupInfo?.id || '-'}</Text>
|
||||
</View>
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.infoLabel}>创建时间</Text>
|
||||
<Text style={styles.infoValue}>
|
||||
{groupInfo?.created_at
|
||||
? new Date(groupInfo.created_at).toLocaleDateString('zh-CN')
|
||||
: '-'}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={styles.infoLabel}>全员禁言</Text>
|
||||
<Text style={styles.infoValue}>
|
||||
{groupInfo?.mute_all ? '已开启' : '已关闭'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View style={styles.actionSection}>
|
||||
{conversationId && onTogglePin && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() => onTogglePin(!isPinned)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={isPinned ? "pin-off" : "pin"}
|
||||
size={22}
|
||||
color={colors.primary.main}
|
||||
/>
|
||||
<Text style={styles.actionButtonText}>
|
||||
{isPinned ? '取消置顶' : '置顶群聊'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{onLeaveGroup && (
|
||||
<TouchableOpacity
|
||||
style={[styles.actionButton, styles.leaveButton]}
|
||||
onPress={onLeaveGroup}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="logout"
|
||||
size={22}
|
||||
color={colors.error.main}
|
||||
/>
|
||||
<Text style={[styles.actionButtonText, styles.leaveButtonText]}>
|
||||
退出群聊
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
</Animated.View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
zIndex: 100,
|
||||
},
|
||||
overlayTouchable: {
|
||||
flex: 1,
|
||||
},
|
||||
panel: {
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: PANEL_WIDTH,
|
||||
backgroundColor: '#FFF',
|
||||
zIndex: 101,
|
||||
...shadows.lg,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E8E8E8',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: fontSizes.xl,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
},
|
||||
closeButton: {
|
||||
padding: spacing.xs,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
groupInfoSection: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xl,
|
||||
},
|
||||
groupName: {
|
||||
fontSize: fontSizes.xl,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
memberCount: {
|
||||
fontSize: fontSizes.md,
|
||||
color: '#999',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
joinType: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.primary.main,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
inviteButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
backgroundColor: colors.primary.light + '15',
|
||||
borderRadius: 20,
|
||||
marginTop: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary.light,
|
||||
},
|
||||
inviteButtonText: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.primary.main,
|
||||
marginLeft: spacing.xs,
|
||||
fontWeight: '500',
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: '#E8E8E8',
|
||||
marginHorizontal: spacing.md,
|
||||
},
|
||||
section: {
|
||||
padding: spacing.md,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
noticeBox: {
|
||||
backgroundColor: '#FFF9E6',
|
||||
padding: spacing.md,
|
||||
borderRadius: 8,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.warning.main,
|
||||
},
|
||||
noticeText: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: '#666',
|
||||
lineHeight: 20,
|
||||
},
|
||||
noticeTime: {
|
||||
fontSize: fontSizes.xs,
|
||||
color: '#999',
|
||||
marginTop: spacing.xs,
|
||||
textAlign: 'right',
|
||||
},
|
||||
descriptionBox: {
|
||||
backgroundColor: '#F5F7FA',
|
||||
padding: spacing.md,
|
||||
borderRadius: 8,
|
||||
},
|
||||
description: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: '#666',
|
||||
lineHeight: 20,
|
||||
},
|
||||
memberList: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
memberListHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
viewAllButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
viewAllText: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.primary.main,
|
||||
},
|
||||
memberItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
memberInfo: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.sm,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
memberName: {
|
||||
fontSize: fontSizes.md,
|
||||
color: '#333',
|
||||
flex: 1,
|
||||
},
|
||||
ownerBadge: {
|
||||
fontSize: fontSizes.xs,
|
||||
color: colors.warning.main,
|
||||
marginLeft: spacing.xs,
|
||||
backgroundColor: colors.warning.light + '30',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
adminBadge: {
|
||||
fontSize: fontSizes.xs,
|
||||
color: colors.primary.main,
|
||||
marginLeft: spacing.xs,
|
||||
backgroundColor: colors.primary.light + '30',
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: 4,
|
||||
},
|
||||
moreMembers: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: '#999',
|
||||
textAlign: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
infoRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#F0F0F0',
|
||||
},
|
||||
infoLabel: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: '#999',
|
||||
},
|
||||
infoValue: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: '#333',
|
||||
},
|
||||
actionSection: {
|
||||
padding: spacing.md,
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
backgroundColor: colors.primary.light + '15',
|
||||
borderRadius: 12,
|
||||
marginBottom: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary.light,
|
||||
},
|
||||
actionButtonText: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
color: colors.primary.main,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
leaveButton: {
|
||||
backgroundColor: colors.error.light + '15',
|
||||
borderColor: colors.error.light,
|
||||
},
|
||||
leaveButtonText: {
|
||||
color: colors.error.main,
|
||||
},
|
||||
});
|
||||
|
||||
export default GroupInfoPanel;
|
||||
@@ -130,9 +130,22 @@ const ImageSegment: React.FC<{
|
||||
}> = ({ data, isMe, onImagePress, onImageLongPress }) => {
|
||||
const pressPositionRef = useRef({ x: 0, y: 0 });
|
||||
const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null);
|
||||
const [loadError, setLoadError] = useState(false);
|
||||
const imageUrl = data.thumbnail_url || data.url;
|
||||
|
||||
// 如果没有有效的图片URL,显示图片占位符
|
||||
const hasValidUrl = !!imageUrl && imageUrl.trim() !== '';
|
||||
|
||||
useEffect(() => {
|
||||
// 重置状态
|
||||
setLoadError(false);
|
||||
|
||||
// 如果没有有效的图片URL,设置默认尺寸用于显示占位符
|
||||
if (!hasValidUrl) {
|
||||
setDimensions(IMAGE_FALLBACK_SIZE);
|
||||
return;
|
||||
}
|
||||
|
||||
// 如果已经有宽高信息,直接使用
|
||||
if (data.width && data.height) {
|
||||
const aspectRatio = data.width / data.height;
|
||||
@@ -191,7 +204,7 @@ const ImageSegment: React.FC<{
|
||||
);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [imageUrl, data.width, data.height]);
|
||||
}, [imageUrl, data.width, data.height, hasValidUrl]);
|
||||
|
||||
// 记录按压位置
|
||||
const handlePressIn = (event: GestureResponderEvent) => {
|
||||
@@ -204,6 +217,35 @@ const ImageSegment: React.FC<{
|
||||
onImageLongPress?.(pressPositionRef.current);
|
||||
};
|
||||
|
||||
// 没有有效URL或加载失败时显示占位符
|
||||
if (!hasValidUrl || loadError) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={`image-${data.url || Math.random()}`}
|
||||
style={[styles.imageContainer, isMe ? styles.imageMe : styles.imageOther]}
|
||||
onPressIn={handlePressIn}
|
||||
onPress={() => hasValidUrl && onImagePress?.(data.url)}
|
||||
onLongPress={handleLongPress}
|
||||
delayLongPress={500}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: IMAGE_FALLBACK_SIZE.width,
|
||||
height: IMAGE_FALLBACK_SIZE.height,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<MaterialCommunityIcons name="image" size={32} color="#999" />
|
||||
<Text style={{ color: '#999', fontSize: 12, marginTop: 4 }}>图片</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
// 初始加载时显示占位
|
||||
if (!dimensions) {
|
||||
return (
|
||||
@@ -248,6 +290,9 @@ const ImageSegment: React.FC<{
|
||||
contentFit="cover"
|
||||
cachePolicy="disk"
|
||||
priority="normal"
|
||||
onError={() => {
|
||||
setLoadError(true);
|
||||
}}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ export { LongPressMenu } from './LongPressMenu';
|
||||
export { ChatHeader } from './ChatHeader';
|
||||
export { MessageBubble } from './MessageBubble';
|
||||
export { ChatInput } from './ChatInput';
|
||||
export { GroupInfoPanel } from './GroupInfoPanel';
|
||||
|
||||
// Segment 渲染组件
|
||||
export {
|
||||
|
||||
@@ -322,13 +322,18 @@ export const chatScreenStyles = StyleSheet.create({
|
||||
inputContainer: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderWidth: 1,
|
||||
borderColor: `${colors.divider}88`,
|
||||
borderColor: colors.divider,
|
||||
borderRadius: 24,
|
||||
marginHorizontal: 14,
|
||||
marginBottom: 12,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
...shadows.lg,
|
||||
// 移除明显的阴影效果,改用更微妙的边框
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
inputInner: {
|
||||
flexDirection: 'row',
|
||||
@@ -338,6 +343,12 @@ export const chatScreenStyles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 4,
|
||||
minHeight: 44,
|
||||
// 移除阴影效果
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
inputInnerMuted: {
|
||||
backgroundColor: '#F0F0F0',
|
||||
@@ -395,6 +406,12 @@ export const chatScreenStyles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.xs,
|
||||
maxHeight: 100,
|
||||
justifyContent: 'center',
|
||||
// 移除阴影
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
input: {
|
||||
fontSize: 16,
|
||||
|
||||
@@ -163,6 +163,10 @@ export interface ChatHeaderProps {
|
||||
onBack: () => void;
|
||||
onTitlePress: () => void;
|
||||
onMorePress: () => void;
|
||||
/** 大屏幕下显示群信息面板的回调 */
|
||||
onGroupInfoPress?: () => void;
|
||||
/** 是否为大屏幕模式 */
|
||||
isWideScreen?: boolean;
|
||||
}
|
||||
|
||||
// 回复预览 Props
|
||||
|
||||
@@ -14,26 +14,40 @@ import {
|
||||
Platform,
|
||||
TextInput,
|
||||
Dimensions,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { colors, spacing, fontSizes, shadows } from '../../../theme';
|
||||
import { ConversationResponse, MessageResponse, MessageSegment } from '../../../types/dto';
|
||||
import { Avatar, Text } from '../../../components/common';
|
||||
import { ConversationResponse, MessageResponse, MessageSegment, GroupMemberResponse, ImageSegmentData } from '../../../types/dto';
|
||||
import { Avatar, Text, ImageGallery } from '../../../components/common';
|
||||
import { useAuthStore, messageManager, MessageEvent, MessageSubscriber } from '../../../stores';
|
||||
import { extractTextFromSegments } from '../../../types/dto';
|
||||
import { useBreakpointGTE } from '../../../hooks/useResponsive';
|
||||
import { useMarkAsRead } from '../../../stores/messageManagerHooks';
|
||||
import { messageService } from '../../../services';
|
||||
import { GroupInfoPanel } from './ChatScreen/GroupInfoPanel';
|
||||
import { LongPressMenu, MenuPosition, GroupMessage } from './ChatScreen';
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
const PANEL_WIDTH = 360;
|
||||
|
||||
interface EmbeddedChatProps {
|
||||
conversation: ConversationResponse;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
|
||||
export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack }) => {
|
||||
const navigation = useNavigation();
|
||||
const currentUser = useAuthStore(state => state.currentUser);
|
||||
|
||||
// 响应式布局 - 使用 768px 作为大屏幕和小屏幕的分界线
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
|
||||
// 使用 markAsRead hook
|
||||
const { markAsRead } = useMarkAsRead(String(conversation.id));
|
||||
|
||||
// 状态
|
||||
const [messages, setMessages] = useState<MessageResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -42,6 +56,61 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
// 图片预览状态
|
||||
const [imageGalleryVisible, setImageGalleryVisible] = useState(false);
|
||||
const [currentImageIndex, setCurrentImageIndex] = useState(0);
|
||||
const [galleryImages, setGalleryImages] = useState<{ url: string }[]>([]);
|
||||
|
||||
// 右键菜单状态
|
||||
const [contextMenuVisible, setContextMenuVisible] = useState(false);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState<MenuPosition | undefined>();
|
||||
const [selectedMessageForMenu, setSelectedMessageForMenu] = useState<GroupMessage | null>(null);
|
||||
|
||||
// 回复状态
|
||||
const [replyingToMessage, setReplyingToMessage] = useState<GroupMessage | null>(null);
|
||||
|
||||
// 群信息面板动画
|
||||
const slideAnim = useRef(new Animated.Value(PANEL_WIDTH)).current;
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const [showGroupInfoPanel, setShowGroupInfoPanel] = useState(false);
|
||||
|
||||
// 群成员列表
|
||||
const [groupMembers, setGroupMembers] = useState<GroupMemberResponse[]>([]);
|
||||
|
||||
// 打开/关闭群信息面板
|
||||
const toggleGroupInfoPanel = useCallback(() => {
|
||||
if (showGroupInfoPanel) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: PANEL_WIDTH,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start(() => {
|
||||
setShowGroupInfoPanel(false);
|
||||
});
|
||||
} else {
|
||||
setShowGroupInfoPanel(true);
|
||||
Animated.parallel([
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [showGroupInfoPanel, slideAnim, fadeAnim]);
|
||||
|
||||
// 获取会话信息
|
||||
const isGroupChat = conversation.type === 'group';
|
||||
const chatTitle = isGroupChat
|
||||
@@ -88,6 +157,18 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
||||
};
|
||||
}, [conversation.id, loadMessages]);
|
||||
|
||||
// 自动标记已读 - 当有新消息时
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) return;
|
||||
|
||||
const maxSeq = Math.max(...messages.map(m => m.seq), 0);
|
||||
if (maxSeq > 0) {
|
||||
markAsRead(maxSeq).catch(error => {
|
||||
console.error('[EmbeddedChat] 自动标记已读失败:', error);
|
||||
});
|
||||
}
|
||||
}, [messages, markAsRead]);
|
||||
|
||||
// 发送消息
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!inputText.trim() || sending) return;
|
||||
@@ -127,12 +208,175 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
||||
}
|
||||
};
|
||||
|
||||
// 处理图片点击预览
|
||||
const handleImagePress = (images: { url: string }[], index: number) => {
|
||||
setGalleryImages(images);
|
||||
setCurrentImageIndex(index);
|
||||
setImageGalleryVisible(true);
|
||||
};
|
||||
|
||||
// 处理右键点击消息
|
||||
const handleMessageRightPress = (message: MessageResponse, event: any) => {
|
||||
// 获取鼠标右键位置
|
||||
const pageX = event?.nativeEvent?.pageX || 0;
|
||||
const pageY = event?.nativeEvent?.pageY || 0;
|
||||
|
||||
setSelectedMessageForMenu(message as GroupMessage);
|
||||
setContextMenuPosition({
|
||||
x: pageX,
|
||||
y: pageY,
|
||||
width: 0,
|
||||
height: 0,
|
||||
pressX: pageX,
|
||||
pressY: pageY,
|
||||
});
|
||||
setContextMenuVisible(true);
|
||||
};
|
||||
|
||||
// 关闭右键菜单
|
||||
const handleContextMenuClose = () => {
|
||||
setContextMenuVisible(false);
|
||||
setSelectedMessageForMenu(null);
|
||||
setContextMenuPosition(undefined);
|
||||
};
|
||||
|
||||
// 处理回复消息
|
||||
const handleReply = (message: GroupMessage) => {
|
||||
setReplyingToMessage(message);
|
||||
handleContextMenuClose();
|
||||
};
|
||||
|
||||
// 处理撤回消息
|
||||
const handleRecall = async (messageId: string) => {
|
||||
try {
|
||||
await messageService.recallMessage(messageId);
|
||||
handleContextMenuClose();
|
||||
} catch (error) {
|
||||
console.error('[EmbeddedChat] 撤回消息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理删除消息
|
||||
const handleDeleteMessage = async (messageId: string) => {
|
||||
try {
|
||||
await messageService.deleteMessage(messageId);
|
||||
handleContextMenuClose();
|
||||
} catch (error) {
|
||||
console.error('[EmbeddedChat] 删除消息失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染消息内容
|
||||
const renderMessageContent = (item: MessageResponse) => {
|
||||
// 撤回消息
|
||||
if (item.status === 'recalled') {
|
||||
return (
|
||||
<Text style={[styles.messageText, styles.messageTextRecalled]}>
|
||||
消息已撤回
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// 获取消息 segments
|
||||
const segments = item.segments || [];
|
||||
|
||||
// 检查是否有图片消息
|
||||
const imageSegments = segments
|
||||
.filter(s => s.type === 'image')
|
||||
.map(s => s.data as ImageSegmentData);
|
||||
|
||||
// 提取文本内容
|
||||
const textContent = extractTextFromSegments(segments);
|
||||
|
||||
// 渲染多个内容(文本 + 图片)
|
||||
return (
|
||||
<View>
|
||||
{/* 文本内容 */}
|
||||
{textContent && textContent !== '[图片]' && (
|
||||
<Text style={[styles.messageText, item.sender_id === String(currentUser?.id) ? styles.messageTextMe : styles.messageTextOther]}>
|
||||
{textContent}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 图片内容 */}
|
||||
{imageSegments.length > 0 && (
|
||||
<View style={styles.imageGrid}>
|
||||
{imageSegments.map((imgData, idx) => {
|
||||
const imageUrl = imgData.thumbnail_url || imgData.url;
|
||||
const hasValidUrl = !!imageUrl && imageUrl.trim() !== '';
|
||||
|
||||
if (!hasValidUrl) {
|
||||
// 无效URL显示占位符
|
||||
return (
|
||||
<View key={`img-${item.id}-${idx}`} style={styles.imagePlaceholder}>
|
||||
<MaterialCommunityIcons name="image" size={24} color="#999" />
|
||||
<Text style={styles.imagePlaceholderText}>图片</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 计算图片尺寸
|
||||
let width = 180;
|
||||
let height = 180;
|
||||
if (imgData.width && imgData.height) {
|
||||
const aspectRatio = imgData.width / imgData.height;
|
||||
if (aspectRatio > 1) {
|
||||
height = width / aspectRatio;
|
||||
} else {
|
||||
width = height * aspectRatio;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={`img-${item.id}-${idx}`}
|
||||
onPress={() => handleImagePress(imageSegments.map(s => ({ url: s.thumbnail_url || s.url || '' })), idx)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<ExpoImage
|
||||
source={{ uri: imageUrl }}
|
||||
style={[styles.messageImage, { width, height: Math.round(height) }]}
|
||||
contentFit="cover"
|
||||
cachePolicy="disk"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 如果没有文本也没有图片,显示空消息提示 */}
|
||||
{!textContent && imageSegments.length === 0 && (
|
||||
<Text style={[styles.messageText, styles.messageTextRecalled, { opacity: 0.5 }]}>
|
||||
[消息格式错误]
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染消息
|
||||
const renderMessage = ({ item }: { item: MessageResponse }) => {
|
||||
const isMe = String(item.sender_id) === String(currentUser?.id);
|
||||
|
||||
// 处理指针事件(鼠标右键)- 使用 onPointerDown 检测鼠标右键
|
||||
const handlePointerDown = (e: any) => {
|
||||
// e.nativeEvent.button: 0=左键, 1=中键, 2=右键
|
||||
const isRightClick = e.nativeEvent?.button === 2;
|
||||
if (isRightClick) {
|
||||
// 阻止默认右键菜单
|
||||
e.preventDefault?.();
|
||||
e.stopPropagation?.();
|
||||
handleMessageRightPress(item, e);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.messageRow, isMe ? styles.messageRowRight : styles.messageRowLeft]}>
|
||||
<View
|
||||
style={[styles.messageRow, isMe ? styles.messageRowRight : styles.messageRowLeft]}
|
||||
// @ts-ignore - React Native for Web 支持 pointer events
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
{!isMe && (
|
||||
<Avatar
|
||||
source={item.sender?.avatar}
|
||||
@@ -144,9 +388,7 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
||||
{isGroupChat && !isMe && (
|
||||
<Text style={styles.senderName}>{item.sender?.nickname || item.sender?.username}</Text>
|
||||
)}
|
||||
<Text style={[styles.messageText, isMe ? styles.messageTextMe : styles.messageTextOther]}>
|
||||
{item.status === 'recalled' ? '消息已撤回' : extractTextFromSegments(item.segments)}
|
||||
</Text>
|
||||
{renderMessageContent(item)}
|
||||
</View>
|
||||
{isMe && (
|
||||
<Avatar
|
||||
@@ -163,9 +405,14 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
||||
<View style={styles.container}>
|
||||
{/* 头部 */}
|
||||
<View style={styles.header}>
|
||||
{/* 大屏幕(>= 768px)时隐藏返回按钮 */}
|
||||
{!isWideScreen ? (
|
||||
<TouchableOpacity onPress={onBack} style={styles.headerButton}>
|
||||
<MaterialCommunityIcons name="arrow-left" size={24} color="#666" />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.headerButton} />
|
||||
)}
|
||||
|
||||
<View style={styles.headerCenter}>
|
||||
<Avatar source={chatAvatar} size={36} name={chatTitle} />
|
||||
@@ -174,9 +421,16 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 大屏幕 + 群聊时显示群信息按钮,否则显示放大按钮 */}
|
||||
{isWideScreen && isGroupChat ? (
|
||||
<TouchableOpacity onPress={toggleGroupInfoPanel} style={styles.headerButton}>
|
||||
<MaterialCommunityIcons name="dots-horizontal" size={22} color="#666" />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity onPress={handleNavigateToFullChat} style={styles.headerButton}>
|
||||
<MaterialCommunityIcons name="arrow-expand" size={22} color="#666" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 消息列表 */}
|
||||
@@ -231,6 +485,9 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
||||
returnKeyType="send"
|
||||
onSubmitEditing={handleSend}
|
||||
blurOnSubmit={false}
|
||||
// 确保光标可见
|
||||
cursorColor={colors.primary.main}
|
||||
selectionColor={`${colors.primary.main}40`}
|
||||
/>
|
||||
</View>
|
||||
|
||||
@@ -252,6 +509,96 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* 群信息侧边栏面板 - 仅大屏幕显示 */}
|
||||
{isWideScreen && isGroupChat && (
|
||||
<>
|
||||
{/* 遮罩层 */}
|
||||
{showGroupInfoPanel && (
|
||||
<Animated.View
|
||||
style={[
|
||||
StyleSheet.absoluteFillObject,
|
||||
{
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
opacity: fadeAnim,
|
||||
zIndex: 100,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
onPress={toggleGroupInfoPanel}
|
||||
activeOpacity={1}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
|
||||
{/* 侧边栏面板 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: 'absolute',
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: PANEL_WIDTH,
|
||||
backgroundColor: '#FFF',
|
||||
zIndex: 101,
|
||||
...shadows.lg,
|
||||
transform: [{ translateX: slideAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<GroupInfoPanel
|
||||
visible={showGroupInfoPanel}
|
||||
groupId={conversation.group ? String(conversation.group.id) : undefined}
|
||||
groupInfo={conversation.group || null}
|
||||
currentUserId={currentUser?.id}
|
||||
onClose={toggleGroupInfoPanel}
|
||||
onTogglePin={(pinned) => {
|
||||
// TODO: 实现置顶功能
|
||||
console.log('Toggle pin:', pinned);
|
||||
}}
|
||||
onLeaveGroup={() => {
|
||||
// TODO: 实现退出群聊功能
|
||||
console.log('Leave group');
|
||||
}}
|
||||
onInviteMembers={() => {
|
||||
// TODO: 实现邀请新成员功能
|
||||
console.log('Invite members');
|
||||
}}
|
||||
onViewAllMembers={() => {
|
||||
// TODO: 实现查看全部成员功能
|
||||
console.log('View all members');
|
||||
}}
|
||||
/>
|
||||
</Animated.View>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 右键菜单 */}
|
||||
<LongPressMenu
|
||||
visible={contextMenuVisible}
|
||||
message={selectedMessageForMenu}
|
||||
currentUserId={currentUser?.id || ''}
|
||||
position={contextMenuPosition}
|
||||
onClose={handleContextMenuClose}
|
||||
onReply={handleReply}
|
||||
onRecall={handleRecall}
|
||||
onDelete={handleDeleteMessage}
|
||||
/>
|
||||
|
||||
{/* 图片查看器 */}
|
||||
<ImageGallery
|
||||
visible={imageGalleryVisible}
|
||||
images={galleryImages.map((img, idx) => ({
|
||||
id: String(idx),
|
||||
url: img.url || ''
|
||||
}))}
|
||||
initialIndex={currentImageIndex}
|
||||
onClose={() => setImageGalleryVisible(false)}
|
||||
enableSave
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -328,7 +675,7 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
messageRowRight: {
|
||||
alignSelf: 'flex-end',
|
||||
flexDirection: 'row-reverse',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
messageBubble: {
|
||||
maxWidth: screenWidth * 0.5,
|
||||
@@ -387,6 +734,12 @@ const styles = StyleSheet.create({
|
||||
paddingHorizontal: spacing.md,
|
||||
minHeight: 40,
|
||||
justifyContent: 'center',
|
||||
// 移除阴影
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
textInput: {
|
||||
fontSize: 15,
|
||||
@@ -406,4 +759,33 @@ const styles = StyleSheet.create({
|
||||
sendButtonDisabled: {
|
||||
backgroundColor: '#E0E0E0',
|
||||
},
|
||||
messageTextRecalled: {
|
||||
fontStyle: 'italic',
|
||||
color: '#999',
|
||||
},
|
||||
imageGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
marginTop: 4,
|
||||
},
|
||||
messageImage: {
|
||||
borderRadius: 8,
|
||||
marginRight: 4,
|
||||
marginBottom: 4,
|
||||
},
|
||||
imagePlaceholder: {
|
||||
width: 120,
|
||||
height: 120,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 4,
|
||||
marginBottom: 4,
|
||||
},
|
||||
imagePlaceholderText: {
|
||||
color: '#999',
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -37,7 +37,14 @@ const TAB_ICONS = ['file-document-outline', 'bookmark-outline'];
|
||||
export const ProfileScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const tabBarHeight = useBottomTabBarHeight();
|
||||
// 在大屏幕模式下不使用 Bottom Tab Navigator,所以 try-catch 处理
|
||||
let tabBarHeight = 0;
|
||||
try {
|
||||
tabBarHeight = useBottomTabBarHeight();
|
||||
} catch (e) {
|
||||
// 大屏幕模式下不在 Bottom Tab Navigator 中,会抛出错误,忽略即可
|
||||
tabBarHeight = 0;
|
||||
}
|
||||
const homeNavigation = useNavigation<HomeNavigationProp>();
|
||||
// 使用 any 类型来访问根导航
|
||||
const rootNavigation = useNavigation<RootNavigationProp>();
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
TimeSlot,
|
||||
getCourseColor,
|
||||
hasCourseInWeek,
|
||||
getCurrentWeek,
|
||||
} from '../../types/schedule';
|
||||
import type { ScheduleStackParamList } from '../../navigation/types';
|
||||
import { scheduleService } from '../../services/scheduleService';
|
||||
@@ -130,9 +131,17 @@ const getWeekDates = (weekOffset: number = 0) => {
|
||||
return getWeekInfo(weekOffset).dates;
|
||||
};
|
||||
|
||||
// 学期起始日期字符串(用于 getCurrentWeek 函数)
|
||||
const SEMESTER_START_DATE = '2026-03-09';
|
||||
|
||||
// 计算当前实际周数
|
||||
const getInitialWeek = (): number => {
|
||||
return getCurrentWeek(SEMESTER_START_DATE, TOTAL_WEEKS);
|
||||
};
|
||||
|
||||
export const ScheduleScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NativeStackNavigationProp<ScheduleStackParamList>>();
|
||||
const [currentWeek, setCurrentWeek] = useState(1);
|
||||
const [currentWeek, setCurrentWeek] = useState(getInitialWeek);
|
||||
const [courses, setCourses] = useState<Course[]>([]);
|
||||
const [isAddModalVisible, setIsAddModalVisible] = useState(false);
|
||||
const [pendingDay, setPendingDay] = useState<number>(0);
|
||||
@@ -163,8 +172,10 @@ export const ScheduleScreen: React.FC = () => {
|
||||
};
|
||||
}, []);
|
||||
const todayColumnIndex = getTodayColumnIndex();
|
||||
// currentWeek === 1 对应今天所在的真实周(offset 0),其他周不高亮今日列
|
||||
const isViewingCurrentWeek = currentWeek === 1;
|
||||
// 获取当前实际周数,用于判断是否在查看当前周
|
||||
const actualCurrentWeek = getInitialWeek();
|
||||
// 只有在查看当前实际周时才高亮今日列
|
||||
const isViewingCurrentWeek = currentWeek === actualCurrentWeek;
|
||||
const activeTodayColumn = isViewingCurrentWeek ? todayColumnIndex : -1;
|
||||
|
||||
const dayColumnWidth = getDayColumnWidth();
|
||||
|
||||
@@ -45,7 +45,17 @@ interface SyncScheduleResponse {
|
||||
error_message?: string;
|
||||
}
|
||||
|
||||
const toCourse = (dto: ScheduleCourseDTO): Course => ({
|
||||
const toCourse = (dto: ScheduleCourseDTO): Course => {
|
||||
// 调试日志:检查后端返回的 weeks 字段
|
||||
console.log('[ScheduleService] 转换课程 DTO:', {
|
||||
id: dto.id,
|
||||
name: dto.name,
|
||||
day_of_week: dto.day_of_week,
|
||||
weeks: dto.weeks,
|
||||
weeksType: typeof dto.weeks,
|
||||
weeksLength: dto.weeks?.length,
|
||||
});
|
||||
return {
|
||||
id: dto.id,
|
||||
name: dto.name,
|
||||
teacher: dto.teacher,
|
||||
@@ -55,12 +65,22 @@ const toCourse = (dto: ScheduleCourseDTO): Course => ({
|
||||
endSection: dto.end_section,
|
||||
weeks: dto.weeks ?? [],
|
||||
color: dto.color,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
class ScheduleService {
|
||||
async getCourses(week?: number): Promise<Course[]> {
|
||||
const params = week ? { week } : undefined;
|
||||
console.log('[ScheduleService] 获取课程列表, params:', params);
|
||||
const response = await api.get<ScheduleCourseListResponse>('/schedule/courses', params);
|
||||
console.log('[ScheduleService] 课程列表响应, 数量:', response.data.list.length);
|
||||
// 打印原始响应数据中的 weeks 字段
|
||||
response.data.list.forEach((item, index) => {
|
||||
console.log(`[ScheduleService] 课程[${index}] ${item.name}:`, {
|
||||
day_of_week: item.day_of_week,
|
||||
weeks: item.weeks,
|
||||
});
|
||||
});
|
||||
return response.data.list.map(toCourse);
|
||||
}
|
||||
|
||||
@@ -79,7 +99,9 @@ class ScheduleService {
|
||||
}
|
||||
|
||||
async syncSchedule(req: SyncScheduleRequest): Promise<SyncScheduleResponse> {
|
||||
console.log('[ScheduleService] 开始同步教务系统, username:', req.username);
|
||||
const response = await api.post<SyncScheduleResponse>('/schedule/sync', req);
|
||||
console.log('[ScheduleService] 同步响应:', response.data);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
// ==================== 颜色系统 ====================
|
||||
export const colors = {
|
||||
primary: {
|
||||
main: '#FF6B35', // 胡萝卜橙
|
||||
light: '#FF8F66',
|
||||
dark: '#E5521D',
|
||||
main: '#FF6B35', // 橙色(主色)
|
||||
light: '#FFB733',
|
||||
dark: '#CC8400',
|
||||
contrast: '#FFFFFF',
|
||||
},
|
||||
secondary: {
|
||||
@@ -16,6 +16,18 @@ export const colors = {
|
||||
light: '#80E27E',
|
||||
dark: '#087F23',
|
||||
},
|
||||
neutral: {
|
||||
main: '#6B7280', // 中性灰(辅助色)
|
||||
light: '#9CA3AF',
|
||||
dark: '#374151',
|
||||
bg: '#F3F4F6', // 浅灰背景
|
||||
bgDark: '#1F2937', // 深灰背景
|
||||
},
|
||||
accent: {
|
||||
main: '#E57373', // 低饱和红(点缀色)
|
||||
light: '#FFCDD2',
|
||||
dark: '#C62828',
|
||||
},
|
||||
background: {
|
||||
default: '#F5F5F5',
|
||||
paper: '#FFFFFF',
|
||||
@@ -73,6 +85,8 @@ export const spacing = {
|
||||
'2xl': 24,
|
||||
'3xl': 32,
|
||||
'4xl': 40,
|
||||
'5xl': 48,
|
||||
'6xl': 56,
|
||||
};
|
||||
|
||||
// ==================== 圆角系统 ====================
|
||||
|
||||
33
src/utils/inputStyles.ts
Normal file
33
src/utils/inputStyles.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 输入框辅助样式
|
||||
* 用于处理React Native Web在浏览器中的默认focus outline样式
|
||||
*/
|
||||
|
||||
/**
|
||||
* 移除浏览器默认focus outline的样式
|
||||
* 这个样式可以覆盖浏览器原生的黑框/蓝框
|
||||
*/
|
||||
export const noFocusOutline = {
|
||||
// 移除浏览器默认的outline(针对Web)
|
||||
outlineWidth: 0,
|
||||
outline: 'none' as const,
|
||||
outlineColor: 'transparent',
|
||||
// 兼容React Native Web
|
||||
':focus': {
|
||||
outlineWidth: 0,
|
||||
outline: 'none',
|
||||
},
|
||||
// Webkit浏览器兼容
|
||||
'::placeholder': {
|
||||
outlineWidth: 0,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 为TextInput添加无outline样式
|
||||
*/
|
||||
export const inputNoFocusOutline = {
|
||||
...noFocusOutline,
|
||||
// 确保placeholder也没有outline
|
||||
placeholderTextColor: undefined, // 会在使用时覆盖
|
||||
};
|
||||
Reference in New Issue
Block a user