双端适配,web端的修改父容器和接口
This commit is contained in:
@@ -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',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||
// Web 端不支持扫码组件
|
||||
const QRCodeScannerWeb: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||
const themeColors = useAppColors();
|
||||
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||
<MaterialCommunityIcons name="close" size={24} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.headerTitle}>扫码登录</Text>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
<View style={styles.webNotSupportedContainer}>
|
||||
<MaterialCommunityIcons name="web-off" size={64} color="rgba(255,255,255,0.5)" />
|
||||
<Text style={styles.webNotSupportedText}>
|
||||
扫码登录功能暂不支持网页端使用{'\n'}
|
||||
请下载手机 App 体验完整功能
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.permissionButton} onPress={onClose}>
|
||||
<Text style={styles.permissionButtonText}>知道了</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// 原生端扫码组件
|
||||
const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||
const router = useRouter();
|
||||
const themeColors = useAppColors();
|
||||
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
|
||||
// 动态导入 expo-camera,避免 Web 端加载
|
||||
const [cameraModule, setCameraModule] = useState<typeof import('expo-camera') | null>(null);
|
||||
const [permission, setPermission] = useState<any>(null);
|
||||
const [scanned, setScanned] = useState(false);
|
||||
|
||||
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<QRCodeScannerProps> = ({ visible, onClose }
|
||||
}
|
||||
};
|
||||
|
||||
if (!permission?.granted) {
|
||||
if (!cameraModule || !permission?.granted) {
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||
<View style={styles.container}>
|
||||
@@ -192,7 +244,10 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
||||
<View style={styles.permissionContainer}>
|
||||
<MaterialCommunityIcons name="camera-off" size={64} color="rgba(255,255,255,0.5)" />
|
||||
<Text style={styles.permissionText}>需要相机权限才能扫码</Text>
|
||||
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
|
||||
<TouchableOpacity
|
||||
style={styles.permissionButton}
|
||||
onPress={() => cameraModule?.useCameraPermissions()[1]()}
|
||||
>
|
||||
<Text style={styles.permissionButtonText}>授予权限</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -201,6 +256,8 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
||||
);
|
||||
}
|
||||
|
||||
const { CameraView } = cameraModule;
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||
<View style={styles.container}>
|
||||
@@ -243,4 +300,9 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
||||
);
|
||||
};
|
||||
|
||||
// 根据平台导出不同组件
|
||||
export const QRCodeScanner: React.FC<QRCodeScannerProps> = Platform.OS === 'web'
|
||||
? QRCodeScannerWeb
|
||||
: QRCodeScannerNative;
|
||||
|
||||
export default QRCodeScanner;
|
||||
|
||||
160
src/components/call/CallScreen.web.tsx
Normal file
160
src/components/call/CallScreen.web.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { callStore } from '../../stores/callStore';
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const CallScreen: React.FC = () => {
|
||||
const currentCall = callStore((s) => s.currentCall);
|
||||
const callDuration = callStore((s) => s.callDuration);
|
||||
const endCall = callStore((s) => s.endCall);
|
||||
const isMinimized = callStore((s) => s.isMinimized);
|
||||
|
||||
// Don't render full screen when minimized or no call
|
||||
if (!currentCall || isMinimized) return null;
|
||||
|
||||
const getStatusText = (): string => {
|
||||
switch (currentCall.status) {
|
||||
case 'ringing':
|
||||
return '通话功能暂不支持网页端';
|
||||
case 'connecting':
|
||||
return '通话功能暂不支持网页端';
|
||||
case 'connected':
|
||||
return '通话功能暂不支持网页端';
|
||||
case 'ending':
|
||||
return '通话已结束';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndCall = () => {
|
||||
endCall('hangup');
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
||||
|
||||
{/* Background - single consistent color */}
|
||||
<View style={styles.background} />
|
||||
|
||||
{/* Center: Peer info */}
|
||||
<View style={styles.centerArea}>
|
||||
<View style={styles.avatarOuter}>
|
||||
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
||||
<MaterialCommunityIcons name="web-off" size={50} color="#fff" />
|
||||
</View>
|
||||
</View>
|
||||
<Text style={styles.peerName} numberOfLines={1}>
|
||||
{currentCall.peerName || '未知用户'}
|
||||
</Text>
|
||||
<Text style={styles.status}>{getStatusText()}</Text>
|
||||
</View>
|
||||
|
||||
{/* Bottom controls */}
|
||||
<View style={styles.controls}>
|
||||
{/* End call - prominent red button */}
|
||||
<TouchableOpacity
|
||||
style={styles.endCallButton}
|
||||
onPress={handleEndCall}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.endCallCircle}>
|
||||
<MaterialCommunityIcons name="phone-hangup" size={32} color="#fff" />
|
||||
</View>
|
||||
<Text style={styles.endCallLabel}>关闭</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const END_CALL_SIZE = 64;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: '#1A1A2E',
|
||||
zIndex: 9999,
|
||||
},
|
||||
background: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: '#1A1A2E',
|
||||
},
|
||||
centerArea: {
|
||||
position: 'absolute',
|
||||
top: '28%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 30,
|
||||
},
|
||||
avatarOuter: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
avatar: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: '#3A3A5C',
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 40,
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
peerName: {
|
||||
fontSize: 22,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
marginBottom: 6,
|
||||
textAlign: 'center',
|
||||
maxWidth: 280,
|
||||
},
|
||||
status: {
|
||||
fontSize: 14,
|
||||
color: 'rgba(255, 255, 255, 0.5)',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
controls: {
|
||||
position: 'absolute',
|
||||
bottom: 60,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
},
|
||||
endCallButton: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
endCallCircle: {
|
||||
width: END_CALL_SIZE,
|
||||
height: END_CALL_SIZE,
|
||||
borderRadius: END_CALL_SIZE / 2,
|
||||
backgroundColor: '#E54D42',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
endCallLabel: {
|
||||
fontSize: 11,
|
||||
color: 'rgba(255, 255, 255, 0.55)',
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default CallScreen;
|
||||
@@ -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 = () => {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={720}>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{renderTiles()}
|
||||
</ScrollView>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{renderTiles()}
|
||||
</ScrollView>
|
||||
)}
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{renderTiles()}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 = () => {
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={800}>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{renderContent()}
|
||||
</ScrollView>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{renderContent()}
|
||||
</ScrollView>
|
||||
)}
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={handleRefresh}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{renderContent()}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 = () => {
|
||||
</View>
|
||||
|
||||
{/* Content */}
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={800}>
|
||||
<View style={[styles.contentWrapper, { paddingBottom: scrollBottomInset }]}>
|
||||
{renderContent()}
|
||||
</View>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<View style={[styles.contentWrapper, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
|
||||
{renderContent()}
|
||||
</View>
|
||||
)}
|
||||
<View style={[styles.contentWrapper, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
|
||||
{renderContent()}
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<EmojiPanelProps> = ({
|
||||
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<EmojiPanelProps> = ({
|
||||
}
|
||||
}, [activeTab, loadStickers]);
|
||||
|
||||
const renderEmojiItem: ListRenderItem<string> = useCallback(({ item }) => (
|
||||
<TouchableOpacity
|
||||
style={styles.emojiItem}
|
||||
onPress={() => onInsertEmoji(item)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.emojiText}>{item}</Text>
|
||||
</TouchableOpacity>
|
||||
), [styles.emojiItem, styles.emojiText, onInsertEmoji]);
|
||||
|
||||
// 渲染 Emoji 面板(使用 FlatList 虚拟化,降低首次打开卡顿)
|
||||
|
||||
// 渲染 Emoji 面板(使用 flex 布局,每个表情固定 40px 宽度)
|
||||
const renderEmojiPanel = () => (
|
||||
<FlatList
|
||||
data={EMOJIS}
|
||||
key={emojiColumns}
|
||||
numColumns={emojiColumns}
|
||||
keyExtractor={(item, index) => `${item}-${index}`}
|
||||
renderItem={renderEmojiItem}
|
||||
contentContainerStyle={styles.emojiGrid}
|
||||
showsVerticalScrollIndicator={false}
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.emojiContainer}
|
||||
showsVerticalScrollIndicator={true}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
initialNumToRender={emojiColumns * 4}
|
||||
maxToRenderPerBatch={emojiColumns * 4}
|
||||
windowSize={7}
|
||||
removeClippedSubviews={true}
|
||||
getItemLayout={(_, index) => ({
|
||||
length: emojiSize.itemHeight,
|
||||
offset: emojiSize.itemHeight * Math.floor(index / emojiColumns),
|
||||
index,
|
||||
})}
|
||||
/>
|
||||
>
|
||||
{EMOJIS.map((emoji, index) => (
|
||||
<TouchableOpacity
|
||||
key={`${emoji}-${index}`}
|
||||
style={styles.emojiItem}
|
||||
onPress={() => onInsertEmoji(emoji)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.emojiText}>{emoji}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
// 添加表情(从相册选择)
|
||||
|
||||
602
src/screens/message/components/ChatScreen/GroupInfoPanel.web.tsx
Normal file
602
src/screens/message/components/ChatScreen/GroupInfoPanel.web.tsx
Normal file
@@ -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<GroupInfoPanelProps> = ({
|
||||
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<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,
|
||||
// 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 && (
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -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<EmbeddedChatProps> = ({ conversation, onBack
|
||||
// 回复状态
|
||||
const [replyingToMessage, setReplyingToMessage] = useState<GroupMessage | null>(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<EmbeddedChatProps> = ({ conversation, onBack
|
||||
// 群成员列表
|
||||
const [groupMembers, setGroupMembers] = useState<GroupMemberResponse[]>([]);
|
||||
|
||||
// 格式化时间
|
||||
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<EmbeddedChatProps> = ({ 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<EmbeddedChatProps> = ({ 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 (
|
||||
<View>
|
||||
{showTime && (
|
||||
<View style={styles.timeContainer}>
|
||||
<Text style={styles.timeText}>
|
||||
{formatTime(item.created_at)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.systemNoticeContainer}>
|
||||
<Text style={styles.systemNoticeText}>
|
||||
{(item as any).notice_content || extractTextFromSegments(item.segments)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 处理指针事件(鼠标右键)- 使用 onPointerDown 检测鼠标右键
|
||||
const handlePointerDown = (e: any) => {
|
||||
// e.nativeEvent.button: 0=左键, 1=中键, 2=右键
|
||||
@@ -572,33 +778,55 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
||||
handleMessageRightPress(item, e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[styles.messageRow, isMe ? styles.messageRowRight : styles.messageRowLeft]}
|
||||
// @ts-ignore - React Native for Web 支持 pointer events
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
{!isMe && (
|
||||
<Avatar
|
||||
source={item.sender?.avatar}
|
||||
size={36}
|
||||
name={item.sender?.nickname || item.sender?.username}
|
||||
/>
|
||||
<View>
|
||||
{/* 时间分隔 */}
|
||||
{showTime && (
|
||||
<View style={styles.timeContainer}>
|
||||
<Text style={styles.timeText}>
|
||||
{formatTime(item.created_at)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={[styles.messageBubble, isMe ? styles.messageBubbleMe : styles.messageBubbleOther]}>
|
||||
{isGroupChat && !isMe && (
|
||||
<Text style={styles.senderName}>{item.sender?.nickname || item.sender?.username}</Text>
|
||||
<View
|
||||
style={[styles.messageRow, isMe ? styles.messageRowRight : styles.messageRowLeft]}
|
||||
// @ts-ignore - React Native for Web 支持 pointer events
|
||||
onPointerDown={handlePointerDown}
|
||||
>
|
||||
{/* 对方头像(左侧) */}
|
||||
{!isMe && (
|
||||
<View style={styles.avatarWrapper}>
|
||||
<Avatar
|
||||
source={item.sender?.avatar}
|
||||
size={36}
|
||||
name={item.sender?.nickname || item.sender?.username}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 消息内容区域 */}
|
||||
<View style={styles.messageContent}>
|
||||
{/* 群聊模式:显示发送者昵称(在气泡上方,与头像顶部对齐) */}
|
||||
{isGroupChat && !isMe && (
|
||||
<Text style={styles.senderName}>{item.sender?.nickname || item.sender?.username}</Text>
|
||||
)}
|
||||
<View style={[styles.messageBubble, isMe ? styles.messageBubbleMe : styles.messageBubbleOther]}>
|
||||
{renderMessageContent(item)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 自己的头像(右侧) */}
|
||||
{isMe && (
|
||||
<View style={styles.avatarWrapper}>
|
||||
<Avatar
|
||||
source={currentUser?.avatar}
|
||||
size={36}
|
||||
name={currentUser?.nickname || currentUser?.username}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{renderMessageContent(item)}
|
||||
</View>
|
||||
{isMe && (
|
||||
<Avatar
|
||||
source={currentUser?.avatar}
|
||||
size={36}
|
||||
name={currentUser?.nickname || currentUser?.username}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
@@ -669,8 +897,12 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
||||
>
|
||||
<View style={styles.inputArea}>
|
||||
<View style={styles.inputRow}>
|
||||
<TouchableOpacity style={styles.iconButton}>
|
||||
<MaterialCommunityIcons name="plus-circle-outline" size={28} color={colors.text.secondary} />
|
||||
<TouchableOpacity style={styles.iconButton} onPress={toggleMorePanel}>
|
||||
<MaterialCommunityIcons
|
||||
name={showMorePanel ? "close-circle-outline" : "plus-circle-outline"}
|
||||
size={28}
|
||||
color={showMorePanel ? colors.primary.main : colors.text.secondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
@@ -691,8 +923,12 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.iconButton}>
|
||||
<MaterialCommunityIcons name="emoticon-outline" size={28} color={colors.text.secondary} />
|
||||
<TouchableOpacity style={styles.iconButton} onPress={toggleEmojiPanel}>
|
||||
<MaterialCommunityIcons
|
||||
name={showEmojiPanel ? "emoticon-happy" : "emoticon-outline"}
|
||||
size={28}
|
||||
color={showEmojiPanel ? colors.primary.main : colors.text.secondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
@@ -708,6 +944,26 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 表情面板 */}
|
||||
{showEmojiPanel && (
|
||||
<View style={{ height: 350, backgroundColor: colors.background.paper }}>
|
||||
<EmojiPanel
|
||||
onInsertEmoji={handleInsertEmoji}
|
||||
onInsertSticker={handleSendSticker}
|
||||
onClose={closePanels}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 更多功能面板 */}
|
||||
{showMorePanel && (
|
||||
<View style={{ height: 350, backgroundColor: colors.background.paper }}>
|
||||
<MorePanel
|
||||
onAction={handleMoreAction}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
|
||||
{/* 群信息侧边栏面板 - 仅大屏幕显示 */}
|
||||
|
||||
@@ -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 (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={800}>
|
||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>{content}</ScrollView>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>{content}</ScrollView>
|
||||
)}
|
||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
|
||||
{content}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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 ? (
|
||||
<ResponsiveContainer maxWidth={800}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{renderFormContent()}
|
||||
</ScrollView>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{renderFormContent()}
|
||||
</ScrollView>
|
||||
)}
|
||||
<ScrollView
|
||||
contentContainerStyle={[styles.scrollContent, { paddingHorizontal: responsivePadding }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{renderFormContent()}
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={800}>
|
||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
|
||||
{renderContent()}
|
||||
</ScrollView>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
|
||||
{renderContent()}
|
||||
</ScrollView>
|
||||
)}
|
||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
|
||||
{renderContent()}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
|
||||
@@ -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 (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={800}>
|
||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
|
||||
{renderContent()}
|
||||
</ScrollView>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
|
||||
{renderContent()}
|
||||
</ScrollView>
|
||||
)}
|
||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
|
||||
{renderContent()}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
89
src/services/webrtc/WebRTCManager.web.ts
Normal file
89
src/services/webrtc/WebRTCManager.web.ts
Normal file
@@ -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<EventHandler> = new Set();
|
||||
private disposed = false;
|
||||
|
||||
async initialize(iceServers: ICEServer[] = []): Promise<void> {
|
||||
console.log('[WebRTC] WebRTC not supported on web platform');
|
||||
}
|
||||
|
||||
async createLocalStream(voiceOnly = true): Promise<MediaStream | null> {
|
||||
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||
return null;
|
||||
}
|
||||
|
||||
async startCall(isInitiator: boolean): Promise<RTCSessionDescriptionInit | null> {
|
||||
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||
return null;
|
||||
}
|
||||
|
||||
async createOffer(): Promise<RTCSessionDescriptionInit | null> {
|
||||
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||
return null;
|
||||
}
|
||||
|
||||
async createAnswer(): Promise<RTCSessionDescriptionInit | null> {
|
||||
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||
return null;
|
||||
}
|
||||
|
||||
async createAnswerFromOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit | null> {
|
||||
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||
return null;
|
||||
}
|
||||
|
||||
async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
|
||||
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||
}
|
||||
|
||||
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
|
||||
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();
|
||||
Reference in New Issue
Block a user