双端适配,web端的修改父容器和接口
This commit is contained in:
@@ -23,6 +23,29 @@ const devUpdatesUrl =
|
|||||||
|
|
||||||
const expo = appJson.expo;
|
const expo = appJson.expo;
|
||||||
|
|
||||||
|
// 检测是否为 Web 平台
|
||||||
|
const isWeb = process.env.EXPO_TARGET === 'web' || process.env.PLATFORM === 'web';
|
||||||
|
|
||||||
|
// 原生平台特有的插件(在 Web 端会报错)
|
||||||
|
const nativeOnlyPlugins = [
|
||||||
|
'expo-camera',
|
||||||
|
'expo-notifications',
|
||||||
|
'expo-background-fetch',
|
||||||
|
'expo-media-library',
|
||||||
|
'expo-image-picker',
|
||||||
|
'expo-video',
|
||||||
|
'expo-sqlite',
|
||||||
|
'./plugins/withMainActivityConfigChange',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 过滤插件:Web 端排除原生插件
|
||||||
|
const filteredPlugins = isWeb
|
||||||
|
? expo.plugins.filter((plugin) => {
|
||||||
|
const pluginName = Array.isArray(plugin) ? plugin[0] : plugin;
|
||||||
|
return !nativeOnlyPlugins.includes(pluginName);
|
||||||
|
})
|
||||||
|
: expo.plugins;
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
...expo,
|
...expo,
|
||||||
name: isDevVariant ? `${expo.name} Dev` : expo.name,
|
name: isDevVariant ? `${expo.name} Dev` : expo.name,
|
||||||
@@ -40,10 +63,27 @@ module.exports = {
|
|||||||
...expo.android,
|
...expo.android,
|
||||||
package: 'skin.carrot.bbs',
|
package: 'skin.carrot.bbs',
|
||||||
},
|
},
|
||||||
|
// Web 端使用过滤后的插件
|
||||||
|
plugins: filteredPlugins,
|
||||||
extra: {
|
extra: {
|
||||||
...(expo.extra || {}),
|
...(expo.extra || {}),
|
||||||
appVariant: isDevVariant ? 'dev' : 'release',
|
appVariant: isDevVariant ? 'dev' : 'release',
|
||||||
apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl,
|
apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl,
|
||||||
updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
|
updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
|
||||||
},
|
},
|
||||||
|
// Web 端配置
|
||||||
|
...(isWeb && {
|
||||||
|
web: {
|
||||||
|
...expo.web,
|
||||||
|
build: {
|
||||||
|
...expo.web?.build,
|
||||||
|
// 配置 Web 端别名,避免加载原生模块
|
||||||
|
babel: {
|
||||||
|
dangerouslyAddModulePathsToTranspile: [
|
||||||
|
'react-native-webrtc',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "carrot_bbs",
|
"name": "carrot_bbs",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "carrot_bbs",
|
"name": "carrot_bbs",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.1.1",
|
"@expo/vector-icons": "^15.1.1",
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Modal,
|
Modal,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
@@ -133,22 +133,74 @@ function createQrScannerStyles(colors: AppColors) {
|
|||||||
fontSize: fontSizes.md,
|
fontSize: fontSizes.md,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
|
webNotSupportedContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: spacing.xl,
|
||||||
|
backgroundColor: '#000',
|
||||||
|
},
|
||||||
|
webNotSupportedText: {
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
color: 'rgba(255,255,255,0.72)',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
// Web 端不支持扫码组件
|
||||||
|
const QRCodeScannerWeb: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||||
|
const themeColors = useAppColors();
|
||||||
|
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||||
|
<MaterialCommunityIcons name="close" size={24} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>扫码登录</Text>
|
||||||
|
<View style={styles.placeholder} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.webNotSupportedContainer}>
|
||||||
|
<MaterialCommunityIcons name="web-off" size={64} color="rgba(255,255,255,0.5)" />
|
||||||
|
<Text style={styles.webNotSupportedText}>
|
||||||
|
扫码登录功能暂不支持网页端使用{'\n'}
|
||||||
|
请下载手机 App 体验完整功能
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity style={styles.permissionButton} onPress={onClose}>
|
||||||
|
<Text style={styles.permissionButtonText}>知道了</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 原生端扫码组件
|
||||||
|
const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const themeColors = useAppColors();
|
const themeColors = useAppColors();
|
||||||
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
||||||
const [permission, requestPermission] = useCameraPermissions();
|
|
||||||
|
// 动态导入 expo-camera,避免 Web 端加载
|
||||||
|
const [cameraModule, setCameraModule] = useState<typeof import('expo-camera') | null>(null);
|
||||||
|
const [permission, setPermission] = useState<any>(null);
|
||||||
const [scanned, setScanned] = useState(false);
|
const [scanned, setScanned] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible && Platform.OS !== 'web') {
|
||||||
setScanned(false);
|
import('expo-camera').then((module) => {
|
||||||
if (!permission?.granted) {
|
setCameraModule(module);
|
||||||
requestPermission();
|
const [perm, requestPerm] = module.useCameraPermissions();
|
||||||
}
|
setPermission(perm);
|
||||||
|
if (!perm?.granted) {
|
||||||
|
requestPerm();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
@@ -178,7 +230,7 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!permission?.granted) {
|
if (!cameraModule || !permission?.granted) {
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -192,7 +244,10 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
|||||||
<View style={styles.permissionContainer}>
|
<View style={styles.permissionContainer}>
|
||||||
<MaterialCommunityIcons name="camera-off" size={64} color="rgba(255,255,255,0.5)" />
|
<MaterialCommunityIcons name="camera-off" size={64} color="rgba(255,255,255,0.5)" />
|
||||||
<Text style={styles.permissionText}>需要相机权限才能扫码</Text>
|
<Text style={styles.permissionText}>需要相机权限才能扫码</Text>
|
||||||
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
|
<TouchableOpacity
|
||||||
|
style={styles.permissionButton}
|
||||||
|
onPress={() => cameraModule?.useCameraPermissions()[1]()}
|
||||||
|
>
|
||||||
<Text style={styles.permissionButtonText}>授予权限</Text>
|
<Text style={styles.permissionButtonText}>授予权限</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -201,6 +256,8 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { CameraView } = cameraModule;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -243,4 +300,9 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 根据平台导出不同组件
|
||||||
|
export const QRCodeScanner: React.FC<QRCodeScannerProps> = Platform.OS === 'web'
|
||||||
|
? QRCodeScannerWeb
|
||||||
|
: QRCodeScannerNative;
|
||||||
|
|
||||||
export default QRCodeScanner;
|
export default QRCodeScanner;
|
||||||
|
|||||||
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,
|
type AppColors,
|
||||||
} from '../../theme';
|
} from '../../theme';
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
import { Text, ResponsiveContainer } from '../../components/common';
|
import { Text } from '../../components/common';
|
||||||
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
|
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
|
||||||
|
|
||||||
type AppItem = {
|
type AppItem = {
|
||||||
@@ -46,6 +46,9 @@ const APP_ENTRIES: AppItem[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 应用卡片最大宽度设置
|
||||||
|
const APP_CARD_MAX_WIDTH = 720;
|
||||||
|
|
||||||
export const AppsScreen: React.FC = () => {
|
export const AppsScreen: React.FC = () => {
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const resolvedScheme = useResolvedColorScheme();
|
const resolvedScheme = useResolvedColorScheme();
|
||||||
@@ -63,6 +66,9 @@ export const AppsScreen: React.FC = () => {
|
|||||||
shadowOpacity: 0.06,
|
shadowOpacity: 0.06,
|
||||||
shadowRadius: 8,
|
shadowRadius: 8,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
|
maxWidth: APP_CARD_MAX_WIDTH,
|
||||||
|
alignSelf: 'center' as const,
|
||||||
|
width: '100%' as const,
|
||||||
}),
|
}),
|
||||||
[colors]
|
[colors]
|
||||||
);
|
);
|
||||||
@@ -136,28 +142,16 @@ export const AppsScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isWideScreen ? (
|
<ScrollView
|
||||||
<ResponsiveContainer maxWidth={720}>
|
style={styles.scroll}
|
||||||
<ScrollView
|
contentContainerStyle={[
|
||||||
style={styles.scroll}
|
styles.scrollContent,
|
||||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}
|
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
|
||||||
showsVerticalScrollIndicator={false}
|
]}
|
||||||
>
|
showsVerticalScrollIndicator={false}
|
||||||
{renderTiles()}
|
>
|
||||||
</ScrollView>
|
{renderTiles()}
|
||||||
</ResponsiveContainer>
|
</ScrollView>
|
||||||
) : (
|
|
||||||
<ScrollView
|
|
||||||
style={styles.scroll}
|
|
||||||
contentContainerStyle={[
|
|
||||||
styles.scrollContent,
|
|
||||||
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
|
|
||||||
]}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{renderTiles()}
|
|
||||||
</ScrollView>
|
|
||||||
)}
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,8 +24,11 @@ import {
|
|||||||
type AppColors,
|
type AppColors,
|
||||||
} from '../../theme';
|
} from '../../theme';
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
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';
|
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
|
||||||
|
|
||||||
|
// 学科卡片最大宽度设置
|
||||||
|
const SUBJECT_CARD_MAX_WIDTH = 720;
|
||||||
import type { MaterialSubject } from '../../types/material';
|
import type { MaterialSubject } from '../../types/material';
|
||||||
import { materialRepository } from '../../data/repositories/MaterialRepository';
|
import { materialRepository } from '../../data/repositories/MaterialRepository';
|
||||||
|
|
||||||
@@ -87,6 +90,9 @@ export const MaterialsScreen: React.FC = () => {
|
|||||||
shadowOpacity: 0.06,
|
shadowOpacity: 0.06,
|
||||||
shadowRadius: 8,
|
shadowRadius: 8,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
|
maxWidth: SUBJECT_CARD_MAX_WIDTH,
|
||||||
|
alignSelf: 'center' as const,
|
||||||
|
width: '100%' as const,
|
||||||
}),
|
}),
|
||||||
[colors]
|
[colors]
|
||||||
);
|
);
|
||||||
@@ -177,42 +183,23 @@ export const MaterialsScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
{isWideScreen ? (
|
<ScrollView
|
||||||
<ResponsiveContainer maxWidth={800}>
|
style={styles.scroll}
|
||||||
<ScrollView
|
contentContainerStyle={[
|
||||||
style={styles.scroll}
|
styles.scrollContent,
|
||||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}
|
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
|
||||||
showsVerticalScrollIndicator={false}
|
]}
|
||||||
refreshControl={
|
showsVerticalScrollIndicator={false}
|
||||||
<RefreshControl
|
refreshControl={
|
||||||
refreshing={isRefreshing}
|
<RefreshControl
|
||||||
onRefresh={handleRefresh}
|
refreshing={isRefreshing}
|
||||||
tintColor={colors.primary.main}
|
onRefresh={handleRefresh}
|
||||||
/>
|
tintColor={colors.primary.main}
|
||||||
}
|
/>
|
||||||
>
|
}
|
||||||
{renderContent()}
|
>
|
||||||
</ScrollView>
|
{renderContent()}
|
||||||
</ResponsiveContainer>
|
</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>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,8 +25,11 @@ import {
|
|||||||
type AppColors,
|
type AppColors,
|
||||||
} from '../../theme';
|
} from '../../theme';
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
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';
|
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
|
||||||
|
|
||||||
|
// 资料卡片最大宽度
|
||||||
|
const MATERIAL_CARD_MAX_WIDTH = 720;
|
||||||
import type { MaterialSubject, MaterialFile, MaterialFileType } from '../../types/material';
|
import type { MaterialSubject, MaterialFile, MaterialFileType } from '../../types/material';
|
||||||
import {
|
import {
|
||||||
materialRepository,
|
materialRepository,
|
||||||
@@ -120,6 +123,9 @@ export const SubjectMaterialsScreen: React.FC = () => {
|
|||||||
shadowOpacity: 0.05,
|
shadowOpacity: 0.05,
|
||||||
shadowRadius: 4,
|
shadowRadius: 4,
|
||||||
elevation: 1,
|
elevation: 1,
|
||||||
|
maxWidth: MATERIAL_CARD_MAX_WIDTH,
|
||||||
|
alignSelf: 'center' as const,
|
||||||
|
width: '100%' as const,
|
||||||
}),
|
}),
|
||||||
[colors]
|
[colors]
|
||||||
);
|
);
|
||||||
@@ -266,17 +272,9 @@ export const SubjectMaterialsScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
{isWideScreen ? (
|
<View style={[styles.contentWrapper, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
|
||||||
<ResponsiveContainer maxWidth={800}>
|
{renderContent()}
|
||||||
<View style={[styles.contentWrapper, { paddingBottom: scrollBottomInset }]}>
|
</View>
|
||||||
{renderContent()}
|
|
||||||
</View>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<View style={[styles.contentWrapper, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
|
|
||||||
{renderContent()}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
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 { Image as ExpoImage } from 'expo-image';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
@@ -18,11 +18,11 @@ import { useAppColors, spacing } from '../../../../theme';
|
|||||||
|
|
||||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
|
||||||
// 表情尺寸配置
|
// 表情尺寸配置 - 固定宽度 40px,使用 flex 布局
|
||||||
const EMOJI_SIZES = {
|
const EMOJI_SIZES = {
|
||||||
mobile: { size: 28, itemHeight: 52 },
|
mobile: { size: 24, itemWidth: 40, itemHeight: 40 },
|
||||||
tablet: { size: 32, itemHeight: 60 },
|
tablet: { size: 26, itemWidth: 40, itemHeight: 40 },
|
||||||
desktop: { size: 36, itemHeight: 68 },
|
desktop: { size: 28, itemWidth: 40, itemHeight: 40 },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tab 类型
|
// Tab 类型
|
||||||
@@ -62,29 +62,30 @@ export const EmojiPanel: React.FC<EmojiPanelProps> = ({
|
|||||||
return EMOJI_SIZES.mobile;
|
return EMOJI_SIZES.mobile;
|
||||||
}, [isWideScreen, isTablet]);
|
}, [isWideScreen, isTablet]);
|
||||||
|
|
||||||
// 表情网格列数
|
|
||||||
const emojiColumns = useMemo(() => {
|
|
||||||
if (isWideScreen) return 10;
|
|
||||||
if (isTablet) return 9;
|
|
||||||
return 8;
|
|
||||||
}, [isWideScreen, isTablet]);
|
|
||||||
|
|
||||||
// 合并样式
|
// 合并样式 - 使用 flex 布局,每个表情固定 40px 宽度
|
||||||
const styles = useMemo(() => {
|
const styles = useMemo(() => {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
...baseStyles,
|
...baseStyles,
|
||||||
|
emojiContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
padding: spacing.sm,
|
||||||
|
},
|
||||||
emojiItem: {
|
emojiItem: {
|
||||||
...baseStyles.emojiItem,
|
width: 40,
|
||||||
width: `${100 / emojiColumns}%`,
|
height: 40,
|
||||||
height: emojiSize.itemHeight,
|
margin: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
emojiText: {
|
emojiText: {
|
||||||
...baseStyles.emojiText,
|
|
||||||
fontSize: emojiSize.size,
|
fontSize: emojiSize.size,
|
||||||
lineHeight: emojiSize.size + 6,
|
lineHeight: emojiSize.size + 4,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [emojiSize, emojiColumns, baseStyles]);
|
}, [emojiSize, baseStyles]);
|
||||||
|
|
||||||
// 加载自定义表情
|
// 加载自定义表情
|
||||||
const loadStickers = useCallback(async () => {
|
const loadStickers = useCallback(async () => {
|
||||||
@@ -106,37 +107,26 @@ export const EmojiPanel: React.FC<EmojiPanelProps> = ({
|
|||||||
}
|
}
|
||||||
}, [activeTab, loadStickers]);
|
}, [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 = () => (
|
const renderEmojiPanel = () => (
|
||||||
<FlatList
|
<ScrollView
|
||||||
data={EMOJIS}
|
contentContainerStyle={styles.emojiContainer}
|
||||||
key={emojiColumns}
|
showsVerticalScrollIndicator={true}
|
||||||
numColumns={emojiColumns}
|
|
||||||
keyExtractor={(item, index) => `${item}-${index}`}
|
|
||||||
renderItem={renderEmojiItem}
|
|
||||||
contentContainerStyle={styles.emojiGrid}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
initialNumToRender={emojiColumns * 4}
|
>
|
||||||
maxToRenderPerBatch={emojiColumns * 4}
|
{EMOJIS.map((emoji, index) => (
|
||||||
windowSize={7}
|
<TouchableOpacity
|
||||||
removeClippedSubviews={true}
|
key={`${emoji}-${index}`}
|
||||||
getItemLayout={(_, index) => ({
|
style={styles.emojiItem}
|
||||||
length: emojiSize.itemHeight,
|
onPress={() => onInsertEmoji(emoji)}
|
||||||
offset: emojiSize.itemHeight * Math.floor(index / emojiColumns),
|
activeOpacity={0.7}
|
||||||
index,
|
>
|
||||||
})}
|
<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 { ConversationResponse, MessageResponse, MessageSegment, GroupMemberResponse, ImageSegmentData } from '../../../types/dto';
|
||||||
import { Avatar, Text, ImageGallery, AppBackButton } from '../../../components/common';
|
import { Avatar, Text, ImageGallery, AppBackButton } from '../../../components/common';
|
||||||
import { useAuthStore, messageManager, MessageEvent, MessageSubscriber } from '../../../stores';
|
import { useAuthStore, messageManager, MessageEvent, MessageSubscriber } from '../../../stores';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { zhCN } from 'date-fns/locale';
|
||||||
import { extractTextFromSegments } from '../../../types/dto';
|
import { extractTextFromSegments } from '../../../types/dto';
|
||||||
import { useBreakpointGTE } from '../../../hooks/useResponsive';
|
import { useBreakpointGTE } from '../../../hooks/useResponsive';
|
||||||
import { useMarkAsRead } from '../../../stores/messageManagerHooks';
|
import { useMarkAsRead } from '../../../stores/messageManagerHooks';
|
||||||
@@ -30,15 +32,20 @@ import { messageService } from '../../../services';
|
|||||||
import * as hrefs from '../../../navigation/hrefs';
|
import * as hrefs from '../../../navigation/hrefs';
|
||||||
import { GroupInfoPanel } from './ChatScreen/GroupInfoPanel';
|
import { GroupInfoPanel } from './ChatScreen/GroupInfoPanel';
|
||||||
import { LongPressMenu, MenuPosition, GroupMessage } from './ChatScreen';
|
import { LongPressMenu, MenuPosition, GroupMessage } from './ChatScreen';
|
||||||
|
import { EmojiPanel } from './ChatScreen/EmojiPanel';
|
||||||
|
import { MorePanel } from './ChatScreen/MorePanel';
|
||||||
|
|
||||||
const { width: screenWidth } = Dimensions.get('window');
|
const { width: screenWidth } = Dimensions.get('window');
|
||||||
const PANEL_WIDTH = 360;
|
const PANEL_WIDTH = 360;
|
||||||
|
|
||||||
|
// 时间分隔间隔(毫秒)- 5分钟
|
||||||
|
const TIME_SEPARATOR_INTERVAL = 5 * 60 * 1000;
|
||||||
|
|
||||||
function createEmbeddedChatStyles(colors: AppColors) {
|
function createEmbeddedChatStyles(colors: AppColors) {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.chat.screen,
|
backgroundColor: colors.background.default,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -78,7 +85,7 @@ function createEmbeddedChatStyles(colors: AppColors) {
|
|||||||
},
|
},
|
||||||
messageList: {
|
messageList: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.chat.screen,
|
backgroundColor: colors.background.default,
|
||||||
},
|
},
|
||||||
centerContainer: {
|
centerContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -102,7 +109,7 @@ function createEmbeddedChatStyles(colors: AppColors) {
|
|||||||
},
|
},
|
||||||
messageRow: {
|
messageRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-end',
|
alignItems: 'flex-start',
|
||||||
marginBottom: spacing.md,
|
marginBottom: spacing.md,
|
||||||
maxWidth: screenWidth * 0.7,
|
maxWidth: screenWidth * 0.7,
|
||||||
},
|
},
|
||||||
@@ -112,6 +119,15 @@ function createEmbeddedChatStyles(colors: AppColors) {
|
|||||||
messageRowRight: {
|
messageRowRight: {
|
||||||
alignSelf: 'flex-end',
|
alignSelf: 'flex-end',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
avatarWrapper: {
|
||||||
|
marginHorizontal: 2,
|
||||||
|
},
|
||||||
|
messageContent: {
|
||||||
|
maxWidth: screenWidth * 0.5,
|
||||||
|
marginHorizontal: spacing.xs,
|
||||||
|
alignItems: 'flex-start',
|
||||||
},
|
},
|
||||||
messageBubble: {
|
messageBubble: {
|
||||||
maxWidth: screenWidth * 0.5,
|
maxWidth: screenWidth * 0.5,
|
||||||
@@ -122,11 +138,11 @@ function createEmbeddedChatStyles(colors: AppColors) {
|
|||||||
},
|
},
|
||||||
messageBubbleMe: {
|
messageBubbleMe: {
|
||||||
backgroundColor: colors.chat.replyTint,
|
backgroundColor: colors.chat.replyTint,
|
||||||
borderBottomRightRadius: 4,
|
borderTopRightRadius: 4,
|
||||||
},
|
},
|
||||||
messageBubbleOther: {
|
messageBubbleOther: {
|
||||||
backgroundColor: colors.chat.bubbleIncoming,
|
backgroundColor: colors.chat.bubbleIncoming,
|
||||||
borderBottomLeftRadius: 4,
|
borderTopLeftRadius: 4,
|
||||||
shadowColor: colors.chat.shadow,
|
shadowColor: colors.chat.shadow,
|
||||||
shadowOffset: { width: 0, height: 0.5 },
|
shadowOffset: { width: 0, height: 0.5 },
|
||||||
shadowOpacity: 0.04,
|
shadowOpacity: 0.04,
|
||||||
@@ -134,9 +150,11 @@ function createEmbeddedChatStyles(colors: AppColors) {
|
|||||||
elevation: 1,
|
elevation: 1,
|
||||||
},
|
},
|
||||||
senderName: {
|
senderName: {
|
||||||
fontSize: 12,
|
fontSize: 14,
|
||||||
color: colors.text.secondary,
|
color: colors.text.secondary,
|
||||||
marginBottom: 2,
|
marginBottom: 4,
|
||||||
|
marginLeft: 4,
|
||||||
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
messageText: {
|
messageText: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
@@ -149,7 +167,7 @@ function createEmbeddedChatStyles(colors: AppColors) {
|
|||||||
color: colors.chat.textPrimary,
|
color: colors.chat.textPrimary,
|
||||||
},
|
},
|
||||||
inputArea: {
|
inputArea: {
|
||||||
backgroundColor: colors.chat.screen,
|
backgroundColor: colors.background.paper,
|
||||||
borderTopWidth: StyleSheet.hairlineWidth,
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
borderTopColor: colors.divider,
|
borderTopColor: colors.divider,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
@@ -227,6 +245,43 @@ function createEmbeddedChatStyles(colors: AppColors) {
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
marginTop: 4,
|
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 [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 slideAnim = useRef(new Animated.Value(PANEL_WIDTH)).current;
|
||||||
const fadeAnim = useRef(new Animated.Value(0)).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 [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(() => {
|
const toggleGroupInfoPanel = useCallback(() => {
|
||||||
if (showGroupInfoPanel) {
|
if (showGroupInfoPanel) {
|
||||||
@@ -325,8 +491,21 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
await messageManager.fetchMessages(String(conversation.id));
|
await messageManager.fetchMessages(String(conversation.id));
|
||||||
setMessages(messageManager.getMessages(String(conversation.id)));
|
setMessages(messageManager.getMessages(String(conversation.id)));
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('[EmbeddedChat] 加载消息失败:', error);
|
// 检查是否是权限错误或会话不存在错误
|
||||||
|
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 {
|
} finally {
|
||||||
setLoading(false);
|
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 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 检测鼠标右键
|
// 处理指针事件(鼠标右键)- 使用 onPointerDown 检测鼠标右键
|
||||||
const handlePointerDown = (e: any) => {
|
const handlePointerDown = (e: any) => {
|
||||||
// e.nativeEvent.button: 0=左键, 1=中键, 2=右键
|
// e.nativeEvent.button: 0=左键, 1=中键, 2=右键
|
||||||
@@ -572,33 +778,55 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
|||||||
handleMessageRightPress(item, e);
|
handleMessageRightPress(item, e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View>
|
||||||
style={[styles.messageRow, isMe ? styles.messageRowRight : styles.messageRowLeft]}
|
{/* 时间分隔 */}
|
||||||
// @ts-ignore - React Native for Web 支持 pointer events
|
{showTime && (
|
||||||
onPointerDown={handlePointerDown}
|
<View style={styles.timeContainer}>
|
||||||
>
|
<Text style={styles.timeText}>
|
||||||
{!isMe && (
|
{formatTime(item.created_at)}
|
||||||
<Avatar
|
</Text>
|
||||||
source={item.sender?.avatar}
|
</View>
|
||||||
size={36}
|
|
||||||
name={item.sender?.nickname || item.sender?.username}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<View style={[styles.messageBubble, isMe ? styles.messageBubbleMe : styles.messageBubbleOther]}>
|
<View
|
||||||
{isGroupChat && !isMe && (
|
style={[styles.messageRow, isMe ? styles.messageRowRight : styles.messageRowLeft]}
|
||||||
<Text style={styles.senderName}>{item.sender?.nickname || item.sender?.username}</Text>
|
// @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>
|
</View>
|
||||||
{isMe && (
|
|
||||||
<Avatar
|
|
||||||
source={currentUser?.avatar}
|
|
||||||
size={36}
|
|
||||||
name={currentUser?.nickname || currentUser?.username}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -669,8 +897,12 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
|||||||
>
|
>
|
||||||
<View style={styles.inputArea}>
|
<View style={styles.inputArea}>
|
||||||
<View style={styles.inputRow}>
|
<View style={styles.inputRow}>
|
||||||
<TouchableOpacity style={styles.iconButton}>
|
<TouchableOpacity style={styles.iconButton} onPress={toggleMorePanel}>
|
||||||
<MaterialCommunityIcons name="plus-circle-outline" size={28} color={colors.text.secondary} />
|
<MaterialCommunityIcons
|
||||||
|
name={showMorePanel ? "close-circle-outline" : "plus-circle-outline"}
|
||||||
|
size={28}
|
||||||
|
color={showMorePanel ? colors.primary.main : colors.text.secondary}
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View style={styles.inputContainer}>
|
<View style={styles.inputContainer}>
|
||||||
@@ -691,8 +923,12 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity style={styles.iconButton}>
|
<TouchableOpacity style={styles.iconButton} onPress={toggleEmojiPanel}>
|
||||||
<MaterialCommunityIcons name="emoticon-outline" size={28} color={colors.text.secondary} />
|
<MaterialCommunityIcons
|
||||||
|
name={showEmojiPanel ? "emoticon-happy" : "emoticon-outline"}
|
||||||
|
size={28}
|
||||||
|
color={showEmojiPanel ? colors.primary.main : colors.text.secondary}
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -708,6 +944,26 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</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>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
{/* 群信息侧边栏面板 - 仅大屏幕显示 */}
|
{/* 群信息侧边栏面板 - 仅大屏幕显示 */}
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ import {
|
|||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||||
import { Text, ResponsiveContainer } from '../../components/common';
|
import { Text } from '../../components/common';
|
||||||
import { useResponsive } from '../../hooks';
|
import { useResponsive, useResponsiveSpacing } from '../../hooks';
|
||||||
|
|
||||||
|
// 内容最大宽度
|
||||||
|
const CONTENT_MAX_WIDTH = 720;
|
||||||
import { authService, resolveAuthApiError } from '../../services/authService';
|
import { authService, resolveAuthApiError } from '../../services/authService';
|
||||||
import { showPrompt } from '../../services/promptService';
|
import { showPrompt } from '../../services/promptService';
|
||||||
import { useAuthStore } from '../../stores';
|
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 (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||||
{isWideScreen ? (
|
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
|
||||||
<ResponsiveContainer maxWidth={800}>
|
{content}
|
||||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>{content}</ScrollView>
|
</ScrollView>
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>{content}</ScrollView>
|
|
||||||
)}
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -380,6 +381,9 @@ function createAccountSecurityStyles(colors: AppColors) {
|
|||||||
marginHorizontal: spacing.lg,
|
marginHorizontal: spacing.lg,
|
||||||
borderRadius: borderRadius.lg,
|
borderRadius: borderRadius.lg,
|
||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
|
maxWidth: CONTENT_MAX_WIDTH,
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: '100%',
|
||||||
},
|
},
|
||||||
statusRow: {
|
statusRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@@ -31,9 +31,12 @@ import {
|
|||||||
type AppColors,
|
type AppColors,
|
||||||
} from '../../theme';
|
} from '../../theme';
|
||||||
import { useAuthStore } from '../../stores';
|
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 { authService, uploadService } from '../../services';
|
||||||
import { useResponsive } from '../../hooks';
|
import { useResponsive, useResponsiveSpacing } from '../../hooks';
|
||||||
|
|
||||||
|
// 内容最大宽度
|
||||||
|
const CONTENT_MAX_WIDTH = 720;
|
||||||
|
|
||||||
function createEditProfileStyles(colors: AppColors) {
|
function createEditProfileStyles(colors: AppColors) {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
@@ -210,6 +213,9 @@ function createEditProfileStyles(colors: AppColors) {
|
|||||||
shadowOpacity: 0.05,
|
shadowOpacity: 0.05,
|
||||||
shadowRadius: 8,
|
shadowRadius: 8,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
|
maxWidth: CONTENT_MAX_WIDTH,
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: '100%',
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
marginBottom: spacing.lg,
|
marginBottom: spacing.lg,
|
||||||
@@ -357,6 +363,7 @@ export const EditProfileScreen: React.FC = () => {
|
|||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { currentUser, updateUser } = useAuthStore();
|
const { currentUser, updateUser } = useAuthStore();
|
||||||
const { isWideScreen, isMobile, width } = useResponsive();
|
const { isWideScreen, isMobile, width } = useResponsive();
|
||||||
|
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const [nickname, setNickname] = useState(currentUser?.nickname || '');
|
const [nickname, setNickname] = useState(currentUser?.nickname || '');
|
||||||
@@ -793,23 +800,12 @@ export const EditProfileScreen: React.FC = () => {
|
|||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
style={styles.keyboardView}
|
style={styles.keyboardView}
|
||||||
>
|
>
|
||||||
{isWideScreen ? (
|
<ScrollView
|
||||||
<ResponsiveContainer maxWidth={800}>
|
contentContainerStyle={[styles.scrollContent, { paddingHorizontal: responsivePadding }]}
|
||||||
<ScrollView
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={styles.scrollContent}
|
>
|
||||||
showsVerticalScrollIndicator={false}
|
{renderFormContent()}
|
||||||
>
|
</ScrollView>
|
||||||
{renderFormContent()}
|
|
||||||
</ScrollView>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={styles.scrollContent}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{renderFormContent()}
|
|
||||||
</ScrollView>
|
|
||||||
)}
|
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,14 +14,17 @@ import {
|
|||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { spacing, borderRadius, useAppColors, type AppColors } from '../../theme';
|
import { spacing, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||||
import { Text, ResponsiveContainer } from '../../components/common';
|
import { Text } from '../../components/common';
|
||||||
import {
|
import {
|
||||||
loadNotificationPreferences,
|
loadNotificationPreferences,
|
||||||
setPushNotificationsPreference,
|
setPushNotificationsPreference,
|
||||||
setSoundPreference,
|
setSoundPreference,
|
||||||
setVibrationPreference,
|
setVibrationPreference,
|
||||||
} from '../../services/notificationPreferences';
|
} from '../../services/notificationPreferences';
|
||||||
import { useResponsive } from '../../hooks';
|
import { useResponsive, useResponsiveSpacing } from '../../hooks';
|
||||||
|
|
||||||
|
// 内容最大宽度
|
||||||
|
const CONTENT_MAX_WIDTH = 720;
|
||||||
|
|
||||||
interface NotificationSettingItem {
|
interface NotificationSettingItem {
|
||||||
key: string;
|
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 (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||||
{isWideScreen ? (
|
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
|
||||||
<ResponsiveContainer maxWidth={800}>
|
{renderContent()}
|
||||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
|
</ScrollView>
|
||||||
{renderContent()}
|
|
||||||
</ScrollView>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
|
|
||||||
{renderContent()}
|
|
||||||
</ScrollView>
|
|
||||||
)}
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -212,6 +209,9 @@ function createNotificationSettingsStyles(colors: AppColors) {
|
|||||||
marginHorizontal: spacing.lg,
|
marginHorizontal: spacing.lg,
|
||||||
borderRadius: borderRadius.lg,
|
borderRadius: borderRadius.lg,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
maxWidth: CONTENT_MAX_WIDTH,
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: '100%',
|
||||||
},
|
},
|
||||||
settingItem: {
|
settingItem: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@@ -27,10 +27,13 @@ import {
|
|||||||
type AppColors,
|
type AppColors,
|
||||||
} from '../../theme';
|
} from '../../theme';
|
||||||
import { useAuthStore } from '../../stores';
|
import { useAuthStore } from '../../stores';
|
||||||
import { Text, ResponsiveContainer } from '../../components/common';
|
import { Text } from '../../components/common';
|
||||||
import { useResponsive } from '../../hooks';
|
import { useResponsive, useResponsiveSpacing } from '../../hooks';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
// 设置卡片最大宽度
|
||||||
|
const SETTINGS_CARD_MAX_WIDTH = 720;
|
||||||
|
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
|
|
||||||
interface SettingsItem {
|
interface SettingsItem {
|
||||||
@@ -84,6 +87,9 @@ function createSettingsStyles(colors: AppColors) {
|
|||||||
},
|
},
|
||||||
groupContainer: {
|
groupContainer: {
|
||||||
marginBottom: spacing.lg,
|
marginBottom: spacing.lg,
|
||||||
|
maxWidth: SETTINGS_CARD_MAX_WIDTH,
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: '100%',
|
||||||
},
|
},
|
||||||
groupHeader: {
|
groupHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -154,6 +160,9 @@ function createSettingsStyles(colors: AppColors) {
|
|||||||
paddingVertical: spacing.md,
|
paddingVertical: spacing.md,
|
||||||
borderRadius: borderRadius.lg,
|
borderRadius: borderRadius.lg,
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
|
maxWidth: SETTINGS_CARD_MAX_WIDTH,
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: '100%',
|
||||||
},
|
},
|
||||||
logoutText: {
|
logoutText: {
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
@@ -172,9 +181,9 @@ function createSettingsStyles(colors: AppColors) {
|
|||||||
export const SettingsScreen: React.FC = () => {
|
export const SettingsScreen: React.FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { logout } = useAuthStore();
|
const { logout } = useAuthStore();
|
||||||
const { isWideScreen } = useResponsive();
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { isMobile } = useResponsive();
|
const { isMobile } = useResponsive();
|
||||||
|
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const styles = useMemo(() => createSettingsStyles(colors), [colors]);
|
const styles = useMemo(() => createSettingsStyles(colors), [colors]);
|
||||||
const themePreference = useThemePreference();
|
const themePreference = useThemePreference();
|
||||||
@@ -355,17 +364,9 @@ export const SettingsScreen: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||||
{isWideScreen ? (
|
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
|
||||||
<ResponsiveContainer maxWidth={800}>
|
{renderContent()}
|
||||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
|
</ScrollView>
|
||||||
{renderContent()}
|
|
||||||
</ScrollView>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
|
|
||||||
{renderContent()}
|
|
||||||
</ScrollView>
|
|
||||||
)}
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -332,8 +332,22 @@ class MessageService {
|
|||||||
page: data.page || 1,
|
page: data.page || 1,
|
||||||
page_size: data.page_size || 20,
|
page_size: data.page_size || 20,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('获取消息列表失败:', error);
|
// 检查是否是权限错误或会话不存在错误 - 这是正常情况,不需要打印错误日志
|
||||||
|
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 {
|
return {
|
||||||
messages: [],
|
messages: [],
|
||||||
total: 0,
|
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