PC端的部分适配。登录,注册,首页导航栏竞态条件,搜索栏,系统消息解耦,群聊部分界面 #2

Merged
lan merged 3 commits from feature/adap-pc into master 2026-03-17 14:45:35 +08:00
38 changed files with 4830 additions and 464 deletions

8
.idea/.gitignore generated vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -4,7 +4,7 @@
*/ */
import React, { useEffect, useRef } from 'react'; 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 { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler'; import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { SafeAreaProvider } from 'react-native-safe-area-context'; import { SafeAreaProvider } from 'react-native-safe-area-context';
@@ -42,6 +42,34 @@ const queryClient = new QueryClient({
installAlertOverride(); installAlertOverride();
// 注入全局CSS来移除React Native Web在浏览器中的默认focus outline
if (Platform.OS === 'web') {
const style = document.createElement('style');
style.textContent = `
/* 移除TextInput focus时的浏览器默认outline */
input:focus, textarea:focus, select:focus {
outline: none !important;
outline-width: 0 !important;
box-shadow: none !important;
}
/* 移除focus-visible时的默认outline */
input:focus-visible, textarea:focus-visible {
outline: none !important;
}
/* 移除所有:focus相关的默认样式 */
*:focus {
outline: none !important;
}
*:focus-visible {
outline: none !important;
}
`;
document.head.appendChild(style);
}
export default function App() { export default function App() {
const appState = useRef<AppStateStatus>(AppState.currentState); const appState = useRef<AppStateStatus>(AppState.currentState);
const notificationResponseListener = useRef<Notifications.EventSubscription | null>(null); const notificationResponseListener = useRef<Notifications.EventSubscription | null>(null);

BIN
screenshots/after-fix.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -59,6 +59,9 @@ const SearchBar: React.FC<SearchBarProps> = ({
onFocus={handleFocus} onFocus={handleFocus}
onBlur={handleBlur} onBlur={handleBlur}
autoFocus={autoFocus} autoFocus={autoFocus}
// 确保光标可见
cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`}
/> />
{value.length > 0 && ( {value.length > 0 && (
<TouchableOpacity <TouchableOpacity
@@ -87,18 +90,22 @@ const styles = StyleSheet.create({
height: 46, height: 46,
borderWidth: 1, borderWidth: 1,
borderColor: '#E7E7E7', borderColor: '#E7E7E7',
shadowColor: '#000', // 减少常态阴影,避免看起来像"黑框"
shadowOffset: { width: 0, height: 2 }, shadowColor: 'transparent',
shadowOpacity: 0.09, shadowOffset: { width: 0, height: 0 },
shadowRadius: 6, shadowOpacity: 0,
elevation: 2, shadowRadius: 0,
elevation: 0,
}, },
containerFocused: { containerFocused: {
borderColor: `${colors.primary.main}66`, // 焦点时使用更柔和的边框颜色
shadowColor: colors.primary.main, borderColor: colors.primary.main,
shadowOpacity: 0.18, // 不添加额外的阴影,保持简洁
shadowRadius: 10, shadowColor: 'transparent',
elevation: 4, shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
}, },
searchIconWrap: { searchIconWrap: {
width: 30, width: 30,

View File

@@ -189,6 +189,99 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
requestStatus === 'pending' && requestStatus === 'pending' &&
!!onRequestAction; !!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( const showOperatorAvatar = ['follow', 'like_post', 'like_comment', 'like_reply', 'favorite_post', 'comment', 'reply', 'mention', 'group_join_apply'].includes(
message.system_type message.system_type
@@ -207,19 +300,19 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
{showGroupAvatar ? ( {showGroupAvatar ? (
<Avatar <Avatar
source={groupAvatar || ''} source={groupAvatar || ''}
size={44} size={avatarSize}
name={extra_data?.group_name || '群聊'} name={extra_data?.group_name || '群聊'}
/> />
) : showOperatorAvatar ? ( ) : showOperatorAvatar ? (
<Avatar <Avatar
source={operatorAvatar || ''} source={operatorAvatar || ''}
size={44} size={avatarSize}
name={operatorName} name={operatorName}
onPress={onAvatarPress} onPress={onAvatarPress}
/> />
) : ( ) : (
<View style={[styles.iconWrapper, { backgroundColor: bgColor }]}> <View style={[styles.iconWrapper, { backgroundColor: bgColor }]}>
<MaterialCommunityIcons name={icon} size={22} color={color} /> <MaterialCommunityIcons name={icon} size={iconSize} color={color} />
</View> </View>
)} )}
</View> </View>
@@ -231,7 +324,7 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
<Text variant="body" style={styles.title} numberOfLines={1}> <Text variant="body" style={styles.title} numberOfLines={1}>
{title} {title}
</Text> </Text>
<Text variant="caption" color={colors.text.secondary}> <Text variant="caption" color={colors.text.secondary} style={styles.time}>
{formatTime(message.created_at)} {formatTime(message.created_at)}
</Text> </Text>
</View> </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; export default SystemMessageItem;

View File

@@ -105,6 +105,9 @@ const Input: React.FC<InputProps> = ({
autoCapitalize={autoCapitalize} autoCapitalize={autoCapitalize}
keyboardType={keyboardType} keyboardType={keyboardType}
autoCorrect={autoCorrect} autoCorrect={autoCorrect}
// 确保光标可见
cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`}
/> />
{rightIcon && ( {rightIcon && (
<TouchableOpacity <TouchableOpacity
@@ -143,6 +146,8 @@ const styles = StyleSheet.create({
borderWidth: 1, borderWidth: 1,
borderRadius: borderRadius.md, borderRadius: borderRadius.md,
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
// 减少非焦点状态的边框明显度
borderColor: colors.divider,
}, },
input: { input: {
flex: 1, flex: 1,

View File

@@ -35,6 +35,7 @@ import { colors, shadows } from '../theme';
import { useAuthStore, useTotalUnreadCount } from '../stores'; import { useAuthStore, useTotalUnreadCount } from '../stores';
import { messageManager } from '../stores'; import { messageManager } from '../stores';
import { useResponsive } from '../hooks'; import { useResponsive } from '../hooks';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { import type {
RootStackParamList, RootStackParamList,
MainTabParamList, MainTabParamList,
@@ -44,6 +45,7 @@ import type {
ProfileStackParamList, ProfileStackParamList,
AuthStackParamList, AuthStackParamList,
} from './types'; } from './types';
import { SimpleMobileTabNavigator } from './SimpleMobileTabNavigator';
// ==================== 导入屏幕组件 ==================== // ==================== 导入屏幕组件 ====================
import { HomeScreen, PostDetailScreen, SearchScreen } from '../screens/home'; import { HomeScreen, PostDetailScreen, SearchScreen } from '../screens/home';
@@ -81,6 +83,9 @@ const SIDEBAR_WIDTH_TABLET = 200;
const SIDEBAR_COLLAPSED_WIDTH = 72; const SIDEBAR_COLLAPSED_WIDTH = 72;
const MOBILE_TAB_FLOATING_MARGIN = 12; const MOBILE_TAB_FLOATING_MARGIN = 12;
// ==================== 持久化存储键 ====================
const LAST_ACTIVE_TAB_KEY = 'last_active_tab';
// ==================== 导航项类型 ==================== // ==================== 导航项类型 ====================
type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab'; type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab';
@@ -306,6 +311,18 @@ function Sidebar({
}: SidebarProps) { }: SidebarProps) {
const insets = useSafeAreaInsets(); 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[] = [ const navItems: NavItem[] = [
{ name: 'HomeTab', label: '首页', icon: 'home', iconOutline: 'home-outline' }, { name: 'HomeTab', label: '首页', icon: 'home', iconOutline: 'home-outline' },
{ name: 'MessageTab', label: '消息', icon: 'message-text', iconOutline: 'message-text-outline', badge: unreadCount }, { name: 'MessageTab', label: '消息', icon: 'message-text', iconOutline: 'message-text-outline', badge: unreadCount },
@@ -461,12 +478,13 @@ interface ResponsiveTabNavigatorProps {
function ResponsiveTabNavigator({ children }: ResponsiveTabNavigatorProps) { function ResponsiveTabNavigator({ children }: ResponsiveTabNavigatorProps) {
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>(); const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
const route = useRoute<RouteProp<RootStackParamList, 'Main'>>(); const route = useRoute<RouteProp<RootStackParamList, 'Main'>>();
const { isMobile, isTablet, isDesktop } = useResponsive(); const { isMobile, isTablet, isDesktop, width, height } = useResponsive();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const [activeTab, setActiveTab] = useState<TabName>('HomeTab'); const [activeTab, setActiveTab] = useState<TabName>('HomeTab');
const [isCollapsed, setIsCollapsed] = useState(false); const [isCollapsed, setIsCollapsed] = useState(false);
const messageUnreadCount = useTotalUnreadCount(); const messageUnreadCount = useTotalUnreadCount();
// 【新架构】MessageManager 自动处理 WebSocket 消息和未读数更新 // 【新架构】MessageManager 自动处理 WebSocket 消息和未读数更新
useEffect(() => { useEffect(() => {
messageManager.initialize(); messageManager.initialize();
@@ -483,54 +501,62 @@ function ResponsiveTabNavigator({ children }: ResponsiveTabNavigatorProps) {
// 计算侧边栏宽度 // 计算侧边栏宽度
const sidebarWidth = useCallback(() => { const sidebarWidth = useCallback(() => {
if (isMobile) return 0; // 移动端不显示侧边栏
if (isCollapsed) return SIDEBAR_COLLAPSED_WIDTH; if (isCollapsed) return SIDEBAR_COLLAPSED_WIDTH;
if (isDesktop) return SIDEBAR_WIDTH_DESKTOP; if (isDesktop) return SIDEBAR_WIDTH_DESKTOP;
if (isTablet) return SIDEBAR_WIDTH_TABLET; if (isTablet) return SIDEBAR_WIDTH_TABLET;
return 0; // 默认返回桌面宽度,避免返回 0 导致导航栏消失
}, [isCollapsed, isDesktop, isTablet]); return SIDEBAR_WIDTH_DESKTOP;
}, [isCollapsed, isDesktop, isTablet, isMobile]);
// 切换 Tab // 切换 Tab
const handleTabChange = useCallback((tab: TabName) => { const handleTabChange = useCallback((tab: TabName) => {
setActiveTab(tab); setActiveTab(tab);
if (Platform.OS === 'web') { // 在大屏幕模式下,通过导航切换 Tab
if (!isMobile) {
let tabTarget: NavigatorScreenParams<MainTabParamList>; let tabTarget: NavigatorScreenParams<MainTabParamList>;
if (tab === 'HomeTab') { if (tab === 'HomeTab') {
tabTarget = { screen: 'HomeTab', params: { screen: 'Home' } }; tabTarget = { screen: 'HomeTab', params: { screen: 'Home' } };
} else if (tab === 'MessageTab') { } else if (tab === 'MessageTab') {
tabTarget = { screen: 'MessageTab', params: { screen: 'MessageList' } }; tabTarget = { screen: 'MessageTab', params: { screen: 'MessageList' } };
} else if (tab === 'ScheduleTab') {
tabTarget = { screen: 'ScheduleTab', params: { screen: 'Schedule' } };
} else { } else {
tabTarget = { screen: 'ProfileTab', params: { screen: 'Profile' } }; tabTarget = { screen: 'ProfileTab', params: { screen: 'Profile' } };
} }
navigation.navigate('Main', tabTarget); navigation.navigate('Main', tabTarget);
} }
}, [navigation]); }, [navigation, isMobile]);
// 切换折叠状态 // 切换折叠状态
const handleToggleCollapse = useCallback(() => { const handleToggleCollapse = useCallback(() => {
setIsCollapsed(prev => !prev); setIsCollapsed(prev => !prev);
}, []); }, []);
// 渲染当前 Tab 的内容 // 移动端:使用底部 Tab 导航
if (isMobile) {
// 使用简化的移动端导航,避免 React Navigation 状态恢复问题
return <SimpleMobileTabNavigator />;
}
// 渲染当前 Tab 的内容(仅在大屏模式下使用)
// 注意:直接渲染屏幕组件,不使用 Stack Navigators
// 这样可以避免 React Navigation 状态恢复问题
const renderTabContent = () => { const renderTabContent = () => {
switch (activeTab) { switch (activeTab) {
case 'HomeTab': case 'HomeTab':
return <HomeStackNavigator />; return <HomeScreen />;
case 'MessageTab': case 'MessageTab':
return <MessageStackNavigator />; return <MessageListScreen />;
case 'ScheduleTab': case 'ScheduleTab':
return <ScheduleStackNavigator />; return <ScheduleScreen />;
case 'ProfileTab': case 'ProfileTab':
return <ProfileStackNavigator />; return <ProfileScreen />;
default: default:
return <HomeStackNavigator />; return <HomeScreen />;
} }
}; };
// 移动端:使用底部 Tab 导航
if (isMobile) {
return <MainTabNavigator />;
}
// 平板/桌面端:使用侧边导航 // 平板/桌面端:使用侧边导航
const currentSidebarWidth = sidebarWidth(); const currentSidebarWidth = sidebarWidth();

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,545 @@
/**
* 移动端 Tab Navigator完全独立版本
*
* 这个组件完全独立于 MainNavigator.tsx 中的其他导航组件,
* 用于解决从大屏切换到小屏时的白屏问题。
*
* 问题根源:
* - React Navigation 的 TabNavigator 在初始化时需要完整的 navigation state
* - 从大屏Sidebar切换到小屏BottomTabstate 可能还没有准备好
* - 导致 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 }],
},
});

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

View File

@@ -1,9 +1,10 @@
/** /**
* 登录页 LoginScreen响应式适配 * 登录页 LoginScreen响应式适配 - 分栏布局
* 胡萝卜BBS - 用户登录 * 胡萝卜BBS - 用户登录
* 现代化设计 - 渐变背景 + 动画效果 *
* 登录表单在桌面端居中显示,限制最大宽度 * 布局规则:
* 支持横屏模式下的布局调整 * - 屏幕宽度 < 768px单栏布局橙色渐变背景
* - 屏幕宽度 >= 768px分栏布局左侧橙色右侧中性灰
*/ */
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
@@ -18,6 +19,7 @@ import {
ScrollView, ScrollView,
ActivityIndicator, ActivityIndicator,
Animated, Animated,
StatusBar,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
@@ -28,11 +30,13 @@ import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme';
import { useAuthStore } from '../../stores'; import { useAuthStore } from '../../stores';
import { RootStackParamList } from '../../navigation/types'; import { RootStackParamList } from '../../navigation/types';
import { useResponsive, useResponsiveValue } from '../../hooks'; import { useResponsive, useResponsiveValue } from '../../hooks';
import { ResponsiveContainer } from '../../components/common';
import { showPrompt } from '../../services/promptService'; import { showPrompt } from '../../services/promptService';
type LoginNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>; type LoginNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>;
// 分栏布局断点
const SPLIT_BREAKPOINT = 768;
export const LoginScreen: React.FC = () => { export const LoginScreen: React.FC = () => {
const navigation = useNavigation<LoginNavigationProp>(); const navigation = useNavigation<LoginNavigationProp>();
const login = useAuthStore((state) => state.login); const login = useAuthStore((state) => state.login);
@@ -40,7 +44,10 @@ export const LoginScreen: React.FC = () => {
const setStoreError = useAuthStore((state) => state.setError); 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 [username, setUsername] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -54,11 +61,23 @@ export const LoginScreen: React.FC = () => {
const scaleAnim = useRef(new Animated.Value(0.9)).current; const scaleAnim = useRef(new Animated.Value(0.9)).current;
const inputFadeAnim = useRef(new Animated.Value(0)).current; const inputFadeAnim = useRef(new Animated.Value(0)).current;
// 响应式值 // 响应式值 - 大屏模式单独放大Logo尺寸更显大气
const logoSize = useResponsiveValue({ xs: 64, sm: 72, md: 80, lg: 96, xl: 110 }); const logoSize = useResponsiveValue({
const logoContainerSize = useResponsiveValue({ xs: 110, sm: 120, md: 130, lg: 150, xl: 170 }); xs: 64, sm: 72, md: 80,
const appNameSize = useResponsiveValue({ xs: 28, sm: 30, md: 32, lg: 36, xl: 40 }); lg: isSplitLayout ? 140 : 96, // 大屏模式Logo图标进一步放大
const formMaxWidth = isDesktop ? 480 : isWideScreen ? 420 : undefined; xl: isSplitLayout ? 160 : 110
});
const logoContainerSize = useResponsiveValue({
xs: 110, sm: 120, md: 130,
lg: isSplitLayout ? 200 : 150, // 大屏模式Logo容器进一步放大
xl: isSplitLayout ? 220 : 170
});
const appNameSize = useResponsiveValue({
xs: 28, sm: 30, md: 32,
lg: isSplitLayout ? 60 : 36, // 大屏模式标题文字大幅放大
xl: isSplitLayout ? 72 : 40
});
const formMaxWidth = isSplitLayout ? 420 : undefined;
// 启动入场动画 // 启动入场动画
useEffect(() => { useEffect(() => {
@@ -105,7 +124,7 @@ export const LoginScreen: React.FC = () => {
return true; return true;
}; };
// store error 同步到本地,方便在输入时清除 // store error 同步到本地
useEffect(() => { useEffect(() => {
if (storeError) { if (storeError) {
setErrorMsg(storeError); setErrorMsg(storeError);
@@ -126,8 +145,6 @@ export const LoginScreen: React.FC = () => {
try { try {
const success = await login({ username, password }); const success = await login({ username, password });
if (!success) { if (!success) {
// authStore 已将错误写入 storeErroruseEffect 会同步到 errorMsg
// 若 storeError 为空则显示通用提示
if (!useAuthStore.getState().error) { if (!useAuthStore.getState().error) {
setErrorMsg('登录失败,请稍后重试'); setErrorMsg('登录失败,请稍后重试');
} }
@@ -144,39 +161,66 @@ export const LoginScreen: React.FC = () => {
navigation.navigate('Register' as any); navigation.navigate('Register' as any);
}; };
// 渲染左侧面板Logo和品牌信息- 优化大屏布局
const renderLeftPanel = () => (
<Animated.View
style={[
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
name="carrot"
size={logoSize}
color="#FFF"
/>
</View>
{/* 品牌名称 - 优化大屏排版 */}
<Text style={[styles.appName, isSplitLayout && styles.splitAppName]}></Text>
<Text style={[styles.subtitle, isSplitLayout && styles.splitSubtitle]}></Text>
{/* 装饰元素 - 大屏模式改为水平排列 + 大幅放大 */}
<View style={[styles.leftPanelDecorBottom, isSplitLayout && styles.splitLeftPanelDecorBottom]}>
{isSplitLayout ? (
// 大屏模式:水平排列 + 大幅放大图标
<View style={styles.splitDecorRow}>
<MaterialCommunityIcons name="chat-outline" size={80} color="rgba(255,255,255,0.4)" style={styles.splitDecorIcon} />
<MaterialCommunityIcons name="account-group-outline" size={90} color="rgba(255,255,255,0.45)" style={styles.splitDecorIcon} />
<MaterialCommunityIcons name="bell-outline" size={85} color="rgba(255,255,255,0.35)" style={styles.splitDecorIcon} />
</View>
) : (
// 小屏模式:保持原有横向排列
<View style={styles.decorRow}>
<MaterialCommunityIcons name="chat-outline" size={48} color="rgba(255,255,255,0.35)" />
<MaterialCommunityIcons name="account-group-outline" size={44} color="rgba(255,255,255,0.3)" />
<MaterialCommunityIcons name="bell-outline" size={40} color="rgba(255,255,255,0.25)" />
</View>
)}
</View>
</Animated.View>
);
// 渲染表单内容 // 渲染表单内容
const renderFormContent = () => ( const renderFormContent = () => (
<> <View style={isSplitLayout ? styles.splitFormWrapper : styles.singleFormWrapper}>
{/* Logo和标题区域 */}
<Animated.View
style={[
styles.headerSection,
isLandscape && styles.headerSectionLandscape,
{
opacity: fadeAnim,
transform: [{ scale: scaleAnim }],
},
]}
>
<View style={[
styles.logoContainer,
{ width: logoContainerSize, height: logoContainerSize, borderRadius: logoContainerSize / 2 }
]}>
<MaterialCommunityIcons
name="carrot"
size={logoSize}
color="#FFF"
/>
</View>
<Text style={[styles.appName, { fontSize: appNameSize }]}></Text>
<Text style={styles.subtitle}></Text>
</Animated.View>
{/* 表单卡片 */}
<Animated.View <Animated.View
style={[ style={[
styles.formCard, styles.formCard,
formMaxWidth && { maxWidth: formMaxWidth, alignSelf: 'center', width: '100%' }, // 核心修改:大屏模式使用专用的阴影样式,小屏用原有阴影
isSplitLayout ? styles.splitFormCardShadow : styles.normalFormCardShadow,
formMaxWidth && { maxWidth: formMaxWidth, width: '100%' },
{ {
opacity: inputFadeAnim, opacity: inputFadeAnim,
transform: [{ translateY: slideAnim }], transform: [{ translateY: slideAnim }],
@@ -187,21 +231,15 @@ export const LoginScreen: React.FC = () => {
{/* 用户名输入框 */} {/* 用户名输入框 */}
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
<View style={[ <View style={styles.inputWrapper}>
styles.inputWrapper, <MaterialCommunityIcons
isWideScreen && styles.inputWrapperWide, name="account-outline"
]}> size={22}
<MaterialCommunityIcons color={colors.primary.main}
name="account-outline"
size={22}
color={colors.primary.main}
style={styles.inputIcon} style={styles.inputIcon}
/> />
<TextInput <TextInput
style={[ style={styles.input}
styles.input,
isWideScreen && styles.inputWide,
]}
placeholder="用户名 / 邮箱 / 手机号" placeholder="用户名 / 邮箱 / 手机号"
placeholderTextColor={colors.text.hint} placeholderTextColor={colors.text.hint}
value={username} value={username}
@@ -227,21 +265,15 @@ export const LoginScreen: React.FC = () => {
{/* 密码输入框 */} {/* 密码输入框 */}
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
<View style={[ <View style={styles.inputWrapper}>
styles.inputWrapper, <MaterialCommunityIcons
isWideScreen && styles.inputWrapperWide, name="lock-outline"
]}> size={22}
<MaterialCommunityIcons color={colors.primary.main}
name="lock-outline"
size={22}
color={colors.primary.main}
style={styles.inputIcon} style={styles.inputIcon}
/> />
<TextInput <TextInput
style={[ style={styles.input}
styles.input,
isWideScreen && styles.inputWide,
]}
placeholder="请输入密码" placeholder="请输入密码"
placeholderTextColor={colors.text.hint} placeholderTextColor={colors.text.hint}
value={password} value={password}
@@ -275,7 +307,7 @@ export const LoginScreen: React.FC = () => {
<MaterialCommunityIcons <MaterialCommunityIcons
name="alert-circle-outline" name="alert-circle-outline"
size={16} size={16}
color={colors.error?.main ?? '#D32F2F'} color={colors.error.dark}
style={{ marginRight: 6, marginTop: 1 }} style={{ marginRight: 6, marginTop: 1 }}
/> />
<Text style={styles.errorText}>{errorMsg}</Text> <Text style={styles.errorText}>{errorMsg}</Text>
@@ -290,13 +322,10 @@ export const LoginScreen: React.FC = () => {
activeOpacity={0.8} activeOpacity={0.8}
> >
<LinearGradient <LinearGradient
colors={['#FF6B35', '#FF8F66']} colors={[colors.primary.main, colors.primary.light]}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
style={[ style={styles.loginButtonGradient}
styles.loginButtonGradient,
isWideScreen && styles.loginButtonGradientWide,
]}
> >
{loading ? ( {loading ? (
<ActivityIndicator size="small" color="#FFF" /> <ActivityIndicator size="small" color="#FFF" />
@@ -319,13 +348,48 @@ export const LoginScreen: React.FC = () => {
<Text style={styles.registerLink}></Text> <Text style={styles.registerLink}></Text>
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </Animated.View>
</> </View>
); );
return ( // 渲染分栏布局
<SafeAreaView style={styles.container}> const renderSplitLayout = () => (
<View style={styles.splitContainer}>
{/* 左侧橙色面板 */}
<LinearGradient <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 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
style={styles.gradient} style={styles.gradient}
@@ -334,43 +398,52 @@ export const LoginScreen: React.FC = () => {
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView} style={styles.keyboardView}
> >
{isWideScreen ? ( <ScrollView
<ResponsiveContainer maxWidth={600}> contentContainerStyle={[
<ScrollView styles.scrollContent,
contentContainerStyle={[ isLandscape && styles.scrollContentLandscape,
styles.scrollContent, ]}
isLandscape && styles.scrollContentLandscape, showsVerticalScrollIndicator={false}
]} keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false} >
keyboardShouldPersistTaps="handled" {/* 装饰性背景元素 */}
> <View style={styles.decorCircle1} />
{/* 装饰性背景元素 */} <View style={styles.decorCircle2} />
<View style={styles.decorCircle1} />
<View style={styles.decorCircle2} /> {/* 头部 */}
<Animated.View
{renderFormContent()} style={[
</ScrollView> styles.headerSection,
</ResponsiveContainer> isLandscape && styles.headerSectionLandscape,
) : ( {
<ScrollView opacity: fadeAnim,
contentContainerStyle={[ transform: [{ scale: scaleAnim }],
styles.scrollContent, },
isLandscape && styles.scrollContentLandscape,
]} ]}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
> >
{/* 装饰性背景元素 */} <View style={[
<View style={styles.decorCircle1} /> styles.logoContainer,
<View style={styles.decorCircle2} /> { width: logoContainerSize, height: logoContainerSize, borderRadius: logoContainerSize / 2 }
]}>
{renderFormContent()} <MaterialCommunityIcons
</ScrollView> name="carrot"
)} size={logoSize}
color="#FFF"
/>
</View>
<Text style={[styles.appName, { fontSize: appNameSize }]}></Text>
<Text style={styles.subtitle}></Text>
</Animated.View>
{/* 表单 */}
{renderFormContent()}
</ScrollView>
</KeyboardAvoidingView> </KeyboardAvoidingView>
</LinearGradient> </LinearGradient>
</SafeAreaView> </SafeAreaView>
); );
return isSplitLayout ? renderSplitLayout() : renderSingleLayout();
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
@@ -383,11 +456,17 @@ const styles = StyleSheet.create({
keyboardView: { keyboardView: {
flex: 1, flex: 1,
}, },
// 单栏布局内容
scrollContent: { scrollContent: {
flexGrow: 1, flexGrow: 1,
paddingHorizontal: spacing.xl, paddingHorizontal: spacing.xl,
paddingVertical: spacing['2xl'], paddingVertical: spacing['2xl'],
}, },
scrollContentCentered: {
alignItems: 'center',
justifyContent: 'center',
flex: 1,
},
scrollContentLandscape: { scrollContentLandscape: {
paddingVertical: spacing.lg, paddingVertical: spacing.lg,
}, },
@@ -429,6 +508,15 @@ const styles = StyleSheet.create({
borderColor: 'rgba(255,255,255,0.4)', borderColor: 'rgba(255,255,255,0.4)',
...shadows.md, ...shadows.md,
}, },
// 大屏模式Logo容器样式 - 增加阴影和内边距,更立体
splitLogoContainer: {
borderWidth: 3,
borderColor: 'rgba(255,255,255,0.5)',
...shadows.lg,
},
logoContainerTop: {
marginTop: spacing['3xl'],
},
appName: { appName: {
fontWeight: '800', fontWeight: '800',
color: '#FFFFFF', color: '#FFFFFF',
@@ -437,20 +525,54 @@ const styles = StyleSheet.create({
textShadowOffset: { width: 0, height: 2 }, textShadowOffset: { width: 0, height: 2 },
textShadowRadius: 4, textShadowRadius: 4,
}, },
// 大屏模式标题样式 - 大幅放大,增加间距和文字效果
splitAppName: {
fontSize: 72, // 大幅放大主标题
marginBottom: spacing['2xl'],
textShadowOffset: { width: 0, height: 4 },
textShadowRadius: 8,
letterSpacing: 4, // 增加字间距,更显大气
fontWeight: '900',
},
subtitle: { subtitle: {
fontSize: fontSizes.md, fontSize: fontSizes.md,
color: 'rgba(255,255,255,0.9)', color: 'rgba(255,255,255,0.9)',
textAlign: 'center', textAlign: 'center',
paddingHorizontal: spacing.xl, paddingHorizontal: spacing.xl,
}, },
// 表单卡片 // 大屏模式副标题样式 - 大幅放大
splitSubtitle: {
fontSize: 24, // 放大副标题
marginBottom: spacing['6xl'], // 增加底部间距
lineHeight: 36,
paddingHorizontal: spacing['4xl'],
letterSpacing: 1.5,
fontWeight: '500',
},
// 表单卡片(基础样式,无阴影)
formCard: { formCard: {
backgroundColor: colors.background.paper, backgroundColor: colors.background.paper,
borderRadius: borderRadius['2xl'], borderRadius: borderRadius['2xl'],
padding: spacing['2xl'], padding: spacing['2xl'],
marginBottom: spacing.xl, marginBottom: spacing.xl,
},
// 小屏模式:原有默认阴影
normalFormCardShadow: {
...shadows.md, ...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: { formTitle: {
fontSize: fontSizes['2xl'], fontSize: fontSizes['2xl'],
fontWeight: '700', fontWeight: '700',
@@ -471,9 +593,6 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
height: 56, height: 56,
}, },
inputWrapperWide: {
height: 60,
},
inputIcon: { inputIcon: {
marginRight: spacing.sm, marginRight: spacing.sm,
}, },
@@ -482,9 +601,6 @@ const styles = StyleSheet.create({
fontSize: fontSizes.md, fontSize: fontSizes.md,
color: colors.text.primary, color: colors.text.primary,
}, },
inputWide: {
fontSize: fontSizes.lg,
},
clearButton: { clearButton: {
padding: spacing.xs, padding: spacing.xs,
}, },
@@ -495,23 +611,23 @@ const styles = StyleSheet.create({
forgotPasswordText: { forgotPasswordText: {
fontSize: fontSizes.sm, fontSize: fontSizes.sm,
color: colors.primary.main, color: colors.primary.main,
fontWeight: '500', fontWeight: '600',
}, },
errorBox: { errorBox: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'flex-start', alignItems: 'flex-start',
backgroundColor: '#FFEBEE', backgroundColor: colors.accent.light,
borderRadius: borderRadius.md, borderRadius: borderRadius.md,
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
marginBottom: spacing.md, marginBottom: spacing.md,
borderLeftWidth: 3, borderLeftWidth: 3,
borderLeftColor: '#D32F2F', borderLeftColor: colors.accent.main,
}, },
errorText: { errorText: {
flex: 1, flex: 1,
fontSize: fontSizes.sm, fontSize: fontSizes.sm,
color: '#D32F2F', color: colors.accent.dark,
lineHeight: 20, lineHeight: 20,
}, },
loginButton: { loginButton: {
@@ -524,9 +640,6 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
loginButtonGradientWide: {
height: 56,
},
loginButtonDisabled: { loginButtonDisabled: {
opacity: 0.7, opacity: 0.7,
}, },
@@ -540,20 +653,108 @@ const styles = StyleSheet.create({
flexDirection: 'row', flexDirection: 'row',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
marginTop: 'auto',
paddingTop: spacing.xl, paddingTop: spacing.xl,
}, },
footerText: { footerText: {
fontSize: fontSizes.md, fontSize: fontSizes.md,
color: 'rgba(255,255,255,0.9)', color: colors.text.primary,
}, },
registerLink: { registerLink: {
fontSize: fontSizes.md, fontSize: fontSizes.md,
color: '#FFFFFF', color: colors.primary.main,
fontWeight: '700', fontWeight: '700',
marginLeft: spacing.xs, marginLeft: spacing.xs,
textDecorationLine: 'underline', 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; export default LoginScreen;

View File

@@ -1,9 +1,10 @@
/** /**
* 注册页 RegisterScreen响应式适配 * 注册页 RegisterScreen响应式适配 - 分栏布局
* 胡萝卜BBS - 用户注册 * 胡萝卜BBS - 用户注册
* 现代化设计 - 渐变背景 + 动画效果 *
* 注册表单在桌面端居中显示,限制最大宽度 * 布局规则:
* 支持横屏模式下的布局调整 * - 屏幕宽度 < 768px单栏布局橙色渐变背景
* - 屏幕宽度 >= 768px分栏布局左侧橙色右侧中性灰
*/ */
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from 'react';
@@ -18,6 +19,7 @@ import {
ScrollView, ScrollView,
ActivityIndicator, ActivityIndicator,
Animated, Animated,
StatusBar,
} from 'react-native'; } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
@@ -29,17 +31,22 @@ import { authService, resolveAuthApiError } from '../../services/authService';
import { useAuthStore } from '../../stores'; import { useAuthStore } from '../../stores';
import { RootStackParamList } from '../../navigation/types'; import { RootStackParamList } from '../../navigation/types';
import { useResponsive, useResponsiveValue } from '../../hooks'; import { useResponsive, useResponsiveValue } from '../../hooks';
import { ResponsiveContainer } from '../../components/common';
import { showPrompt } from '../../services/promptService'; import { showPrompt } from '../../services/promptService';
type RegisterNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>; type RegisterNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>;
// 分栏布局断点
const SPLIT_BREAKPOINT = 768;
export const RegisterScreen: React.FC = () => { export const RegisterScreen: React.FC = () => {
const navigation = useNavigation<RegisterNavigationProp>(); const navigation = useNavigation<RegisterNavigationProp>();
const register = useAuthStore((state) => state.register); 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 [username, setUsername] = useState('');
const [nickname, setNickname] = useState(''); const [nickname, setNickname] = useState('');
@@ -61,11 +68,23 @@ export const RegisterScreen: React.FC = () => {
const scaleAnim = useRef(new Animated.Value(0.95)).current; const scaleAnim = useRef(new Animated.Value(0.95)).current;
const inputFadeAnim = useRef(new Animated.Value(0)).current; const inputFadeAnim = useRef(new Animated.Value(0)).current;
// 响应式值 // 响应式值 - 大屏模式单独放大图标尺寸,更显大气
const iconSize = useResponsiveValue({ xs: 48, sm: 52, md: 56, lg: 64, xl: 72 }); const iconSize = useResponsiveValue({
const iconContainerSize = useResponsiveValue({ xs: 80, sm: 88, md: 96, lg: 108, xl: 120 }); xs: 48, sm: 52, md: 56,
const titleSize = useResponsiveValue({ xs: 24, sm: 26, md: 28, lg: 30, xl: 32 }); lg: isSplitLayout ? 120 : 64, // 大屏模式图标进一步放大
const formMaxWidth = isDesktop ? 520 : isWideScreen ? 480 : undefined; 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(() => { useEffect(() => {
@@ -219,64 +238,93 @@ export const RegisterScreen: React.FC = () => {
// 跳转到登录页 // 跳转到登录页
const handleGoToLogin = () => { const handleGoToLogin = () => {
navigation.goBack(); navigation.navigate('Login' as any);
}; };
// 渲染左侧面板Logo和品牌信息- 优化大屏布局
const renderLeftPanel = () => (
<Animated.View
style={[
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
name="account-plus"
size={iconSize}
color="#FFF"
/>
</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 = () => ( const renderFormContent = () => (
<> <View style={isSplitLayout ? styles.splitFormWrapper : styles.singleFormWrapper}>
{/* 标题区域 */} <Animated.View
<Animated.View
style={[
styles.headerSection,
isLandscape && styles.headerSectionLandscape,
{
opacity: fadeAnim,
transform: [{ scale: scaleAnim }],
},
]}
>
<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>
{/* 表单卡片 */}
<Animated.View
style={[ style={[
styles.formCard, styles.formCard,
formMaxWidth && { maxWidth: formMaxWidth, alignSelf: 'center', width: '100%' }, // 核心修改:大屏模式使用专用的阴影样式,小屏用原有阴影
isSplitLayout ? styles.splitFormCardShadow : styles.normalFormCardShadow,
formMaxWidth && { maxWidth: formMaxWidth, width: '100%' },
{ {
opacity: inputFadeAnim, opacity: inputFadeAnim,
transform: [{ translateY: slideAnim }], transform: [{ translateY: slideAnim }],
}, },
]} ]}
> >
<Text style={styles.formTitle}></Text>
{/* 用户名输入框 */} {/* 用户名输入框 */}
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
<View style={[ <View style={[
styles.inputWrapper, styles.inputWrapper,
isWideScreen && styles.inputWrapperWide, isSplitLayout && styles.inputWrapperWide,
]}> ]}>
<MaterialCommunityIcons <MaterialCommunityIcons
name="account-outline" name="account-outline"
size={22} size={22}
color={colors.primary.main} color={colors.primary.main}
style={styles.inputIcon} style={styles.inputIcon}
/> />
<TextInput <TextInput
style={[ style={[
styles.input, styles.input,
isWideScreen && styles.inputWide, isSplitLayout && styles.inputWide,
]} ]}
placeholder="用户名3-20个字符" placeholder="用户名3-20个字符"
placeholderTextColor={colors.text.hint} placeholderTextColor={colors.text.hint}
@@ -305,18 +353,18 @@ export const RegisterScreen: React.FC = () => {
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
<View style={[ <View style={[
styles.inputWrapper, styles.inputWrapper,
isWideScreen && styles.inputWrapperWide, isSplitLayout && styles.inputWrapperWide,
]}> ]}>
<MaterialCommunityIcons <MaterialCommunityIcons
name="emoticon-outline" name="emoticon-outline"
size={22} size={22}
color={colors.primary.main} color={colors.primary.main}
style={styles.inputIcon} style={styles.inputIcon}
/> />
<TextInput <TextInput
style={[ style={[
styles.input, styles.input,
isWideScreen && styles.inputWide, isSplitLayout && styles.inputWide,
]} ]}
placeholder="昵称2-20个字符" placeholder="昵称2-20个字符"
placeholderTextColor={colors.text.hint} placeholderTextColor={colors.text.hint}
@@ -343,18 +391,18 @@ export const RegisterScreen: React.FC = () => {
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
<View style={[ <View style={[
styles.inputWrapper, styles.inputWrapper,
isWideScreen && styles.inputWrapperWide, isSplitLayout && styles.inputWrapperWide,
]}> ]}>
<MaterialCommunityIcons <MaterialCommunityIcons
name="email-outline" name="email-outline"
size={22} size={22}
color={colors.primary.main} color={colors.primary.main}
style={styles.inputIcon} style={styles.inputIcon}
/> />
<TextInput <TextInput
style={[ style={[
styles.input, styles.input,
isWideScreen && styles.inputWide, isSplitLayout && styles.inputWide,
]} ]}
placeholder="邮箱(必填)" placeholder="邮箱(必填)"
placeholderTextColor={colors.text.hint} placeholderTextColor={colors.text.hint}
@@ -382,7 +430,7 @@ export const RegisterScreen: React.FC = () => {
{/* 邮箱验证码 */} {/* 邮箱验证码 */}
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
<View style={styles.codeRow}> <View style={styles.codeRow}>
<View style={[styles.inputWrapper, styles.codeInputWrapper, isWideScreen && styles.inputWrapperWide]}> <View style={[styles.inputWrapper, styles.codeInputWrapper, isSplitLayout && styles.inputWrapperWide]}>
<MaterialCommunityIcons <MaterialCommunityIcons
name="shield-key-outline" name="shield-key-outline"
size={22} size={22}
@@ -390,7 +438,7 @@ export const RegisterScreen: React.FC = () => {
style={styles.inputIcon} style={styles.inputIcon}
/> />
<TextInput <TextInput
style={[styles.input, isWideScreen && styles.inputWide]} style={[styles.input, isSplitLayout && styles.inputWide]}
placeholder="邮箱验证码" placeholder="邮箱验证码"
placeholderTextColor={colors.text.hint} placeholderTextColor={colors.text.hint}
value={verificationCode} value={verificationCode}
@@ -419,18 +467,18 @@ export const RegisterScreen: React.FC = () => {
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
<View style={[ <View style={[
styles.inputWrapper, styles.inputWrapper,
isWideScreen && styles.inputWrapperWide, isSplitLayout && styles.inputWrapperWide,
]}> ]}>
<MaterialCommunityIcons <MaterialCommunityIcons
name="phone-outline" name="phone-outline"
size={22} size={22}
color={colors.primary.main} color={colors.primary.main}
style={styles.inputIcon} style={styles.inputIcon}
/> />
<TextInput <TextInput
style={[ style={[
styles.input, styles.input,
isWideScreen && styles.inputWide, isSplitLayout && styles.inputWide,
]} ]}
placeholder="手机号(选填)" placeholder="手机号(选填)"
placeholderTextColor={colors.text.hint} placeholderTextColor={colors.text.hint}
@@ -458,18 +506,18 @@ export const RegisterScreen: React.FC = () => {
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
<View style={[ <View style={[
styles.inputWrapper, styles.inputWrapper,
isWideScreen && styles.inputWrapperWide, isSplitLayout && styles.inputWrapperWide,
]}> ]}>
<MaterialCommunityIcons <MaterialCommunityIcons
name="lock-outline" name="lock-outline"
size={22} size={22}
color={colors.primary.main} color={colors.primary.main}
style={styles.inputIcon} style={styles.inputIcon}
/> />
<TextInput <TextInput
style={[ style={[
styles.input, styles.input,
isWideScreen && styles.inputWide, isSplitLayout && styles.inputWide,
]} ]}
placeholder="密码至少6位" placeholder="密码至少6位"
placeholderTextColor={colors.text.hint} placeholderTextColor={colors.text.hint}
@@ -495,18 +543,18 @@ export const RegisterScreen: React.FC = () => {
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
<View style={[ <View style={[
styles.inputWrapper, styles.inputWrapper,
isWideScreen && styles.inputWrapperWide, isSplitLayout && styles.inputWrapperWide,
]}> ]}>
<MaterialCommunityIcons <MaterialCommunityIcons
name="lock-check-outline" name="lock-check-outline"
size={22} size={22}
color={colors.primary.main} color={colors.primary.main}
style={styles.inputIcon} style={styles.inputIcon}
/> />
<TextInput <TextInput
style={[ style={[
styles.input, styles.input,
isWideScreen && styles.inputWide, isSplitLayout && styles.inputWide,
]} ]}
placeholder="确认密码" placeholder="确认密码"
placeholderTextColor={colors.text.hint} placeholderTextColor={colors.text.hint}
@@ -559,12 +607,12 @@ export const RegisterScreen: React.FC = () => {
activeOpacity={0.8} activeOpacity={0.8}
> >
<LinearGradient <LinearGradient
colors={['#FF6B35', '#FF8F66']} colors={[colors.primary.main, colors.primary.light]}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
style={[ style={[
styles.registerButtonGradient, styles.registerButtonGradient,
isWideScreen && styles.registerButtonGradientWide, isSplitLayout && styles.registerButtonGradientWide,
]} ]}
> >
{loading ? ( {loading ? (
@@ -577,24 +625,60 @@ export const RegisterScreen: React.FC = () => {
</Animated.View> </Animated.View>
{/* 底部登录提示 */} {/* 底部登录提示 */}
<Animated.View <Animated.View
style={[ style={[
styles.footerSection, styles.footerSection,
isSplitLayout && styles.splitFooterSection,
{ opacity: inputFadeAnim }, { opacity: inputFadeAnim },
]} ]}
> >
<Text style={styles.footerText}></Text> <Text style={[styles.footerText, isSplitLayout && styles.splitFooterText]}></Text>
<TouchableOpacity onPress={handleGoToLogin}> <TouchableOpacity onPress={handleGoToLogin}>
<Text style={styles.loginLink}></Text> <Text style={[styles.loginLink, isSplitLayout && styles.splitLoginLink]}></Text>
</TouchableOpacity> </TouchableOpacity>
</Animated.View> </Animated.View>
</> </View>
); );
return ( // 渲染分栏布局
<SafeAreaView style={styles.container}> const renderSplitLayout = () => (
<View style={styles.splitContainer}>
{/* 左侧橙色面板 */}
<LinearGradient <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 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }} end={{ x: 1, y: 1 }}
style={styles.gradient} style={styles.gradient}
@@ -603,43 +687,52 @@ export const RegisterScreen: React.FC = () => {
behavior={Platform.OS === 'ios' ? 'padding' : 'height'} behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView} style={styles.keyboardView}
> >
{isWideScreen ? ( <ScrollView
<ResponsiveContainer maxWidth={600}> contentContainerStyle={[
<ScrollView styles.scrollContent,
contentContainerStyle={[ isLandscape && styles.scrollContentLandscape,
styles.scrollContent, ]}
isLandscape && styles.scrollContentLandscape, showsVerticalScrollIndicator={false}
]} keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false} >
keyboardShouldPersistTaps="handled" {/* 装饰性背景元素 */}
> <View style={styles.decorCircle1} />
{/* 装饰性背景元素 */} <View style={styles.decorCircle2} />
<View style={styles.decorCircle1} />
<View style={styles.decorCircle2} /> {/* 头部 */}
<Animated.View
{renderFormContent()} style={[
</ScrollView> styles.headerSection,
</ResponsiveContainer> isLandscape && styles.headerSectionLandscape,
) : ( {
<ScrollView opacity: fadeAnim,
contentContainerStyle={[ transform: [{ scale: scaleAnim }],
styles.scrollContent, },
isLandscape && styles.scrollContentLandscape,
]} ]}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
> >
{/* 装饰性背景元素 */} <View style={[
<View style={styles.decorCircle1} /> styles.iconContainer,
<View style={styles.decorCircle2} /> { width: iconContainerSize, height: iconContainerSize, borderRadius: iconContainerSize / 2 }
]}>
{renderFormContent()} <MaterialCommunityIcons
</ScrollView> 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> </KeyboardAvoidingView>
</LinearGradient> </LinearGradient>
</SafeAreaView> </SafeAreaView>
); );
return isSplitLayout ? renderSplitLayout() : renderSingleLayout();
}; };
const styles = StyleSheet.create({ const styles = StyleSheet.create({
@@ -827,10 +920,16 @@ const styles = StyleSheet.create({
paddingTop: spacing.md, paddingTop: spacing.md,
paddingBottom: spacing.md, paddingBottom: spacing.md,
}, },
splitFooterSection: {
paddingTop: spacing.xl,
},
footerText: { footerText: {
fontSize: fontSizes.md, fontSize: fontSizes.md,
color: 'rgba(255,255,255,0.9)', color: 'rgba(255,255,255,0.9)',
}, },
splitFooterText: {
color: colors.text.primary,
},
loginLink: { loginLink: {
fontSize: fontSizes.md, fontSize: fontSizes.md,
color: '#FFFFFF', color: '#FFFFFF',
@@ -838,6 +937,141 @@ const styles = StyleSheet.create({
marginLeft: spacing.xs, marginLeft: spacing.xs,
textDecorationLine: 'underline', 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; export default RegisterScreen;

View File

@@ -32,6 +32,7 @@ import { PostCard, TabBar, SearchBar } from '../../components/business';
import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common'; import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common';
import { HomeStackParamList, RootStackParamList } from '../../navigation/types'; import { HomeStackParamList, RootStackParamList } from '../../navigation/types';
import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive'; import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive';
import { SearchScreen } from './SearchScreen';
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'Home'> & NativeStackNavigationProp<RootStackParamList>; type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'Home'> & NativeStackNavigationProp<RootStackParamList>;
@@ -84,6 +85,9 @@ export const HomeScreen: React.FC = () => {
const [postImages, setPostImages] = useState<ImageGridItem[]>([]); const [postImages, setPostImages] = useState<ImageGridItem[]>([]);
const [selectedImageIndex, setSelectedImageIndex] = useState(0); const [selectedImageIndex, setSelectedImageIndex] = useState(0);
// 搜索显示状态(用于内嵌搜索页面)
const [showSearch, setShowSearch] = useState(false);
// 用于跟踪当前页面显示的帖子 ID以便从 store 同步状态 // 用于跟踪当前页面显示的帖子 ID以便从 store 同步状态
const postIdsRef = React.useRef<Set<string>>(new Set()); const postIdsRef = React.useRef<Set<string>>(new Set());
const inFlightRequestKeysRef = React.useRef<Set<string>>(new Set()); const inFlightRequestKeysRef = React.useRef<Set<string>>(new Set());
@@ -325,9 +329,9 @@ export const HomeScreen: React.FC = () => {
}) })
), [handleSwipeTabChange]); ), [handleSwipeTabChange]);
// 跳转到搜索页 // 跳转到搜索页(使用内嵌模式,不再依赖导航)
const handleSearchPress = () => { 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 ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
<StatusBar barStyle="dark-content" backgroundColor={colors.background.paper} /> <StatusBar barStyle="dark-content" backgroundColor={colors.background.paper} />
@@ -667,7 +684,20 @@ export const HomeScreen: React.FC = () => {
</View> </View>
{/* 帖子列表 */} {/* 帖子列表 */}
<GestureDetector gesture={swipeGesture}> {isMobile ? (
// 移动端:使用 GestureDetector 支持手势切换 Tab
<GestureDetector gesture={swipeGesture}>
<View style={styles.contentContainer}>
{viewMode === 'list' ? (
renderListContent()
) : (
// 网格模式:使用响应式网格
renderResponsiveGrid()
)}
</View>
</GestureDetector>
) : (
// 桌面端/平板端:不使用 GestureDetector避免拦截侧边栏点击
<View style={styles.contentContainer}> <View style={styles.contentContainer}>
{viewMode === 'list' ? ( {viewMode === 'list' ? (
renderListContent() renderListContent()
@@ -676,7 +706,7 @@ export const HomeScreen: React.FC = () => {
renderResponsiveGrid() renderResponsiveGrid()
)} )}
</View> </View>
</GestureDetector> )}
{/* 漂浮发帖按钮 */} {/* 漂浮发帖按钮 */}
<TouchableOpacity <TouchableOpacity
@@ -695,9 +725,9 @@ export const HomeScreen: React.FC = () => {
{/* 图片查看器 */} {/* 图片查看器 */}
<ImageGallery <ImageGallery
visible={showImageViewer} visible={showImageViewer}
images={postImages.map(img => ({ images={postImages.map(img => ({
id: img.id || img.url || String(Math.random()), id: img.id || img.url || String(Math.random()),
url: img.url || img.uri || '' url: img.url || img.uri || ''
}))} }))}
initialIndex={selectedImageIndex} initialIndex={selectedImageIndex}
onClose={() => setShowImageViewer(false)} onClose={() => setShowImageViewer(false)}
@@ -717,6 +747,12 @@ const styles = StyleSheet.create({
}, },
searchWrapper: { searchWrapper: {
paddingBottom: spacing.sm, paddingBottom: spacing.sm,
// 移除阴影效果
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
}, },
viewToggleBtn: { viewToggleBtn: {
width: 44, width: 44,

View File

@@ -31,8 +31,14 @@ const TABS = ['帖子', '用户'];
type SearchType = 'posts' | 'users'; type SearchType = 'posts' | 'users';
export const SearchScreen: React.FC = () => { interface SearchScreenProps {
const navigation = useNavigation<NavigationProp>(); onBack?: () => void;
navigation?: NavigationProp;
}
export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation: propNavigation }) => {
// 如果传入了 navigation 则使用传入的,否则使用 hook 获取的
const navigation = propNavigation || useNavigation<NavigationProp>();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const { searchHistory: history, addSearchHistory, clearSearchHistory } = useUserStore(); const { searchHistory: history, addSearchHistory, clearSearchHistory } = useUserStore();
@@ -422,9 +428,9 @@ export const SearchScreen: React.FC = () => {
autoFocus autoFocus
/> />
</View> </View>
<TouchableOpacity <TouchableOpacity
style={[styles.cancelButton, { marginLeft: responsiveGap }]} style={[styles.cancelButton, { marginLeft: responsiveGap }]}
onPress={() => navigation.goBack()} onPress={() => onBack ? onBack() : navigation.goBack()}
activeOpacity={0.85} activeOpacity={0.85}
> >
<Text <Text
@@ -476,6 +482,12 @@ const styles = StyleSheet.create({
backgroundColor: `${colors.primary.main}08`, backgroundColor: `${colors.primary.main}08`,
paddingHorizontal: spacing.xs, paddingHorizontal: spacing.xs,
paddingVertical: spacing.xs, paddingVertical: spacing.xs,
// 移除阴影效果
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
}, },
cancelButton: { cancelButton: {
backgroundColor: `${colors.primary.main}14`, backgroundColor: `${colors.primary.main}14`,

View File

@@ -45,16 +45,36 @@ import {
ChatHeader, ChatHeader,
MessageBubble, MessageBubble,
ChatInput, ChatInput,
GroupInfoPanel,
PANEL_HEIGHTS, PANEL_HEIGHTS,
} from './components/ChatScreen'; } from './components/ChatScreen';
export const ChatScreen: React.FC = () => { export const ChatScreen: React.FC = () => {
const navigation = useNavigation(); const navigation = useNavigation<any>();
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
// 响应式布局 // 响应式布局
const isWideScreen = useBreakpointGTE('lg'); const isWideScreen = useBreakpointGTE('lg');
const styles = baseStyles; const styles = baseStyles;
// 监听屏幕宽度变化当变为大屏幕时自动跳转到web端首页
useEffect(() => {
if (isWideScreen) {
// 导航到大屏幕模式的主界面首页
navigation.reset({
index: 0,
routes: [
{
name: 'Main',
params: {
screen: 'HomeTab',
params: { screen: 'Home' }
}
}
]
});
}
}, [isWideScreen, navigation]);
// 输入框区域高度(用于定位浮动 mention 面板) // 输入框区域高度(用于定位浮动 mention 面板)
const [inputWrapperHeight, setInputWrapperHeight] = useState(60); const [inputWrapperHeight, setInputWrapperHeight] = useState(60);
@@ -93,6 +113,19 @@ export const ChatScreen: React.FC = () => {
setShowImageViewer(false); setShowImageViewer(false);
}; };
// 群信息面板状态
const [showGroupInfoPanel, setShowGroupInfoPanel] = useState(false);
// 打开群信息面板
const handleGroupInfoPress = useCallback(() => {
setShowGroupInfoPanel(true);
}, []);
// 关闭群信息面板
const handleCloseGroupInfoPanel = useCallback(() => {
setShowGroupInfoPanel(false);
}, []);
const { const {
// 状态 // 状态
messages, messages,
@@ -125,6 +158,7 @@ export const ChatScreen: React.FC = () => {
messageMap, messageMap,
loadingMore, loadingMore,
hasMoreHistory, hasMoreHistory,
conversationId,
// Refs // Refs
flatListRef, flatListRef,
@@ -222,6 +256,8 @@ export const ChatScreen: React.FC = () => {
onBack={() => navigation.goBack()} onBack={() => navigation.goBack()}
onTitlePress={navigateToInfo} onTitlePress={navigateToInfo}
onMorePress={navigateToChatSettings} onMorePress={navigateToChatSettings}
onGroupInfoPress={handleGroupInfoPress}
isWideScreen={isWideScreen}
/> />
{/* 消息列表 */} {/* 消息列表 */}
@@ -369,6 +405,33 @@ export const ChatScreen: React.FC = () => {
onClose={handleCloseImageViewer} onClose={handleCloseImageViewer}
enableSave 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> </KeyboardAvoidingView>
); );
}; };

View File

@@ -215,6 +215,8 @@ const CreateGroupScreen: React.FC = () => {
placeholder="请输入群名称" placeholder="请输入群名称"
placeholderTextColor={colors.text.hint} placeholderTextColor={colors.text.hint}
maxLength={50} maxLength={50}
cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`}
/> />
<Text variant="caption" color={colors.text.hint} style={styles.charCount}> <Text variant="caption" color={colors.text.hint} style={styles.charCount}>
{groupName.length}/50 {groupName.length}/50
@@ -238,6 +240,8 @@ const CreateGroupScreen: React.FC = () => {
multiline multiline
numberOfLines={4} numberOfLines={4}
textAlignVertical="top" textAlignVertical="top"
cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`}
/> />
<Text variant="caption" color={colors.text.hint} style={styles.textAreaCharCount}> <Text variant="caption" color={colors.text.hint} style={styles.textAreaCharCount}>
{groupDescription.length}/500 {groupDescription.length}/500

View File

@@ -110,6 +110,8 @@ const JoinGroupScreen: React.FC = () => {
style={styles.input} style={styles.input}
editable={!searching && !joining} editable={!searching && !joining}
autoCapitalize="none" autoCapitalize="none"
cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`}
/> />
<TouchableOpacity <TouchableOpacity
style={[styles.searchBtn, (!keyword.trim() || searching || joining) && styles.submitBtnDisabled]} style={[styles.searchBtn, (!keyword.trim() || searching || joining) && styles.submitBtnDisabled]}

View File

@@ -40,6 +40,8 @@ import { RootStackParamList, MessageStackParamList } from '../../navigation/type
import { getUserCache } from '../../services/database'; import { getUserCache } from '../../services/database';
// 导入 EmbeddedChat 组件用于桌面端双栏布局 // 导入 EmbeddedChat 组件用于桌面端双栏布局
import { EmbeddedChat } from './components/EmbeddedChat'; import { EmbeddedChat } from './components/EmbeddedChat';
// 导入 NotificationsScreen 用于在内部显示
import { NotificationsScreen } from './NotificationsScreen';
type NavigationProp = NativeStackNavigationProp<RootStackParamList>; type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
type MessageNavProp = NativeStackNavigationProp<MessageStackParamList>; type MessageNavProp = NativeStackNavigationProp<MessageStackParamList>;
@@ -140,7 +142,15 @@ export const MessageListScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp & MessageNavProp>(); const navigation = useNavigation<NavigationProp & MessageNavProp>();
const isFocused = useIsFocused(); const isFocused = useIsFocused();
const insets = useSafeAreaInsets(); 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 currentUserId = useAuthStore(state => state.currentUser?.id);
const setMessageUnreadCount = useUserStore(state => state.setMessageUnreadCount); const setMessageUnreadCount = useUserStore(state => state.setMessageUnreadCount);
@@ -173,6 +183,30 @@ export const MessageListScreen: React.FC = () => {
const [activeSearchTab, setActiveSearchTab] = useState<'chat' | 'user'>('chat'); const [activeSearchTab, setActiveSearchTab] = useState<'chat' | 'user'>('chat');
const [actionMenuVisible, setActionMenuVisible] = useState(false); 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(() => const [scaleAnims] = useState(() =>
Array.from({ length: 20 }, () => new Animated.Value(1)) Array.from({ length: 20 }, () => new Animated.Value(1))
@@ -272,19 +306,19 @@ export const MessageListScreen: React.FC = () => {
}), }),
]).start(() => { ]).start(() => {
if (conversation.id === SYSTEM_MESSAGE_CHANNEL_ID) { if (conversation.id === SYSTEM_MESSAGE_CHANNEL_ID) {
// 跳转到系统通知页面 - 使用正确的导航路径 // 系统通知 - 根据屏幕宽度决定如何显示
if (isWideScreen) { if (isWideScreen) {
// 宽屏下直接导航到通知页面 // 宽屏下使用内部状态显示通知页面,同时设置系统消息为选中状态
navigation.navigate('Notifications' as never); setSelectedConversation(systemMessageConversation);
setShowNotifications(true);
} else { } else {
// 窄屏下使用 Tab 导航 // 窄屏下使用内部状态显示通知页面
(navigation as any).navigate('Main', { setShowNotifications(true);
screen: 'MessageTab',
params: { screen: 'Notifications' }
});
} }
} else if (isWideScreen) { } else if (isWideScreen) {
// 【桌面端双栏布局】宽屏下设置选中会话,在右侧显示聊天 // 【桌面端双栏布局】宽屏下设置选中会话,在右侧显示聊天
// 同时关闭系统通知页面
setShowNotifications(false);
setSelectedConversation(conversation); setSelectedConversation(conversation);
} else if (conversation.type === 'group' && conversation.group) { } else if (conversation.type === 'group' && conversation.group) {
// 群聊 - 窄屏下导航 // 群聊 - 窄屏下导航
@@ -765,6 +799,9 @@ export const MessageListScreen: React.FC = () => {
onChangeText={setSearchText} onChangeText={setSearchText}
autoFocus autoFocus
returnKeyType="search" returnKeyType="search"
// 确保光标可见
cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`}
/> />
{searchText.length > 0 && ( {searchText.length > 0 && (
<TouchableOpacity onPress={() => setSearchText('')}> <TouchableOpacity onPress={() => setSearchText('')}>
@@ -903,7 +940,10 @@ export const MessageListScreen: React.FC = () => {
{/* 右侧内容区域 - 使用 flex:1 填充剩余空间 */} {/* 右侧内容区域 - 使用 flex:1 填充剩余空间 */}
<View style={styles.chatArea}> <View style={styles.chatArea}>
{selectedConversation ? ( {showNotifications ? (
// 显示系统通知页面
<NotificationsScreen onBack={() => setShowNotifications(false)} />
) : selectedConversation ? (
// 显示选中会话的聊天内容 // 显示选中会话的聊天内容
<EmbeddedChat <EmbeddedChat
conversation={selectedConversation} conversation={selectedConversation}
@@ -927,16 +967,17 @@ export const MessageListScreen: React.FC = () => {
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
{isSearchMode ? ( {showNotifications ? (
// 显示系统通知页面,传入 onBack 回调
<NotificationsScreen onBack={() => setShowNotifications(false)} />
) : isSearchMode ? (
renderSearchMode() renderSearchMode()
) : isWideScreen ? (
<ResponsiveContainer maxWidth={800}>
{renderConversationList()}
</ResponsiveContainer>
) : ( ) : (
isWideScreen ? ( renderConversationList()
<ResponsiveContainer maxWidth={800}>
{renderConversationList()}
</ResponsiveContainer>
) : (
renderConversationList()
)
)} )}
{renderActionMenu()} {renderActionMenu()}
</SafeAreaView> </SafeAreaView>
@@ -1101,7 +1142,7 @@ const styles = StyleSheet.create({
...shadows.sm, ...shadows.sm,
}, },
conversationItemSelected: { conversationItemSelected: {
backgroundColor: '#E3F2FD', backgroundColor: colors.primary.light + '20',
borderColor: colors.primary.main, borderColor: colors.primary.main,
borderWidth: 1, borderWidth: 1,
}, },

View File

@@ -13,18 +13,19 @@ import {
TouchableOpacity, TouchableOpacity,
RefreshControl, RefreshControl,
ActivityIndicator, ActivityIndicator,
Dimensions,
} from 'react-native'; } 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 { useIsFocused } from '@react-navigation/native';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing } from '../../theme'; import { colors, spacing } from '../../theme';
import { SystemMessageResponse } from '../../types/dto'; import { SystemMessageResponse } from '../../types/dto';
import { messageService } from '../../services/messageService'; import { messageService } from '../../services/messageService';
import { commentService } from '../../services/commentService'; import { commentService } from '../../services/commentService';
import { SystemMessageItem } from '../../components/business'; import { SystemMessageItem } from '../../components/business';
import { Text, EmptyState, ResponsiveContainer } from '../../components/common'; import { Text, EmptyState, ResponsiveContainer } from '../../components/common';
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
import { RootStackParamList } from '../../navigation/types'; import { RootStackParamList } from '../../navigation/types';
import { useMessageManagerSystemUnreadCount, useUserStore } from '../../stores'; 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 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']); 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 isFocused = useIsFocused();
const fetchMessageUnreadCount = useUserStore(state => state.fetchMessageUnreadCount); const fetchMessageUnreadCount = useUserStore(state => state.fetchMessageUnreadCount);
const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount(); const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount();
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>(); const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
// 响应式布局 // 修复竞态条件:使用本地状态管理窗口尺寸,避免 hydration 问题
const { isDesktop, isTablet } = useResponsive(); const [windowSize, setWindowSize] = useState({ width: 0, height: 0 });
const isWideScreen = useBreakpointGTE('lg'); 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 [messages, setMessages] = useState<SystemMessageResponse[]>([]);
const [activeType, setActiveType] = useState('all'); const [activeType, setActiveType] = useState('all');
@@ -148,16 +167,12 @@ export const NotificationsScreen: React.FC = () => {
} }
}, [isFocused, fetchMessages, fetchUnreadCount, handleMarkAllRead]); }, [isFocused, fetchMessages, fetchUnreadCount, handleMarkAllRead]);
// 屏幕失去焦点时返回消息列表 // 屏幕失去焦点时,如果有 onBack 回调则调用它(用于内嵌模式)
useEffect(() => { useEffect(() => {
if (!isFocused) { if (!isFocused && onBack) {
// 使用 setTimeout 确保在导航状态稳定后再执行 onBack();
const timer = setTimeout(() => {
navigation.goBack();
}, 0);
return () => clearTimeout(timer);
} }
}, [isFocused, navigation]); }, [isFocused, onBack]);
// 筛选消息 // 筛选消息
const filteredMessages = activeType === 'all' 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 = () => ( const renderEmpty = () => (
<EmptyState <View style={[styles.emptyContainer, isWideScreen && styles.emptyContainerWeb]}>
title="暂无通知" <EmptyState
description="关注一些用户来获取通知" title="暂无通知"
icon="bell-outline" 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 ( return (
<SafeAreaView style={styles.container} edges={[]}> <SafeAreaView style={styles.container} edges={[]}>
{isWideScreen ? ( {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 => { {MESSAGE_TYPES.map(type => {
const count = type.key === 'all' const count = type.key === 'all'
? displayMessages.length ? displayMessages.length
@@ -403,6 +459,8 @@ export const NotificationsScreen: React.FC = () => {
</ResponsiveContainer> </ResponsiveContainer>
) : ( ) : (
<> <>
{/* 头部 - 小屏模式下显示返回按钮 */}
{renderHeader()}
{/* 分类筛选 */} {/* 分类筛选 */}
<View style={styles.filterContainer}> <View style={styles.filterContainer}>
{MESSAGE_TYPES.map(type => { {MESSAGE_TYPES.map(type => {
@@ -491,10 +549,11 @@ const styles = StyleSheet.create({
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: colors.divider, borderBottomColor: colors.divider,
}, },
filterContainerWide: { filterContainerWideWeb: {
paddingHorizontal: spacing.xl, paddingHorizontal: spacing.xl,
paddingVertical: spacing.md, paddingVertical: spacing.md,
justifyContent: 'center', justifyContent: 'center',
backgroundColor: colors.background.paper,
}, },
filterTag: { filterTag: {
flexDirection: 'row', flexDirection: 'row',
@@ -516,6 +575,38 @@ const styles = StyleSheet.create({
filterCount: { filterCount: {
marginLeft: spacing.xs, 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: { listContent: {
flexGrow: 1, flexGrow: 1,
}, },
@@ -537,4 +628,14 @@ const styles = StyleSheet.create({
loadingMoreText: { loadingMoreText: {
marginLeft: spacing.sm, marginLeft: spacing.sm,
}, },
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
emptyContainerWeb: {
minHeight: 500,
justifyContent: 'center',
paddingTop: 100,
},
}); });

View File

@@ -22,10 +22,15 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
onBack, onBack,
onTitlePress, onTitlePress,
onMorePress, onMorePress,
onGroupInfoPress,
isWideScreen: propIsWideScreen,
}) => { }) => {
// 响应式布局 // 响应式布局
const { width } = useResponsive(); const { width } = useResponsive();
const isWideScreen = useBreakpointGTE('lg'); // 使用 768px 作为大屏幕和小屏幕的分界线
const isWideScreenHook = useBreakpointGTE('lg');
// 优先使用 props 传入的 isWideScreen否则使用 hook
const isWideScreen = propIsWideScreen !== undefined ? propIsWideScreen : isWideScreenHook;
// 合并样式 // 合并样式
const styles = useMemo(() => { const styles = useMemo(() => {
@@ -67,12 +72,17 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
return ( return (
<SafeAreaView edges={['top']} style={styles.headerContainer}> <SafeAreaView edges={['top']} style={styles.headerContainer}>
<View style={styles.header}> <View style={styles.header}>
<TouchableOpacity {/* 大屏幕(>= 768px时隐藏返回按钮 */}
style={styles.backButton} {!isWideScreen ? (
onPress={onBack} <TouchableOpacity
> style={styles.backButton}
<MaterialCommunityIcons name="arrow-left" size={22} color={colors.primary.dark} /> onPress={onBack}
</TouchableOpacity> >
<MaterialCommunityIcons name="arrow-left" size={22} color={colors.primary.dark} />
</TouchableOpacity>
) : (
<View style={styles.backButton} />
)}
<View style={styles.headerCenter}> <View style={styles.headerCenter}>
<TouchableOpacity <TouchableOpacity
@@ -115,12 +125,22 @@ export const ChatHeader: React.FC<ChatHeaderProps> = ({
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<TouchableOpacity {/* 大屏幕 + 群聊时显示群信息按钮,否则显示三点菜单 */}
style={styles.moreButton} {isWideScreen && isGroupChat && onGroupInfoPress ? (
onPress={onMorePress} <TouchableOpacity
> style={styles.moreButton}
<MaterialCommunityIcons name="dots-horizontal" size={22} color="#667085" /> onPress={onGroupInfoPress}
</TouchableOpacity> >
<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> </View>
</SafeAreaView> </SafeAreaView>
); );

View File

@@ -134,6 +134,9 @@ export const ChatInput: React.FC<ChatInputProps & {
blurOnSubmit={false} blurOnSubmit={false}
editable={!isDisabled} editable={!isDisabled}
onFocus={onFocus} onFocus={onFocus}
// 确保光标可见
cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`}
/> />
</View> </View>

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

View File

@@ -130,9 +130,22 @@ const ImageSegment: React.FC<{
}> = ({ data, isMe, onImagePress, onImageLongPress }) => { }> = ({ data, isMe, onImagePress, onImageLongPress }) => {
const pressPositionRef = useRef({ x: 0, y: 0 }); const pressPositionRef = useRef({ x: 0, y: 0 });
const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null); const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null);
const [loadError, setLoadError] = useState(false);
const imageUrl = data.thumbnail_url || data.url; const imageUrl = data.thumbnail_url || data.url;
// 如果没有有效的图片URL显示图片占位符
const hasValidUrl = !!imageUrl && imageUrl.trim() !== '';
useEffect(() => { useEffect(() => {
// 重置状态
setLoadError(false);
// 如果没有有效的图片URL设置默认尺寸用于显示占位符
if (!hasValidUrl) {
setDimensions(IMAGE_FALLBACK_SIZE);
return;
}
// 如果已经有宽高信息,直接使用 // 如果已经有宽高信息,直接使用
if (data.width && data.height) { if (data.width && data.height) {
const aspectRatio = data.width / data.height; const aspectRatio = data.width / data.height;
@@ -191,7 +204,7 @@ const ImageSegment: React.FC<{
); );
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}, [imageUrl, data.width, data.height]); }, [imageUrl, data.width, data.height, hasValidUrl]);
// 记录按压位置 // 记录按压位置
const handlePressIn = (event: GestureResponderEvent) => { const handlePressIn = (event: GestureResponderEvent) => {
@@ -204,6 +217,35 @@ const ImageSegment: React.FC<{
onImageLongPress?.(pressPositionRef.current); 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) { if (!dimensions) {
return ( return (
@@ -248,6 +290,9 @@ const ImageSegment: React.FC<{
contentFit="cover" contentFit="cover"
cachePolicy="disk" cachePolicy="disk"
priority="normal" priority="normal"
onError={() => {
setLoadError(true);
}}
/> />
</TouchableOpacity> </TouchableOpacity>
); );

View File

@@ -19,6 +19,7 @@ export { LongPressMenu } from './LongPressMenu';
export { ChatHeader } from './ChatHeader'; export { ChatHeader } from './ChatHeader';
export { MessageBubble } from './MessageBubble'; export { MessageBubble } from './MessageBubble';
export { ChatInput } from './ChatInput'; export { ChatInput } from './ChatInput';
export { GroupInfoPanel } from './GroupInfoPanel';
// Segment 渲染组件 // Segment 渲染组件
export { export {

View File

@@ -322,13 +322,18 @@ export const chatScreenStyles = StyleSheet.create({
inputContainer: { inputContainer: {
backgroundColor: colors.background.paper, backgroundColor: colors.background.paper,
borderWidth: 1, borderWidth: 1,
borderColor: `${colors.divider}88`, borderColor: colors.divider,
borderRadius: 24, borderRadius: 24,
marginHorizontal: 14, marginHorizontal: 14,
marginBottom: 12, marginBottom: 12,
paddingHorizontal: spacing.sm, paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm, paddingVertical: spacing.sm,
...shadows.lg, // 移除明显的阴影效果,改用更微妙的边框
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
}, },
inputInner: { inputInner: {
flexDirection: 'row', flexDirection: 'row',
@@ -338,6 +343,12 @@ export const chatScreenStyles = StyleSheet.create({
paddingHorizontal: spacing.xs, paddingHorizontal: spacing.xs,
paddingVertical: 4, paddingVertical: 4,
minHeight: 44, minHeight: 44,
// 移除阴影效果
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
}, },
inputInnerMuted: { inputInnerMuted: {
backgroundColor: '#F0F0F0', backgroundColor: '#F0F0F0',
@@ -395,6 +406,12 @@ export const chatScreenStyles = StyleSheet.create({
paddingHorizontal: spacing.xs, paddingHorizontal: spacing.xs,
maxHeight: 100, maxHeight: 100,
justifyContent: 'center', justifyContent: 'center',
// 移除阴影
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
}, },
input: { input: {
fontSize: 16, fontSize: 16,

View File

@@ -163,6 +163,10 @@ export interface ChatHeaderProps {
onBack: () => void; onBack: () => void;
onTitlePress: () => void; onTitlePress: () => void;
onMorePress: () => void; onMorePress: () => void;
/** 大屏幕下显示群信息面板的回调 */
onGroupInfoPress?: () => void;
/** 是否为大屏幕模式 */
isWideScreen?: boolean;
} }
// 回复预览 Props // 回复预览 Props

View File

@@ -14,26 +14,40 @@ import {
Platform, Platform,
TextInput, TextInput,
Dimensions, Dimensions,
Animated,
} from 'react-native'; } from 'react-native';
import { useNavigation } from '@react-navigation/native'; import { useNavigation } from '@react-navigation/native';
import { MaterialCommunityIcons } from '@expo/vector-icons'; import { MaterialCommunityIcons } from '@expo/vector-icons';
import { Image as ExpoImage } from 'expo-image';
import { colors, spacing, fontSizes, shadows } from '../../../theme'; import { colors, spacing, fontSizes, shadows } from '../../../theme';
import { ConversationResponse, MessageResponse, MessageSegment } from '../../../types/dto'; import { ConversationResponse, MessageResponse, MessageSegment, GroupMemberResponse, ImageSegmentData } from '../../../types/dto';
import { Avatar, Text } from '../../../components/common'; import { Avatar, Text, ImageGallery } from '../../../components/common';
import { useAuthStore, messageManager, MessageEvent, MessageSubscriber } from '../../../stores'; import { useAuthStore, messageManager, MessageEvent, MessageSubscriber } from '../../../stores';
import { extractTextFromSegments } from '../../../types/dto'; 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 { interface EmbeddedChatProps {
conversation: ConversationResponse; conversation: ConversationResponse;
onBack: () => void; onBack: () => void;
} }
const { width: screenWidth } = Dimensions.get('window');
export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack }) => { export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack }) => {
const navigation = useNavigation(); const navigation = useNavigation();
const currentUser = useAuthStore(state => state.currentUser); 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 [messages, setMessages] = useState<MessageResponse[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -42,6 +56,61 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
const flatListRef = useRef<FlatList>(null); const flatListRef = useRef<FlatList>(null);
const inputRef = useRef<TextInput>(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 isGroupChat = conversation.type === 'group';
const chatTitle = isGroupChat const chatTitle = isGroupChat
@@ -88,6 +157,18 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
}; };
}, [conversation.id, loadMessages]); }, [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 () => { const handleSend = useCallback(async () => {
if (!inputText.trim() || sending) return; if (!inputText.trim() || sending) return;
@@ -127,32 +208,193 @@ 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 renderMessage = ({ item }: { item: MessageResponse }) => {
const isMe = String(item.sender_id) === String(currentUser?.id); 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 ( 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 && ( {!isMe && (
<Avatar <Avatar
source={item.sender?.avatar} source={item.sender?.avatar}
size={36} size={36}
name={item.sender?.nickname || item.sender?.username} name={item.sender?.nickname || item.sender?.username}
/> />
)} )}
<View style={[styles.messageBubble, isMe ? styles.messageBubbleMe : styles.messageBubbleOther]}> <View style={[styles.messageBubble, isMe ? styles.messageBubbleMe : styles.messageBubbleOther]}>
{isGroupChat && !isMe && ( {isGroupChat && !isMe && (
<Text style={styles.senderName}>{item.sender?.nickname || item.sender?.username}</Text> <Text style={styles.senderName}>{item.sender?.nickname || item.sender?.username}</Text>
)} )}
<Text style={[styles.messageText, isMe ? styles.messageTextMe : styles.messageTextOther]}> {renderMessageContent(item)}
{item.status === 'recalled' ? '消息已撤回' : extractTextFromSegments(item.segments)}
</Text>
</View> </View>
{isMe && ( {isMe && (
<Avatar <Avatar
source={currentUser?.avatar} source={currentUser?.avatar}
size={36} size={36}
name={currentUser?.nickname || currentUser?.username} name={currentUser?.nickname || currentUser?.username}
/> />
)} )}
</View> </View>
@@ -163,9 +405,14 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
<View style={styles.container}> <View style={styles.container}>
{/* 头部 */} {/* 头部 */}
<View style={styles.header}> <View style={styles.header}>
<TouchableOpacity onPress={onBack} style={styles.headerButton}> {/* 大屏幕(>= 768px时隐藏返回按钮 */}
<MaterialCommunityIcons name="arrow-left" size={24} color="#666" /> {!isWideScreen ? (
</TouchableOpacity> <TouchableOpacity onPress={onBack} style={styles.headerButton}>
<MaterialCommunityIcons name="arrow-left" size={24} color="#666" />
</TouchableOpacity>
) : (
<View style={styles.headerButton} />
)}
<View style={styles.headerCenter}> <View style={styles.headerCenter}>
<Avatar source={chatAvatar} size={36} name={chatTitle} /> <Avatar source={chatAvatar} size={36} name={chatTitle} />
@@ -174,9 +421,16 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
</Text> </Text>
</View> </View>
<TouchableOpacity onPress={handleNavigateToFullChat} style={styles.headerButton}> {/* 大屏幕 + 群聊时显示群信息按钮,否则显示放大按钮 */}
<MaterialCommunityIcons name="arrow-expand" size={22} color="#666" /> {isWideScreen && isGroupChat ? (
</TouchableOpacity> <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> </View>
{/* 消息列表 */} {/* 消息列表 */}
@@ -231,6 +485,9 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
returnKeyType="send" returnKeyType="send"
onSubmitEditing={handleSend} onSubmitEditing={handleSend}
blurOnSubmit={false} blurOnSubmit={false}
// 确保光标可见
cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`}
/> />
</View> </View>
@@ -252,6 +509,96 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
</View> </View>
</View> </View>
</KeyboardAvoidingView> </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> </View>
); );
}; };
@@ -328,7 +675,7 @@ const styles = StyleSheet.create({
}, },
messageRowRight: { messageRowRight: {
alignSelf: 'flex-end', alignSelf: 'flex-end',
flexDirection: 'row-reverse', justifyContent: 'flex-end',
}, },
messageBubble: { messageBubble: {
maxWidth: screenWidth * 0.5, maxWidth: screenWidth * 0.5,
@@ -387,6 +734,12 @@ const styles = StyleSheet.create({
paddingHorizontal: spacing.md, paddingHorizontal: spacing.md,
minHeight: 40, minHeight: 40,
justifyContent: 'center', justifyContent: 'center',
// 移除阴影
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
}, },
textInput: { textInput: {
fontSize: 15, fontSize: 15,
@@ -406,4 +759,33 @@ const styles = StyleSheet.create({
sendButtonDisabled: { sendButtonDisabled: {
backgroundColor: '#E0E0E0', 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,
},
}); });

View File

@@ -37,7 +37,14 @@ const TAB_ICONS = ['file-document-outline', 'bookmark-outline'];
export const ProfileScreen: React.FC = () => { export const ProfileScreen: React.FC = () => {
const navigation = useNavigation<NavigationProp>(); const navigation = useNavigation<NavigationProp>();
const insets = useSafeAreaInsets(); 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>(); const homeNavigation = useNavigation<HomeNavigationProp>();
// 使用 any 类型来访问根导航 // 使用 any 类型来访问根导航
const rootNavigation = useNavigation<RootNavigationProp>(); const rootNavigation = useNavigation<RootNavigationProp>();

View File

@@ -30,6 +30,7 @@ import {
TimeSlot, TimeSlot,
getCourseColor, getCourseColor,
hasCourseInWeek, hasCourseInWeek,
getCurrentWeek,
} from '../../types/schedule'; } from '../../types/schedule';
import type { ScheduleStackParamList } from '../../navigation/types'; import type { ScheduleStackParamList } from '../../navigation/types';
import { scheduleService } from '../../services/scheduleService'; import { scheduleService } from '../../services/scheduleService';
@@ -130,9 +131,17 @@ const getWeekDates = (weekOffset: number = 0) => {
return getWeekInfo(weekOffset).dates; 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 = () => { export const ScheduleScreen: React.FC = () => {
const navigation = useNavigation<NativeStackNavigationProp<ScheduleStackParamList>>(); const navigation = useNavigation<NativeStackNavigationProp<ScheduleStackParamList>>();
const [currentWeek, setCurrentWeek] = useState(1); const [currentWeek, setCurrentWeek] = useState(getInitialWeek);
const [courses, setCourses] = useState<Course[]>([]); const [courses, setCourses] = useState<Course[]>([]);
const [isAddModalVisible, setIsAddModalVisible] = useState(false); const [isAddModalVisible, setIsAddModalVisible] = useState(false);
const [pendingDay, setPendingDay] = useState<number>(0); const [pendingDay, setPendingDay] = useState<number>(0);
@@ -163,8 +172,10 @@ export const ScheduleScreen: React.FC = () => {
}; };
}, []); }, []);
const todayColumnIndex = getTodayColumnIndex(); const todayColumnIndex = getTodayColumnIndex();
// currentWeek === 1 对应今天所在的真实周offset 0其他周不高亮今日列 // 获取当前实际周数,用于判断是否在查看当前周
const isViewingCurrentWeek = currentWeek === 1; const actualCurrentWeek = getInitialWeek();
// 只有在查看当前实际周时才高亮今日列
const isViewingCurrentWeek = currentWeek === actualCurrentWeek;
const activeTodayColumn = isViewingCurrentWeek ? todayColumnIndex : -1; const activeTodayColumn = isViewingCurrentWeek ? todayColumnIndex : -1;
const dayColumnWidth = getDayColumnWidth(); const dayColumnWidth = getDayColumnWidth();

View File

@@ -45,22 +45,42 @@ interface SyncScheduleResponse {
error_message?: string; error_message?: string;
} }
const toCourse = (dto: ScheduleCourseDTO): Course => ({ const toCourse = (dto: ScheduleCourseDTO): Course => {
id: dto.id, // 调试日志:检查后端返回的 weeks 字段
name: dto.name, console.log('[ScheduleService] 转换课程 DTO:', {
teacher: dto.teacher, id: dto.id,
location: dto.location, name: dto.name,
dayOfWeek: dto.day_of_week, day_of_week: dto.day_of_week,
startSection: dto.start_section, weeks: dto.weeks,
endSection: dto.end_section, weeksType: typeof dto.weeks,
weeks: dto.weeks ?? [], weeksLength: dto.weeks?.length,
color: dto.color, });
}); return {
id: dto.id,
name: dto.name,
teacher: dto.teacher,
location: dto.location,
dayOfWeek: dto.day_of_week,
startSection: dto.start_section,
endSection: dto.end_section,
weeks: dto.weeks ?? [],
color: dto.color,
};
};
class ScheduleService { class ScheduleService {
async getCourses(week?: number): Promise<Course[]> { async getCourses(week?: number): Promise<Course[]> {
const params = week ? { week } : undefined; const params = week ? { week } : undefined;
console.log('[ScheduleService] 获取课程列表, params:', params);
const response = await api.get<ScheduleCourseListResponse>('/schedule/courses', 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); return response.data.list.map(toCourse);
} }
@@ -79,7 +99,9 @@ class ScheduleService {
} }
async syncSchedule(req: SyncScheduleRequest): Promise<SyncScheduleResponse> { async syncSchedule(req: SyncScheduleRequest): Promise<SyncScheduleResponse> {
console.log('[ScheduleService] 开始同步教务系统, username:', req.username);
const response = await api.post<SyncScheduleResponse>('/schedule/sync', req); const response = await api.post<SyncScheduleResponse>('/schedule/sync', req);
console.log('[ScheduleService] 同步响应:', response.data);
return response.data; return response.data;
} }
} }

View File

@@ -6,9 +6,9 @@
// ==================== 颜色系统 ==================== // ==================== 颜色系统 ====================
export const colors = { export const colors = {
primary: { primary: {
main: '#FF6B35', // 胡萝卜 main: '#FF6B35', // 橙色(主色)
light: '#FF8F66', light: '#FFB733',
dark: '#E5521D', dark: '#CC8400',
contrast: '#FFFFFF', contrast: '#FFFFFF',
}, },
secondary: { secondary: {
@@ -16,6 +16,18 @@ export const colors = {
light: '#80E27E', light: '#80E27E',
dark: '#087F23', dark: '#087F23',
}, },
neutral: {
main: '#6B7280', // 中性灰(辅助色)
light: '#9CA3AF',
dark: '#374151',
bg: '#F3F4F6', // 浅灰背景
bgDark: '#1F2937', // 深灰背景
},
accent: {
main: '#E57373', // 低饱和红(点缀色)
light: '#FFCDD2',
dark: '#C62828',
},
background: { background: {
default: '#F5F5F5', default: '#F5F5F5',
paper: '#FFFFFF', paper: '#FFFFFF',
@@ -73,6 +85,8 @@ export const spacing = {
'2xl': 24, '2xl': 24,
'3xl': 32, '3xl': 32,
'4xl': 40, '4xl': 40,
'5xl': 48,
'6xl': 56,
}; };
// ==================== 圆角系统 ==================== // ==================== 圆角系统 ====================

33
src/utils/inputStyles.ts Normal file
View 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, // 会在使用时覆盖
};