diff --git a/app.config.js b/app.config.js index 35b69db..69b32bf 100644 --- a/app.config.js +++ b/app.config.js @@ -23,6 +23,29 @@ const devUpdatesUrl = const expo = appJson.expo; +// 检测是否为 Web 平台 +const isWeb = process.env.EXPO_TARGET === 'web' || process.env.PLATFORM === 'web'; + +// 原生平台特有的插件(在 Web 端会报错) +const nativeOnlyPlugins = [ + 'expo-camera', + 'expo-notifications', + 'expo-background-fetch', + 'expo-media-library', + 'expo-image-picker', + 'expo-video', + 'expo-sqlite', + './plugins/withMainActivityConfigChange', +]; + +// 过滤插件:Web 端排除原生插件 +const filteredPlugins = isWeb + ? expo.plugins.filter((plugin) => { + const pluginName = Array.isArray(plugin) ? plugin[0] : plugin; + return !nativeOnlyPlugins.includes(pluginName); + }) + : expo.plugins; + module.exports = { ...expo, name: isDevVariant ? `${expo.name} Dev` : expo.name, @@ -40,10 +63,27 @@ module.exports = { ...expo.android, package: 'skin.carrot.bbs', }, + // Web 端使用过滤后的插件 + plugins: filteredPlugins, extra: { ...(expo.extra || {}), appVariant: isDevVariant ? 'dev' : 'release', apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl, updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl, }, + // Web 端配置 + ...(isWeb && { + web: { + ...expo.web, + build: { + ...expo.web?.build, + // 配置 Web 端别名,避免加载原生模块 + babel: { + dangerouslyAddModulePathsToTranspile: [ + 'react-native-webrtc', + ], + }, + }, + }, + }), }; diff --git a/package-lock.json b/package-lock.json index cbc15c9..8b4ce28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "carrot_bbs", - "version": "1.0.1", + "version": "1.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "carrot_bbs", - "version": "1.0.1", + "version": "1.0.2", "dependencies": { "@expo/vector-icons": "^15.1.1", "@react-native-async-storage/async-storage": "^2.2.0", diff --git a/src/components/business/QRCodeScanner.tsx b/src/components/business/QRCodeScanner.tsx index 401d683..f05cf62 100644 --- a/src/components/business/QRCodeScanner.tsx +++ b/src/components/business/QRCodeScanner.tsx @@ -7,8 +7,8 @@ import { Alert, Modal, Dimensions, + Platform, } from 'react-native'; -import { CameraView, useCameraPermissions } from 'expo-camera'; import { useRouter } from 'expo-router'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as hrefs from '../../navigation/hrefs'; @@ -133,22 +133,74 @@ function createQrScannerStyles(colors: AppColors) { fontSize: fontSizes.md, fontWeight: '600', }, + webNotSupportedContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + padding: spacing.xl, + backgroundColor: '#000', + }, + webNotSupportedText: { + marginTop: spacing.lg, + fontSize: fontSizes.md, + color: 'rgba(255,255,255,0.72)', + textAlign: 'center', + lineHeight: 24, + }, }); } -export const QRCodeScanner: React.FC = ({ visible, onClose }) => { +// Web 端不支持扫码组件 +const QRCodeScannerWeb: React.FC = ({ visible, onClose }) => { + const themeColors = useAppColors(); + const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]); + + return ( + + + + + + + 扫码登录 + + + + + + 扫码登录功能暂不支持网页端使用{'\n'} + 请下载手机 App 体验完整功能 + + + 知道了 + + + + + ); +}; + +// 原生端扫码组件 +const QRCodeScannerNative: React.FC = ({ visible, onClose }) => { const router = useRouter(); const themeColors = useAppColors(); const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]); - const [permission, requestPermission] = useCameraPermissions(); + + // 动态导入 expo-camera,避免 Web 端加载 + const [cameraModule, setCameraModule] = useState(null); + const [permission, setPermission] = useState(null); const [scanned, setScanned] = useState(false); useEffect(() => { - if (visible) { - setScanned(false); - if (!permission?.granted) { - requestPermission(); - } + if (visible && Platform.OS !== 'web') { + import('expo-camera').then((module) => { + setCameraModule(module); + const [perm, requestPerm] = module.useCameraPermissions(); + setPermission(perm); + if (!perm?.granted) { + requestPerm(); + } + }); } }, [visible]); @@ -178,7 +230,7 @@ export const QRCodeScanner: React.FC = ({ visible, onClose } } }; - if (!permission?.granted) { + if (!cameraModule || !permission?.granted) { return ( @@ -192,7 +244,10 @@ export const QRCodeScanner: React.FC = ({ visible, onClose } 需要相机权限才能扫码 - + cameraModule?.useCameraPermissions()[1]()} + > 授予权限 @@ -201,6 +256,8 @@ export const QRCodeScanner: React.FC = ({ visible, onClose } ); } + const { CameraView } = cameraModule; + return ( @@ -243,4 +300,9 @@ export const QRCodeScanner: React.FC = ({ visible, onClose } ); }; +// 根据平台导出不同组件 +export const QRCodeScanner: React.FC = Platform.OS === 'web' + ? QRCodeScannerWeb + : QRCodeScannerNative; + export default QRCodeScanner; diff --git a/src/components/call/CallScreen.web.tsx b/src/components/call/CallScreen.web.tsx new file mode 100644 index 0000000..a8c9720 --- /dev/null +++ b/src/components/call/CallScreen.web.tsx @@ -0,0 +1,160 @@ +import React from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + StatusBar, +} from 'react-native'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import { callStore } from '../../stores/callStore'; + +const formatDuration = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; +}; + +const CallScreen: React.FC = () => { + const currentCall = callStore((s) => s.currentCall); + const callDuration = callStore((s) => s.callDuration); + const endCall = callStore((s) => s.endCall); + const isMinimized = callStore((s) => s.isMinimized); + + // Don't render full screen when minimized or no call + if (!currentCall || isMinimized) return null; + + const getStatusText = (): string => { + switch (currentCall.status) { + case 'ringing': + return '通话功能暂不支持网页端'; + case 'connecting': + return '通话功能暂不支持网页端'; + case 'connected': + return '通话功能暂不支持网页端'; + case 'ending': + return '通话已结束'; + default: + return ''; + } + }; + + const handleEndCall = () => { + endCall('hangup'); + }; + + return ( + + + + {/* Background - single consistent color */} + + + {/* Center: Peer info */} + + + + + + + + {currentCall.peerName || '未知用户'} + + {getStatusText()} + + + {/* Bottom controls */} + + {/* End call - prominent red button */} + + + + + 关闭 + + + + ); +}; + +const END_CALL_SIZE = 64; + +const styles = StyleSheet.create({ + container: { + ...StyleSheet.absoluteFillObject, + backgroundColor: '#1A1A2E', + zIndex: 9999, + }, + background: { + ...StyleSheet.absoluteFillObject, + backgroundColor: '#1A1A2E', + }, + centerArea: { + position: 'absolute', + top: '28%', + left: 0, + right: 0, + alignItems: 'center', + paddingHorizontal: 30, + }, + avatarOuter: { + marginBottom: 16, + }, + avatar: { + width: 100, + height: 100, + borderRadius: 50, + backgroundColor: '#3A3A5C', + }, + avatarPlaceholder: { + justifyContent: 'center', + alignItems: 'center', + }, + avatarText: { + fontSize: 40, + color: '#fff', + fontWeight: '600', + }, + peerName: { + fontSize: 22, + fontWeight: '600', + color: '#FFFFFF', + marginBottom: 6, + textAlign: 'center', + maxWidth: 280, + }, + status: { + fontSize: 14, + color: 'rgba(255, 255, 255, 0.5)', + letterSpacing: 0.3, + }, + controls: { + position: 'absolute', + bottom: 60, + left: 0, + right: 0, + alignItems: 'center', + }, + endCallButton: { + alignItems: 'center', + }, + endCallCircle: { + width: END_CALL_SIZE, + height: END_CALL_SIZE, + borderRadius: END_CALL_SIZE / 2, + backgroundColor: '#E54D42', + justifyContent: 'center', + alignItems: 'center', + }, + endCallLabel: { + fontSize: 11, + color: 'rgba(255, 255, 255, 0.55)', + marginTop: 8, + }, +}); + +export default CallScreen; \ No newline at end of file diff --git a/src/screens/apps/AppsScreen.tsx b/src/screens/apps/AppsScreen.tsx index acd9be3..cfd1665 100644 --- a/src/screens/apps/AppsScreen.tsx +++ b/src/screens/apps/AppsScreen.tsx @@ -18,7 +18,7 @@ import { type AppColors, } from '../../theme'; import * as hrefs from '../../navigation/hrefs'; -import { Text, ResponsiveContainer } from '../../components/common'; +import { Text } from '../../components/common'; import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive'; type AppItem = { @@ -46,6 +46,9 @@ const APP_ENTRIES: AppItem[] = [ }, ]; +// 应用卡片最大宽度设置 +const APP_CARD_MAX_WIDTH = 720; + export const AppsScreen: React.FC = () => { const colors = useAppColors(); const resolvedScheme = useResolvedColorScheme(); @@ -63,6 +66,9 @@ export const AppsScreen: React.FC = () => { shadowOpacity: 0.06, shadowRadius: 8, elevation: 2, + maxWidth: APP_CARD_MAX_WIDTH, + alignSelf: 'center' as const, + width: '100%' as const, }), [colors] ); @@ -136,28 +142,16 @@ export const AppsScreen: React.FC = () => { - {isWideScreen ? ( - - - {renderTiles()} - - - ) : ( - - {renderTiles()} - - )} + + {renderTiles()} + ); }; diff --git a/src/screens/material/MaterialsScreen.tsx b/src/screens/material/MaterialsScreen.tsx index 7dda791..bb8ccf0 100644 --- a/src/screens/material/MaterialsScreen.tsx +++ b/src/screens/material/MaterialsScreen.tsx @@ -24,8 +24,11 @@ import { type AppColors, } from '../../theme'; import * as hrefs from '../../navigation/hrefs'; -import { Text, ResponsiveContainer, Loading } from '../../components/common'; +import { Text, Loading } from '../../components/common'; import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive'; + +// 学科卡片最大宽度设置 +const SUBJECT_CARD_MAX_WIDTH = 720; import type { MaterialSubject } from '../../types/material'; import { materialRepository } from '../../data/repositories/MaterialRepository'; @@ -87,6 +90,9 @@ export const MaterialsScreen: React.FC = () => { shadowOpacity: 0.06, shadowRadius: 8, elevation: 2, + maxWidth: SUBJECT_CARD_MAX_WIDTH, + alignSelf: 'center' as const, + width: '100%' as const, }), [colors] ); @@ -177,42 +183,23 @@ export const MaterialsScreen: React.FC = () => { {/* Content */} - {isWideScreen ? ( - - - } - > - {renderContent()} - - - ) : ( - - } - > - {renderContent()} - - )} + + } + > + {renderContent()} + ); }; diff --git a/src/screens/material/SubjectMaterialsScreen.tsx b/src/screens/material/SubjectMaterialsScreen.tsx index cf9985d..0ed73a1 100644 --- a/src/screens/material/SubjectMaterialsScreen.tsx +++ b/src/screens/material/SubjectMaterialsScreen.tsx @@ -25,8 +25,11 @@ import { type AppColors, } from '../../theme'; import * as hrefs from '../../navigation/hrefs'; -import { Text, ResponsiveContainer, Loading } from '../../components/common'; +import { Text, Loading } from '../../components/common'; import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive'; + +// 资料卡片最大宽度 +const MATERIAL_CARD_MAX_WIDTH = 720; import type { MaterialSubject, MaterialFile, MaterialFileType } from '../../types/material'; import { materialRepository, @@ -120,6 +123,9 @@ export const SubjectMaterialsScreen: React.FC = () => { shadowOpacity: 0.05, shadowRadius: 4, elevation: 1, + maxWidth: MATERIAL_CARD_MAX_WIDTH, + alignSelf: 'center' as const, + width: '100%' as const, }), [colors] ); @@ -266,17 +272,9 @@ export const SubjectMaterialsScreen: React.FC = () => { {/* Content */} - {isWideScreen ? ( - - - {renderContent()} - - - ) : ( - - {renderContent()} - - )} + + {renderContent()} + ); }; diff --git a/src/screens/message/components/ChatScreen/EmojiPanel.tsx b/src/screens/message/components/ChatScreen/EmojiPanel.tsx index 9c0cfa8..0fd05c1 100644 --- a/src/screens/message/components/ChatScreen/EmojiPanel.tsx +++ b/src/screens/message/components/ChatScreen/EmojiPanel.tsx @@ -5,7 +5,7 @@ */ import React, { useState, useEffect, useCallback, useMemo } from 'react'; -import { View, TouchableOpacity, ActivityIndicator, Modal, Alert, Dimensions, StyleSheet, FlatList, ListRenderItem } from 'react-native'; +import { View, TouchableOpacity, ActivityIndicator, Modal, Alert, Dimensions, StyleSheet, FlatList, ListRenderItem, ScrollView } from 'react-native'; import { Image as ExpoImage } from 'expo-image'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import * as ImagePicker from 'expo-image-picker'; @@ -18,11 +18,11 @@ import { useAppColors, spacing } from '../../../../theme'; const { height: SCREEN_HEIGHT } = Dimensions.get('window'); -// 表情尺寸配置 + // 表情尺寸配置 - 固定宽度 40px,使用 flex 布局 const EMOJI_SIZES = { - mobile: { size: 28, itemHeight: 52 }, - tablet: { size: 32, itemHeight: 60 }, - desktop: { size: 36, itemHeight: 68 }, + mobile: { size: 24, itemWidth: 40, itemHeight: 40 }, + tablet: { size: 26, itemWidth: 40, itemHeight: 40 }, + desktop: { size: 28, itemWidth: 40, itemHeight: 40 }, }; // Tab 类型 @@ -62,29 +62,30 @@ export const EmojiPanel: React.FC = ({ return EMOJI_SIZES.mobile; }, [isWideScreen, isTablet]); - // 表情网格列数 - const emojiColumns = useMemo(() => { - if (isWideScreen) return 10; - if (isTablet) return 9; - return 8; - }, [isWideScreen, isTablet]); + - // 合并样式 + // 合并样式 - 使用 flex 布局,每个表情固定 40px 宽度 const styles = useMemo(() => { return StyleSheet.create({ ...baseStyles, + emojiContainer: { + flexDirection: 'row', + flexWrap: 'wrap', + padding: spacing.sm, + }, emojiItem: { - ...baseStyles.emojiItem, - width: `${100 / emojiColumns}%`, - height: emojiSize.itemHeight, + width: 40, + height: 40, + margin: 10, + alignItems: 'center', + justifyContent: 'center', }, emojiText: { - ...baseStyles.emojiText, fontSize: emojiSize.size, - lineHeight: emojiSize.size + 6, + lineHeight: emojiSize.size + 4, }, }); - }, [emojiSize, emojiColumns, baseStyles]); + }, [emojiSize, baseStyles]); // 加载自定义表情 const loadStickers = useCallback(async () => { @@ -106,37 +107,26 @@ export const EmojiPanel: React.FC = ({ } }, [activeTab, loadStickers]); - const renderEmojiItem: ListRenderItem = useCallback(({ item }) => ( - onInsertEmoji(item)} - activeOpacity={0.7} - > - {item} - - ), [styles.emojiItem, styles.emojiText, onInsertEmoji]); - // 渲染 Emoji 面板(使用 FlatList 虚拟化,降低首次打开卡顿) + + // 渲染 Emoji 面板(使用 flex 布局,每个表情固定 40px 宽度) const renderEmojiPanel = () => ( - `${item}-${index}`} - renderItem={renderEmojiItem} - contentContainerStyle={styles.emojiGrid} - showsVerticalScrollIndicator={false} + ({ - length: emojiSize.itemHeight, - offset: emojiSize.itemHeight * Math.floor(index / emojiColumns), - index, - })} - /> + > + {EMOJIS.map((emoji, index) => ( + onInsertEmoji(emoji)} + activeOpacity={0.7} + > + {emoji} + + ))} + ); // 添加表情(从相册选择) diff --git a/src/screens/message/components/ChatScreen/GroupInfoPanel.web.tsx b/src/screens/message/components/ChatScreen/GroupInfoPanel.web.tsx new file mode 100644 index 0000000..4e684d3 --- /dev/null +++ b/src/screens/message/components/ChatScreen/GroupInfoPanel.web.tsx @@ -0,0 +1,602 @@ +/** + * GroupInfoPanel.web.tsx + * Web端群信息侧边栏组件 + * 大屏幕模式下从右侧滑入显示 + * 实现手机端群信息界面的核心功能 + */ + +import React, { useEffect, useState, useCallback, useMemo } 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 { useAppColors, spacing, fontSizes, shadows, type AppColors } 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 = ({ + visible, + groupId, + groupInfo, + conversationId, + isPinned, + currentUserId, + onClose, + onTogglePin, + onLeaveGroup, + onInviteMembers, + onViewAllMembers, +}) => { + const colors = useAppColors(); + const styles = useMemo(() => createGroupInfoPanelStyles(colors), [colors]); + const isWideScreen = useBreakpointGTE('lg'); + // Web 端不使用 useNativeDriver + const slideAnim = React.useRef(new Animated.Value(PANEL_WIDTH)).current; + const fadeAnim = React.useRef(new Animated.Value(0)).current; + const [members, setMembers] = useState([]); + const [announcements, setAnnouncements] = useState([]); + const [loading, setLoading] = useState(false); + const [showAllMembers, setShowAllMembers] = useState(false); + const [allMembers, setAllMembers] = useState([]); + 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, + // Web 端不使用 useNativeDriver + useNativeDriver: false, + }), + Animated.timing(fadeAnim, { + toValue: 1, + duration: 300, + // Web 端不使用 useNativeDriver + useNativeDriver: false, + }), + ]).start(); + } else { + Animated.parallel([ + Animated.timing(slideAnim, { + toValue: PANEL_WIDTH, + duration: 300, + // Web 端不使用 useNativeDriver + useNativeDriver: false, + }), + Animated.timing(fadeAnim, { + toValue: 0, + duration: 300, + // Web 端不使用 useNativeDriver + useNativeDriver: false, + }), + ]).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 && ( + + + + )} + + {/* 侧边栏面板 */} + + {/* 头部 */} + + 群信息 + + + + + + + {/* 群头像和名称 */} + + + {groupInfo?.name || '群聊'} + + {groupInfo?.member_count || members.length || 0} 位成员 + + {groupInfo?.join_type !== undefined && ( + + {getJoinTypeText(groupInfo.join_type)} + + )} + {/* 邀请新成员按钮 */} + {onInviteMembers && ( + + + 邀请新成员 + + )} + + + {/* 分割线 */} + + + {/* 群公告 */} + {announcements.length > 0 && ( + + + + 群公告 + + + {announcements[0].content} + + {new Date(announcements[0].created_at).toLocaleDateString()} + + + + )} + + {/* 群简介 */} + {!!groupInfo?.description && ( + + 群简介 + + {groupInfo.description} + + + )} + + {/* 群成员列表 */} + + + 成员列表 ({groupInfo?.member_count || members.length}) + {onViewAllMembers && !showAllMembers && members.length >= 10 && ( + + + {loadingMoreMembers ? '加载中...' : '查看全部'} + + + + )} + {onViewAllMembers && showAllMembers && ( + setShowAllMembers(false)} style={styles.viewAllButton}> + 收起 + + + )} + + + {(showAllMembers ? allMembers : members).map((member) => ( + + + + + {member.nickname || member.user?.nickname || '用户'} + + {member.role === 'owner' && ( + 群主 + )} + {member.role === 'admin' && ( + 管理员 + )} + + + ))} + + + + {/* 群信息 */} + + 群信息 + + 群号 + {groupInfo?.id || '-'} + + + 创建时间 + + {groupInfo?.created_at + ? new Date(groupInfo.created_at).toLocaleDateString('zh-CN') + : '-'} + + + + 全员禁言 + + {groupInfo?.mute_all ? '已开启' : '已关闭'} + + + + + {/* 操作按钮 */} + + {conversationId && onTogglePin && ( + onTogglePin(!isPinned)} + activeOpacity={0.7} + > + + + {isPinned ? '取消置顶' : '置顶群聊'} + + + )} + + {onLeaveGroup && ( + + + + 退出群聊 + + + )} + + + + + ); +}; + +function createGroupInfoPanelStyles(colors: AppColors) { + return 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: colors.background.paper, + zIndex: 101, + ...shadows.lg, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: spacing.md, + paddingVertical: spacing.md, + borderBottomWidth: 1, + borderBottomColor: colors.divider, + }, + headerTitle: { + fontSize: fontSizes.xl, + fontWeight: '600', + color: colors.text.primary, + }, + closeButton: { + padding: spacing.xs, + }, + content: { + flex: 1, + }, + groupInfoSection: { + alignItems: 'center', + paddingVertical: spacing.xl, + }, + groupName: { + fontSize: fontSizes.xl, + fontWeight: '600', + color: colors.text.primary, + marginTop: spacing.md, + }, + memberCount: { + fontSize: fontSizes.md, + color: colors.text.secondary, + 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: colors.divider, + marginHorizontal: spacing.md, + }, + section: { + padding: spacing.md, + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.sm, + }, + sectionTitle: { + fontSize: fontSizes.md, + fontWeight: '600', + color: colors.text.primary, + marginLeft: spacing.xs, + }, + noticeBox: { + backgroundColor: colors.chat.tipBg, + padding: spacing.md, + borderRadius: 8, + borderLeftWidth: 3, + borderLeftColor: colors.warning.main, + }, + noticeText: { + fontSize: fontSizes.sm, + color: colors.text.secondary, + lineHeight: 20, + }, + noticeTime: { + fontSize: fontSizes.xs, + color: colors.text.hint, + marginTop: spacing.xs, + textAlign: 'right', + }, + descriptionBox: { + backgroundColor: colors.chat.surfaceMuted, + padding: spacing.md, + borderRadius: 8, + }, + description: { + fontSize: fontSizes.sm, + color: colors.text.secondary, + 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: colors.text.primary, + 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: colors.text.secondary, + textAlign: 'center', + paddingVertical: spacing.sm, + }, + infoRow: { + flexDirection: 'row', + justifyContent: 'space-between', + paddingVertical: spacing.sm, + borderBottomWidth: 1, + borderBottomColor: colors.chat.borderHairline, + }, + infoLabel: { + fontSize: fontSizes.sm, + color: colors.text.secondary, + }, + infoValue: { + fontSize: fontSizes.sm, + color: colors.text.primary, + }, + 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; \ No newline at end of file diff --git a/src/screens/message/components/EmbeddedChat.tsx b/src/screens/message/components/EmbeddedChat.tsx index 0238ad2..0e078a1 100644 --- a/src/screens/message/components/EmbeddedChat.tsx +++ b/src/screens/message/components/EmbeddedChat.tsx @@ -23,6 +23,8 @@ import { spacing, fontSizes, shadows, useAppColors, type AppColors } from '../.. import { ConversationResponse, MessageResponse, MessageSegment, GroupMemberResponse, ImageSegmentData } from '../../../types/dto'; import { Avatar, Text, ImageGallery, AppBackButton } from '../../../components/common'; import { useAuthStore, messageManager, MessageEvent, MessageSubscriber } from '../../../stores'; +import { formatDistanceToNow } from 'date-fns'; +import { zhCN } from 'date-fns/locale'; import { extractTextFromSegments } from '../../../types/dto'; import { useBreakpointGTE } from '../../../hooks/useResponsive'; import { useMarkAsRead } from '../../../stores/messageManagerHooks'; @@ -30,15 +32,20 @@ import { messageService } from '../../../services'; import * as hrefs from '../../../navigation/hrefs'; import { GroupInfoPanel } from './ChatScreen/GroupInfoPanel'; import { LongPressMenu, MenuPosition, GroupMessage } from './ChatScreen'; +import { EmojiPanel } from './ChatScreen/EmojiPanel'; +import { MorePanel } from './ChatScreen/MorePanel'; const { width: screenWidth } = Dimensions.get('window'); const PANEL_WIDTH = 360; +// 时间分隔间隔(毫秒)- 5分钟 +const TIME_SEPARATOR_INTERVAL = 5 * 60 * 1000; + function createEmbeddedChatStyles(colors: AppColors) { return StyleSheet.create({ container: { flex: 1, - backgroundColor: colors.chat.screen, + backgroundColor: colors.background.default, }, header: { flexDirection: 'row', @@ -78,7 +85,7 @@ function createEmbeddedChatStyles(colors: AppColors) { }, messageList: { flex: 1, - backgroundColor: colors.chat.screen, + backgroundColor: colors.background.default, }, centerContainer: { flex: 1, @@ -102,7 +109,7 @@ function createEmbeddedChatStyles(colors: AppColors) { }, messageRow: { flexDirection: 'row', - alignItems: 'flex-end', + alignItems: 'flex-start', marginBottom: spacing.md, maxWidth: screenWidth * 0.7, }, @@ -112,6 +119,15 @@ function createEmbeddedChatStyles(colors: AppColors) { messageRowRight: { alignSelf: 'flex-end', justifyContent: 'flex-end', + alignItems: 'flex-end', + }, + avatarWrapper: { + marginHorizontal: 2, + }, + messageContent: { + maxWidth: screenWidth * 0.5, + marginHorizontal: spacing.xs, + alignItems: 'flex-start', }, messageBubble: { maxWidth: screenWidth * 0.5, @@ -122,11 +138,11 @@ function createEmbeddedChatStyles(colors: AppColors) { }, messageBubbleMe: { backgroundColor: colors.chat.replyTint, - borderBottomRightRadius: 4, + borderTopRightRadius: 4, }, messageBubbleOther: { backgroundColor: colors.chat.bubbleIncoming, - borderBottomLeftRadius: 4, + borderTopLeftRadius: 4, shadowColor: colors.chat.shadow, shadowOffset: { width: 0, height: 0.5 }, shadowOpacity: 0.04, @@ -134,9 +150,11 @@ function createEmbeddedChatStyles(colors: AppColors) { elevation: 1, }, senderName: { - fontSize: 12, + fontSize: 14, color: colors.text.secondary, - marginBottom: 2, + marginBottom: 4, + marginLeft: 4, + fontWeight: '600', }, messageText: { fontSize: 15, @@ -149,7 +167,7 @@ function createEmbeddedChatStyles(colors: AppColors) { color: colors.chat.textPrimary, }, inputArea: { - backgroundColor: colors.chat.screen, + backgroundColor: colors.background.paper, borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: colors.divider, paddingHorizontal: spacing.md, @@ -227,6 +245,43 @@ function createEmbeddedChatStyles(colors: AppColors) { fontSize: 12, marginTop: 4, }, + // 时间分隔样式 + timeContainer: { + alignItems: 'center', + marginVertical: spacing.md, + }, + timeText: { + color: 'rgba(142, 142, 147, 0.95)', + fontSize: 11, + backgroundColor: 'rgba(142, 142, 147, 0.1)', + paddingHorizontal: spacing.sm + 4, + paddingVertical: 5, + borderRadius: 12, + fontWeight: '500', + overflow: 'hidden', + letterSpacing: 0.2, + }, + // 系统通知样式(如"某某加入了群聊") + systemNoticeContainer: { + alignItems: 'center', + marginVertical: spacing.sm, + paddingHorizontal: spacing.md, + }, + systemNoticeText: { + fontSize: 12, + color: colors.chat.textSecondary, + backgroundColor: 'rgba(142, 142, 147, 0.12)', + paddingHorizontal: spacing.md, + paddingVertical: spacing.xs, + borderRadius: 12, + overflow: 'hidden', + }, + // 面板容器样式 + panelContainer: { + backgroundColor: colors.background.paper, + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: colors.divider, + }, }); } @@ -268,6 +323,81 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack // 回复状态 const [replyingToMessage, setReplyingToMessage] = useState(null); + // 面板显示状态 + const [showEmojiPanel, setShowEmojiPanel] = useState(false); + const [showMorePanel, setShowMorePanel] = useState(false); + + // 切换表情面板 + const toggleEmojiPanel = useCallback(() => { + setShowEmojiPanel(prev => !prev); + setShowMorePanel(false); + if (!showEmojiPanel) { + inputRef.current?.blur(); + } + }, [showEmojiPanel]); + + // 切换更多功能面板 + const toggleMorePanel = useCallback(() => { + setShowMorePanel(prev => !prev); + setShowEmojiPanel(false); + if (!showMorePanel) { + inputRef.current?.blur(); + } + }, [showMorePanel]); + + // 关闭所有面板 + const closePanels = useCallback(() => { + setShowEmojiPanel(false); + setShowMorePanel(false); + }, []); + + // 插入表情 + const handleInsertEmoji = useCallback((emoji: string) => { + setInputText(prev => prev + emoji); + }, []); + + // 发送自定义表情 + const handleSendSticker = useCallback(async (stickerUrl: string) => { + if (!conversation.id) return; + + try { + setSending(true); + const segments: MessageSegment[] = [{ + type: 'image', + data: { url: stickerUrl, thumbnail_url: stickerUrl } + }]; + await messageManager.sendMessage(String(conversation.id), segments); + closePanels(); + } catch (error) { + console.error('[EmbeddedChat] 发送表情失败:', error); + } finally { + setSending(false); + } + }, [conversation.id, closePanels]); + + // 处理更多功能 + const handleMoreAction = useCallback((actionId: string) => { + switch (actionId) { + case 'image': + // TODO: 实现选择图片功能 + console.log('选择图片'); + break; + case 'camera': + // TODO: 实现相机功能 + console.log('相机'); + break; + case 'file': + // TODO: 实现文件功能 + console.log('文件'); + break; + case 'location': + // TODO: 实现位置功能 + console.log('位置'); + break; + } + closePanels(); + }, [closePanels]); + // 群信息面板动画 const slideAnim = useRef(new Animated.Value(PANEL_WIDTH)).current; const fadeAnim = useRef(new Animated.Value(0)).current; @@ -276,6 +406,42 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack // 群成员列表 const [groupMembers, setGroupMembers] = useState([]); + // 格式化时间 + const formatTime = useCallback((dateString: string): string => { + try { + const date = new Date(dateString); + const now = new Date(); + const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60); + + if (diffInHours < 24 && date.getDate() === now.getDate()) { + // 当天消息显示 "上午/下午 HH:mm" 格式 + const hours = date.getHours(); + const period = hours < 12 ? '上午' : '下午'; + const displayHours = hours % 12 || 12; + const minutes = date.getMinutes().toString().padStart(2, '0'); + return `${period} ${displayHours}:${minutes}`; + } + + return formatDistanceToNow(date, { + addSuffix: true, + locale: zhCN, + }); + } catch { + return ''; + } + }, []); + + // 检查是否需要显示时间分隔 + const shouldShowTime = useCallback((index: number): boolean => { + if (index === 0) return true; + const currentMsg = messages[index]; + const prevMsg = messages[index - 1]; + if (!currentMsg || !prevMsg) return false; + const currentTime = new Date(currentMsg.created_at).getTime(); + const prevTime = new Date(prevMsg.created_at).getTime(); + return (currentTime - prevTime) > TIME_SEPARATOR_INTERVAL; + }, [messages]); + // 打开/关闭群信息面板 const toggleGroupInfoPanel = useCallback(() => { if (showGroupInfoPanel) { @@ -325,8 +491,21 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack setLoading(true); await messageManager.fetchMessages(String(conversation.id)); setMessages(messageManager.getMessages(String(conversation.id))); - } catch (error) { - console.error('[EmbeddedChat] 加载消息失败:', error); + } catch (error: any) { + // 检查是否是权限错误或会话不存在错误 + const errorMessage = error?.message || String(error); + if ( + errorMessage.includes('not found') || + errorMessage.includes('no permission') || + error?.code === 'NOT_FOUND' || + error?.code === 'FORBIDDEN' + ) { + console.warn('[EmbeddedChat] 会话不存在或无权限访问:', conversation.id); + // 设置空消息列表,让 UI 显示"暂无消息"而不是崩溃 + setMessages([]); + } else { + console.error('[EmbeddedChat] 加载消息失败:', error); + } } finally { setLoading(false); } @@ -558,9 +737,36 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack }; // 渲染消息 - const renderMessage = ({ item }: { item: MessageResponse }) => { + const renderMessage = ({ item, index }: { item: MessageResponse; index: number }) => { const isMe = String(item.sender_id) === String(currentUser?.id); - + const showTime = shouldShowTime(index); + + // 系统通知消息特殊处理 + // 1. is_system_notice 字段(WebSocket 推送时设置) + // 2. category === 'notification'(从数据库加载时使用) + // 3. sender_id === '10000'(系统发送者兜底) + const isSystemNotice = (item as any).is_system_notice || item.category === 'notification' || item.sender_id === '10000'; + + // 系统通知消息渲染(如"某某加入了群聊") + if (isSystemNotice) { + return ( + + {showTime && ( + + + {formatTime(item.created_at)} + + + )} + + + {(item as any).notice_content || extractTextFromSegments(item.segments)} + + + + ); + } + // 处理指针事件(鼠标右键)- 使用 onPointerDown 检测鼠标右键 const handlePointerDown = (e: any) => { // e.nativeEvent.button: 0=左键, 1=中键, 2=右键 @@ -572,33 +778,55 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack handleMessageRightPress(item, e); } }; - + return ( - - {!isMe && ( - + + {/* 时间分隔 */} + {showTime && ( + + + {formatTime(item.created_at)} + + )} - - {isGroupChat && !isMe && ( - {item.sender?.nickname || item.sender?.username} + + {/* 对方头像(左侧) */} + {!isMe && ( + + + + )} + + {/* 消息内容区域 */} + + {/* 群聊模式:显示发送者昵称(在气泡上方,与头像顶部对齐) */} + {isGroupChat && !isMe && ( + {item.sender?.nickname || item.sender?.username} + )} + + {renderMessageContent(item)} + + + + {/* 自己的头像(右侧) */} + {isMe && ( + + + )} - {renderMessageContent(item)} - {isMe && ( - - )} ); }; @@ -669,8 +897,12 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack > - - + + @@ -691,8 +923,12 @@ export const EmbeddedChat: React.FC = ({ conversation, onBack /> - - + + = ({ conversation, onBack + + {/* 表情面板 */} + {showEmojiPanel && ( + + + + )} + + {/* 更多功能面板 */} + {showMorePanel && ( + + + + )} {/* 群信息侧边栏面板 - 仅大屏幕显示 */} diff --git a/src/screens/profile/AccountSecurityScreen.tsx b/src/screens/profile/AccountSecurityScreen.tsx index ac85f26..63ccce5 100644 --- a/src/screens/profile/AccountSecurityScreen.tsx +++ b/src/screens/profile/AccountSecurityScreen.tsx @@ -10,8 +10,11 @@ import { import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme'; -import { Text, ResponsiveContainer } from '../../components/common'; -import { useResponsive } from '../../hooks'; +import { Text } from '../../components/common'; +import { useResponsive, useResponsiveSpacing } from '../../hooks'; + +// 内容最大宽度 +const CONTENT_MAX_WIDTH = 720; import { authService, resolveAuthApiError } from '../../services/authService'; import { showPrompt } from '../../services/promptService'; import { useAuthStore } from '../../stores'; @@ -340,15 +343,13 @@ export const AccountSecurityScreen: React.FC = () => { ); + const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 }); + return ( - {isWideScreen ? ( - - {content} - - ) : ( - {content} - )} + + {content} + ); }; @@ -380,6 +381,9 @@ function createAccountSecurityStyles(colors: AppColors) { marginHorizontal: spacing.lg, borderRadius: borderRadius.lg, padding: spacing.md, + maxWidth: CONTENT_MAX_WIDTH, + alignSelf: 'center', + width: '100%', }, statusRow: { flexDirection: 'row', diff --git a/src/screens/profile/EditProfileScreen.tsx b/src/screens/profile/EditProfileScreen.tsx index a57576a..5da8db4 100644 --- a/src/screens/profile/EditProfileScreen.tsx +++ b/src/screens/profile/EditProfileScreen.tsx @@ -31,9 +31,12 @@ import { type AppColors, } from '../../theme'; import { useAuthStore } from '../../stores'; -import { Avatar, Button, Text, ResponsiveContainer } from '../../components/common'; +import { Avatar, Button, Text } from '../../components/common'; import { authService, uploadService } from '../../services'; -import { useResponsive } from '../../hooks'; +import { useResponsive, useResponsiveSpacing } from '../../hooks'; + +// 内容最大宽度 +const CONTENT_MAX_WIDTH = 720; function createEditProfileStyles(colors: AppColors) { return StyleSheet.create({ @@ -210,6 +213,9 @@ function createEditProfileStyles(colors: AppColors) { shadowOpacity: 0.05, shadowRadius: 8, elevation: 2, + maxWidth: CONTENT_MAX_WIDTH, + alignSelf: 'center', + width: '100%', }, sectionTitle: { marginBottom: spacing.lg, @@ -357,6 +363,7 @@ export const EditProfileScreen: React.FC = () => { const navigation = useNavigation(); const { currentUser, updateUser } = useAuthStore(); const { isWideScreen, isMobile, width } = useResponsive(); + const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 }); const insets = useSafeAreaInsets(); const [nickname, setNickname] = useState(currentUser?.nickname || ''); @@ -793,23 +800,12 @@ export const EditProfileScreen: React.FC = () => { behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.keyboardView} > - {isWideScreen ? ( - - - {renderFormContent()} - - - ) : ( - - {renderFormContent()} - - )} + + {renderFormContent()} + ); diff --git a/src/screens/profile/NotificationSettingsScreen.tsx b/src/screens/profile/NotificationSettingsScreen.tsx index 5128679..157d503 100644 --- a/src/screens/profile/NotificationSettingsScreen.tsx +++ b/src/screens/profile/NotificationSettingsScreen.tsx @@ -14,14 +14,17 @@ import { import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import { MaterialCommunityIcons } from '@expo/vector-icons'; import { spacing, borderRadius, useAppColors, type AppColors } from '../../theme'; -import { Text, ResponsiveContainer } from '../../components/common'; +import { Text } from '../../components/common'; import { loadNotificationPreferences, setPushNotificationsPreference, setSoundPreference, setVibrationPreference, } from '../../services/notificationPreferences'; -import { useResponsive } from '../../hooks'; +import { useResponsive, useResponsiveSpacing } from '../../hooks'; + +// 内容最大宽度 +const CONTENT_MAX_WIDTH = 720; interface NotificationSettingItem { key: string; @@ -168,19 +171,13 @@ export const NotificationSettingsScreen: React.FC = () => { ); + const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 }); + return ( - {isWideScreen ? ( - - - {renderContent()} - - - ) : ( - - {renderContent()} - - )} + + {renderContent()} + ); }; @@ -212,6 +209,9 @@ function createNotificationSettingsStyles(colors: AppColors) { marginHorizontal: spacing.lg, borderRadius: borderRadius.lg, overflow: 'hidden', + maxWidth: CONTENT_MAX_WIDTH, + alignSelf: 'center', + width: '100%', }, settingItem: { flexDirection: 'row', diff --git a/src/screens/profile/SettingsScreen.tsx b/src/screens/profile/SettingsScreen.tsx index 0ba6413..749505f 100644 --- a/src/screens/profile/SettingsScreen.tsx +++ b/src/screens/profile/SettingsScreen.tsx @@ -27,10 +27,13 @@ import { type AppColors, } from '../../theme'; import { useAuthStore } from '../../stores'; -import { Text, ResponsiveContainer } from '../../components/common'; -import { useResponsive } from '../../hooks'; +import { Text } from '../../components/common'; +import { useResponsive, useResponsiveSpacing } from '../../hooks'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +// 设置卡片最大宽度 +const SETTINGS_CARD_MAX_WIDTH = 720; + import * as hrefs from '../../navigation/hrefs'; interface SettingsItem { @@ -84,6 +87,9 @@ function createSettingsStyles(colors: AppColors) { }, groupContainer: { marginBottom: spacing.lg, + maxWidth: SETTINGS_CARD_MAX_WIDTH, + alignSelf: 'center', + width: '100%', }, groupHeader: { flexDirection: 'row', @@ -154,6 +160,9 @@ function createSettingsStyles(colors: AppColors) { paddingVertical: spacing.md, borderRadius: borderRadius.lg, gap: spacing.sm, + maxWidth: SETTINGS_CARD_MAX_WIDTH, + alignSelf: 'center', + width: '100%', }, logoutText: { fontWeight: '500', @@ -172,9 +181,9 @@ function createSettingsStyles(colors: AppColors) { export const SettingsScreen: React.FC = () => { const router = useRouter(); const { logout } = useAuthStore(); - const { isWideScreen } = useResponsive(); const insets = useSafeAreaInsets(); const { isMobile } = useResponsive(); + const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 }); const colors = useAppColors(); const styles = useMemo(() => createSettingsStyles(colors), [colors]); const themePreference = useThemePreference(); @@ -355,17 +364,9 @@ export const SettingsScreen: React.FC = () => { return ( - {isWideScreen ? ( - - - {renderContent()} - - - ) : ( - - {renderContent()} - - )} + + {renderContent()} + ); }; diff --git a/src/services/messageService.ts b/src/services/messageService.ts index 48d165f..78c864b 100644 --- a/src/services/messageService.ts +++ b/src/services/messageService.ts @@ -332,8 +332,22 @@ class MessageService { page: data.page || 1, page_size: data.page_size || 20, }; - } catch (error) { - console.error('获取消息列表失败:', error); + } catch (error: any) { + // 检查是否是权限错误或会话不存在错误 - 这是正常情况,不需要打印错误日志 + const errorMessage = error?.message || String(error); + if ( + errorMessage.includes('not found') || + errorMessage.includes('no permission') || + error?.code === 'NOT_FOUND' || + error?.code === 'FORBIDDEN' + ) { + // 会话不存在或无权限访问,返回空消息列表 + // 这可能是因为会话已被删除、用户被踢出群聊等情况 + console.warn('[MessageService] 会话不存在或无权限访问:', conversationId); + } else { + // 其他错误才打印错误日志 + console.error('[MessageService] 获取消息列表失败:', error); + } return { messages: [], total: 0, diff --git a/src/services/webrtc/WebRTCManager.web.ts b/src/services/webrtc/WebRTCManager.web.ts new file mode 100644 index 0000000..7c372fb --- /dev/null +++ b/src/services/webrtc/WebRTCManager.web.ts @@ -0,0 +1,89 @@ +// WebRTCManager for Web platform (stub implementation) +import type { + ICEServer, + ConnectionState, + WebRTCManagerEvent, +} from './WebRTCManager'; + +type EventHandler = (event: WebRTCManagerEvent) => void; + +class WebRTCManagerWeb { + private eventHandlers: Set = new Set(); + private disposed = false; + + async initialize(iceServers: ICEServer[] = []): Promise { + console.log('[WebRTC] WebRTC not supported on web platform'); + } + + async createLocalStream(voiceOnly = true): Promise { + console.warn('[WebRTC] WebRTC not supported on web platform'); + return null; + } + + async startCall(isInitiator: boolean): Promise { + console.warn('[WebRTC] WebRTC not supported on web platform'); + return null; + } + + async createOffer(): Promise { + console.warn('[WebRTC] WebRTC not supported on web platform'); + return null; + } + + async createAnswer(): Promise { + console.warn('[WebRTC] WebRTC not supported on web platform'); + return null; + } + + async createAnswerFromOffer(offer: RTCSessionDescriptionInit): Promise { + console.warn('[WebRTC] WebRTC not supported on web platform'); + return null; + } + + async setRemoteDescription(description: RTCSessionDescriptionInit): Promise { + console.warn('[WebRTC] WebRTC not supported on web platform'); + } + + async addIceCandidate(candidate: RTCIceCandidateInit): Promise { + console.warn('[WebRTC] WebRTC not supported on web platform'); + } + + setMuted(muted: boolean): void { + console.warn('[WebRTC] WebRTC not supported on web platform'); + } + + isMuted(): boolean { + console.warn('[WebRTC] WebRTC not supported on web platform'); + return false; + } + + getRemoteStream(): MediaStream | null { + console.warn('[WebRTC] WebRTC not supported on web platform'); + return null; + } + + getLocalStream(): MediaStream | null { + console.warn('[WebRTC] WebRTC not supported on web platform'); + return null; + } + + getPeerConnection(): RTCPeerConnection | null { + console.warn('[WebRTC] WebRTC not supported on web platform'); + return null; + } + + onEvent(handler: EventHandler): () => void { + console.warn('[WebRTC] WebRTC not supported on web platform'); + return () => { + this.eventHandlers.delete(handler); + }; + } + + dispose(): void { + this.disposed = true; + this.eventHandlers.clear(); + console.log('[WebRTC] WebRTC disposed on web platform'); + } +} + +export const webrtcManager = new WebRTCManagerWeb(); \ No newline at end of file