release/v1.0.11 #4
3
.gitignore
vendored
3
.gitignore
vendored
@@ -51,3 +51,6 @@ dist-android-update.zip
|
|||||||
# Backend
|
# Backend
|
||||||
backend/data/
|
backend/data/
|
||||||
backend/logs/
|
backend/logs/
|
||||||
|
|
||||||
|
doc/
|
||||||
|
docs/
|
||||||
|
|||||||
11
app.json
11
app.json
@@ -2,10 +2,11 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "萝卜社区",
|
"name": "萝卜社区",
|
||||||
"slug": "qojo",
|
"slug": "qojo",
|
||||||
"version": "1.0.10",
|
"version": "1.0.11",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "light",
|
"userInterfaceStyle": "light",
|
||||||
|
"scheme": "carrotbbs",
|
||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/splash-icon.png",
|
"image": "./assets/splash-icon.png",
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
@@ -33,7 +34,7 @@
|
|||||||
},
|
},
|
||||||
"predictiveBackGestureEnabled": false,
|
"predictiveBackGestureEnabled": false,
|
||||||
"package": "skin.carrot.bbs",
|
"package": "skin.carrot.bbs",
|
||||||
"versionCode": 5,
|
"versionCode": 6,
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"VIBRATE",
|
"VIBRATE",
|
||||||
"RECEIVE_BOOT_COMPLETED",
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
@@ -68,6 +69,12 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"expo-sqlite",
|
"expo-sqlite",
|
||||||
|
[
|
||||||
|
"expo-camera",
|
||||||
|
{
|
||||||
|
"cameraPermission": "允许萝卜社区访问您的相机以扫描二维码"
|
||||||
|
}
|
||||||
|
],
|
||||||
[
|
[
|
||||||
"expo-notifications",
|
"expo-notifications",
|
||||||
{
|
{
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
2988
package-lock.json
generated
2988
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "carrot_bbs",
|
"name": "carrot_bbs",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"main": "index.ts",
|
"main": "index.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"expo": "~55.0.4",
|
"expo": "~55.0.4",
|
||||||
"expo-background-fetch": "~55.0.9",
|
"expo-background-fetch": "~55.0.9",
|
||||||
|
"expo-camera": "^55.0.10",
|
||||||
"expo-constants": "~55.0.7",
|
"expo-constants": "~55.0.7",
|
||||||
"expo-dev-client": "~55.0.10",
|
"expo-dev-client": "~55.0.10",
|
||||||
"expo-file-system": "~55.0.10",
|
"expo-file-system": "~55.0.10",
|
||||||
|
|||||||
1131
plans/frontend_cursor_pagination_design.md
Normal file
1131
plans/frontend_cursor_pagination_design.md
Normal file
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 124 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.8 KiB |
251
src/components/business/QRCodeScanner.tsx
Normal file
251
src/components/business/QRCodeScanner.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
Alert,
|
||||||
|
Modal,
|
||||||
|
Dimensions,
|
||||||
|
} from 'react-native';
|
||||||
|
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { useNavigation } from '@react-navigation/native';
|
||||||
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import { RootStackParamList } from '../../navigation/types';
|
||||||
|
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||||
|
|
||||||
|
const { width, height } = Dimensions.get('window');
|
||||||
|
|
||||||
|
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
|
interface QRCodeScannerProps {
|
||||||
|
visible: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||||
|
const navigation = useNavigation<NavigationProp>();
|
||||||
|
const [permission, requestPermission] = useCameraPermissions();
|
||||||
|
const [scanned, setScanned] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
// 重置扫描状态,确保每次打开都能重新扫描
|
||||||
|
setScanned(false);
|
||||||
|
if (!permission?.granted) {
|
||||||
|
requestPermission();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
const handleBarCodeScanned = ({ type, data }: { type: string; data: string }) => {
|
||||||
|
if (scanned) return;
|
||||||
|
setScanned(true);
|
||||||
|
|
||||||
|
// 先关闭扫描界面,类似微信的做法
|
||||||
|
onClose();
|
||||||
|
|
||||||
|
// 解析二维码内容
|
||||||
|
// 格式: carrotbbs://qrcode/login?session_id=xxx
|
||||||
|
if (data.startsWith('carrotbbs://qrcode/login')) {
|
||||||
|
const sessionId = extractSessionId(data);
|
||||||
|
if (sessionId) {
|
||||||
|
// 跳转到确认页面
|
||||||
|
navigation.navigate('QRCodeConfirm', { sessionId });
|
||||||
|
} else {
|
||||||
|
Alert.alert('无效的二维码', '无法识别该二维码');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Alert.alert('无效的二维码', '请扫描网页端的登录二维码');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const extractSessionId = (url: string): string | null => {
|
||||||
|
try {
|
||||||
|
const sessionIdMatch = url.match(/session_id=([^&]+)/);
|
||||||
|
return sessionIdMatch ? sessionIdMatch[1] : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!permission?.granted) {
|
||||||
|
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.permissionContainer}>
|
||||||
|
<MaterialCommunityIcons name="camera-off" size={64} color="#999" />
|
||||||
|
<Text style={styles.permissionText}>需要相机权限才能扫码</Text>
|
||||||
|
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
|
||||||
|
<Text style={styles.permissionButtonText}>授予权限</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<CameraView
|
||||||
|
style={styles.camera}
|
||||||
|
facing="back"
|
||||||
|
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
|
||||||
|
barcodeScannerSettings={{
|
||||||
|
barcodeTypes: ['qr'],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<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.scanArea}>
|
||||||
|
<View style={styles.scanFrame}>
|
||||||
|
<View style={[styles.corner, styles.topLeft]} />
|
||||||
|
<View style={[styles.corner, styles.topRight]} />
|
||||||
|
<View style={[styles.corner, styles.bottomLeft]} />
|
||||||
|
<View style={[styles.corner, styles.bottomRight]} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.scanText}>将二维码放入框内即可扫描</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.footer}>
|
||||||
|
<TouchableOpacity style={styles.flashButton} onPress={() => {}}>
|
||||||
|
<MaterialCommunityIcons name="flashlight" size={24} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</CameraView>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#000',
|
||||||
|
},
|
||||||
|
camera: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingTop: spacing.xl,
|
||||||
|
paddingBottom: spacing.md,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: spacing.sm,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: fontSizes.lg,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
width: 40,
|
||||||
|
},
|
||||||
|
scanArea: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
scanFrame: {
|
||||||
|
width: width * 0.7,
|
||||||
|
height: width * 0.7,
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
corner: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderColor: colors.primary.main,
|
||||||
|
borderWidth: 3,
|
||||||
|
},
|
||||||
|
topLeft: {
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
borderRightWidth: 0,
|
||||||
|
borderBottomWidth: 0,
|
||||||
|
},
|
||||||
|
topRight: {
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
borderLeftWidth: 0,
|
||||||
|
borderBottomWidth: 0,
|
||||||
|
},
|
||||||
|
bottomLeft: {
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
borderRightWidth: 0,
|
||||||
|
borderTopWidth: 0,
|
||||||
|
},
|
||||||
|
bottomRight: {
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
borderLeftWidth: 0,
|
||||||
|
borderTopWidth: 0,
|
||||||
|
},
|
||||||
|
scanText: {
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
color: '#fff',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
footer: {
|
||||||
|
padding: spacing.xl,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
flashButton: {
|
||||||
|
padding: spacing.md,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
|
borderRadius: borderRadius.full,
|
||||||
|
},
|
||||||
|
permissionContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: spacing.xl,
|
||||||
|
},
|
||||||
|
permissionText: {
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
color: '#666',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
permissionButton: {
|
||||||
|
marginTop: spacing.xl,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
paddingHorizontal: spacing.xl,
|
||||||
|
backgroundColor: colors.primary.main,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
},
|
||||||
|
permissionButtonText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default QRCodeScanner;
|
||||||
@@ -83,6 +83,9 @@ export type {
|
|||||||
UsePaginationReturn,
|
UsePaginationReturn,
|
||||||
} from './usePagination';
|
} from './usePagination';
|
||||||
|
|
||||||
|
// ==================== 游标分页 Hooks ====================
|
||||||
|
export { useCursorPagination } from './useCursorPagination';
|
||||||
|
|
||||||
// ==================== 连接状态 Hooks ====================
|
// ==================== 连接状态 Hooks ====================
|
||||||
export { useConnectionState } from './useConnectionState';
|
export { useConnectionState } from './useConnectionState';
|
||||||
|
|
||||||
|
|||||||
208
src/hooks/useCursorPagination.ts
Normal file
208
src/hooks/useCursorPagination.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
CursorPaginationState,
|
||||||
|
CursorPaginationConfig,
|
||||||
|
CursorDirection,
|
||||||
|
UseCursorPaginationReturn,
|
||||||
|
CursorFetchFunction,
|
||||||
|
} from '../infrastructure/pagination/types';
|
||||||
|
import { CursorPaginationResponse } from '../types/dto';
|
||||||
|
|
||||||
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
const MAX_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游标分页 Hook
|
||||||
|
*
|
||||||
|
* @param fetchFunction 数据获取函数
|
||||||
|
* @param config 分页配置
|
||||||
|
* @param extraParams 额外参数(会传递给 fetchFunction)
|
||||||
|
*/
|
||||||
|
export function useCursorPagination<T, P = void>(
|
||||||
|
fetchFunction: CursorFetchFunction<T, P>,
|
||||||
|
config: Partial<CursorPaginationConfig> = {},
|
||||||
|
extraParams?: P
|
||||||
|
): UseCursorPaginationReturn<T> {
|
||||||
|
const { pageSize = DEFAULT_PAGE_SIZE, bidirectional = false } = config;
|
||||||
|
|
||||||
|
// 限制 pageSize 在有效范围内
|
||||||
|
const effectivePageSize = Math.min(Math.max(1, pageSize), MAX_PAGE_SIZE);
|
||||||
|
|
||||||
|
const [state, setState] = useState<CursorPaginationState<T>>({
|
||||||
|
items: [],
|
||||||
|
nextCursor: null,
|
||||||
|
prevCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
isLoading: false,
|
||||||
|
isRefreshing: false,
|
||||||
|
error: null,
|
||||||
|
isFirstLoad: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 用于取消请求的标志
|
||||||
|
const cancelledRef = useRef(false);
|
||||||
|
|
||||||
|
// 加载更多(下一页)
|
||||||
|
const loadMore = useCallback(async () => {
|
||||||
|
if (state.isLoading || !state.hasMore) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelledRef.current = false;
|
||||||
|
|
||||||
|
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response: CursorPaginationResponse<T> = await fetchFunction({
|
||||||
|
cursor: state.nextCursor || undefined,
|
||||||
|
direction: 'forward',
|
||||||
|
pageSize: effectivePageSize,
|
||||||
|
extraParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cancelledRef.current) return;
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
items: [...prev.items, ...response.items],
|
||||||
|
nextCursor: response.next_cursor,
|
||||||
|
prevCursor: response.prev_cursor,
|
||||||
|
hasMore: response.has_more && response.next_cursor !== null,
|
||||||
|
isLoading: false,
|
||||||
|
isFirstLoad: false,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
if (cancelledRef.current) return;
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
error: error instanceof Error ? error.message : '加载失败',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [state.isLoading, state.hasMore, state.nextCursor, fetchFunction, effectivePageSize, extraParams]);
|
||||||
|
|
||||||
|
// 加载上一页(双向分页)
|
||||||
|
const loadPrevious = useCallback(async () => {
|
||||||
|
if (!bidirectional || state.isLoading || !state.prevCursor) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelledRef.current = false;
|
||||||
|
|
||||||
|
setState(prev => ({ ...prev, isLoading: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response: CursorPaginationResponse<T> = await fetchFunction({
|
||||||
|
cursor: state.prevCursor,
|
||||||
|
direction: 'backward',
|
||||||
|
pageSize: effectivePageSize,
|
||||||
|
extraParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cancelledRef.current) return;
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
// 向前加载时,新数据放在前面
|
||||||
|
items: [...response.items, ...prev.items],
|
||||||
|
nextCursor: response.next_cursor,
|
||||||
|
prevCursor: response.prev_cursor,
|
||||||
|
hasMore: response.has_more,
|
||||||
|
isLoading: false,
|
||||||
|
isFirstLoad: false,
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
if (cancelledRef.current) return;
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isLoading: false,
|
||||||
|
error: error instanceof Error ? error.message : '加载失败',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [bidirectional, state.isLoading, state.prevCursor, fetchFunction, effectivePageSize, extraParams]);
|
||||||
|
|
||||||
|
// 刷新数据
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
cancelledRef.current = false;
|
||||||
|
|
||||||
|
setState(prev => ({ ...prev, isRefreshing: true, error: null }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response: CursorPaginationResponse<T> = await fetchFunction({
|
||||||
|
cursor: undefined,
|
||||||
|
direction: 'forward',
|
||||||
|
pageSize: effectivePageSize,
|
||||||
|
extraParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (cancelledRef.current) return;
|
||||||
|
|
||||||
|
setState({
|
||||||
|
items: response.items,
|
||||||
|
nextCursor: response.next_cursor,
|
||||||
|
prevCursor: response.prev_cursor,
|
||||||
|
hasMore: response.has_more && response.next_cursor !== null,
|
||||||
|
isLoading: false,
|
||||||
|
isRefreshing: false,
|
||||||
|
error: null,
|
||||||
|
isFirstLoad: false,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (cancelledRef.current) return;
|
||||||
|
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
isRefreshing: false,
|
||||||
|
error: error instanceof Error ? error.message : '刷新失败',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, [fetchFunction, effectivePageSize, extraParams]);
|
||||||
|
|
||||||
|
// 重置状态
|
||||||
|
const reset = useCallback(() => {
|
||||||
|
cancelledRef.current = true;
|
||||||
|
setState({
|
||||||
|
items: [],
|
||||||
|
nextCursor: null,
|
||||||
|
prevCursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
isLoading: false,
|
||||||
|
isRefreshing: false,
|
||||||
|
error: null,
|
||||||
|
isFirstLoad: true,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 设置数据(用于外部数据注入)
|
||||||
|
const setItems = useCallback(
|
||||||
|
(
|
||||||
|
items: T[],
|
||||||
|
nextCursor: string | null,
|
||||||
|
prevCursor: string | null,
|
||||||
|
hasMore: boolean
|
||||||
|
) => {
|
||||||
|
setState(prev => ({
|
||||||
|
...prev,
|
||||||
|
items,
|
||||||
|
nextCursor,
|
||||||
|
prevCursor,
|
||||||
|
hasMore,
|
||||||
|
isFirstLoad: false,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
loadMore,
|
||||||
|
loadPrevious,
|
||||||
|
refresh,
|
||||||
|
reset,
|
||||||
|
setItems,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default useCursorPagination;
|
||||||
@@ -19,6 +19,16 @@ export type {
|
|||||||
FetchPageFunction,
|
FetchPageFunction,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
|
// 导出游标分页相关类型
|
||||||
|
export type {
|
||||||
|
CursorDirection,
|
||||||
|
CursorPaginationConfig,
|
||||||
|
CursorPaginationState,
|
||||||
|
CursorPaginationActions,
|
||||||
|
UseCursorPaginationReturn,
|
||||||
|
CursorFetchFunction,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
// 导出工具函数和常量
|
// 导出工具函数和常量
|
||||||
export {
|
export {
|
||||||
DEFAULT_PAGINATION_CONFIG,
|
DEFAULT_PAGINATION_CONFIG,
|
||||||
|
|||||||
@@ -180,3 +180,79 @@ export function createPageCache<T>(
|
|||||||
export function isCacheExpired<T>(cache: PageCache<T>, ttl: number): boolean {
|
export function isCacheExpired<T>(cache: PageCache<T>, ttl: number): boolean {
|
||||||
return Date.now() - cache.cachedAt > ttl;
|
return Date.now() - cache.cachedAt > ttl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 游标分页相关类型 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游标分页方向
|
||||||
|
*/
|
||||||
|
export type CursorDirection = 'forward' | 'backward';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游标分页配置
|
||||||
|
*/
|
||||||
|
export interface CursorPaginationConfig {
|
||||||
|
/** 每页数量 */
|
||||||
|
pageSize: number;
|
||||||
|
/** 是否启用双向分页 */
|
||||||
|
bidirectional?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游标分页状态
|
||||||
|
*/
|
||||||
|
export interface CursorPaginationState<T> {
|
||||||
|
/** 数据项列表 */
|
||||||
|
items: T[];
|
||||||
|
/** 下一页游标 */
|
||||||
|
nextCursor: string | null;
|
||||||
|
/** 上一页游标 */
|
||||||
|
prevCursor: string | null;
|
||||||
|
/** 是否有更多数据 */
|
||||||
|
hasMore: boolean;
|
||||||
|
/** 是否正在加载 */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** 是否正在刷新 */
|
||||||
|
isRefreshing: boolean;
|
||||||
|
/** 错误信息 */
|
||||||
|
error: string | null;
|
||||||
|
/** 是否为首次加载 */
|
||||||
|
isFirstLoad: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游标分页操作
|
||||||
|
*/
|
||||||
|
export interface CursorPaginationActions<T> {
|
||||||
|
/** 加载更多(下一页) */
|
||||||
|
loadMore: () => Promise<void>;
|
||||||
|
/** 加载上一页(双向分页) */
|
||||||
|
loadPrevious: () => Promise<void>;
|
||||||
|
/** 刷新数据(重新从第一页加载) */
|
||||||
|
refresh: () => Promise<void>;
|
||||||
|
/** 重置状态 */
|
||||||
|
reset: () => void;
|
||||||
|
/** 设置数据(用于外部数据注入) */
|
||||||
|
setItems: (items: T[], nextCursor: string | null, prevCursor: string | null, hasMore: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游标分页 Hook 返回值
|
||||||
|
*/
|
||||||
|
export interface UseCursorPaginationReturn<T> extends CursorPaginationState<T>, CursorPaginationActions<T> {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游标分页数据获取函数
|
||||||
|
*/
|
||||||
|
export type CursorFetchFunction<T, P = void> = (params: {
|
||||||
|
cursor?: string;
|
||||||
|
direction: CursorDirection;
|
||||||
|
pageSize: number;
|
||||||
|
/** 额外参数 */
|
||||||
|
extraParams?: P;
|
||||||
|
}) => Promise<{
|
||||||
|
items: T[];
|
||||||
|
next_cursor: string | null;
|
||||||
|
prev_cursor: string | null;
|
||||||
|
has_more: boolean;
|
||||||
|
}>;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import { useAuthStore } from '../stores';
|
|||||||
|
|
||||||
// Deep linking 配置
|
// Deep linking 配置
|
||||||
const linking: LinkingOptions<RootStackParamList> = {
|
const linking: LinkingOptions<RootStackParamList> = {
|
||||||
prefixes: [],
|
prefixes: ['carrotbbs://'],
|
||||||
config: {
|
config: {
|
||||||
screens: {
|
screens: {
|
||||||
Auth: {
|
Auth: {
|
||||||
@@ -63,6 +63,7 @@ const linking: LinkingOptions<RootStackParamList> = {
|
|||||||
CreatePost: 'posts/create',
|
CreatePost: 'posts/create',
|
||||||
Chat: 'chat/:conversationId',
|
Chat: 'chat/:conversationId',
|
||||||
FollowList: 'users/:userId/:type',
|
FollowList: 'users/:userId/:type',
|
||||||
|
QRCodeConfirm: 'qrcode/login/:sessionId',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { PostDetailScreen } from '../screens/home';
|
|||||||
import { UserScreen } from '../screens/profile';
|
import { UserScreen } from '../screens/profile';
|
||||||
import FollowListScreen from '../screens/profile/FollowListScreen';
|
import FollowListScreen from '../screens/profile/FollowListScreen';
|
||||||
import { CreatePostScreen } from '../screens/create';
|
import { CreatePostScreen } from '../screens/create';
|
||||||
|
import { QRCodeConfirmScreen } from '../screens/auth/QRCodeConfirmScreen';
|
||||||
import {
|
import {
|
||||||
ChatScreen,
|
ChatScreen,
|
||||||
CreateGroupScreen,
|
CreateGroupScreen,
|
||||||
@@ -210,6 +211,15 @@ const getAuthenticatedScreens = () => [
|
|||||||
animation: 'slide_from_right',
|
animation: 'slide_from_right',
|
||||||
}}
|
}}
|
||||||
/>,
|
/>,
|
||||||
|
<RootStack.Screen
|
||||||
|
key="QRCodeConfirm"
|
||||||
|
name="QRCodeConfirm"
|
||||||
|
component={QRCodeConfirmScreen}
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: 'modal',
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
];
|
];
|
||||||
|
|
||||||
export function RootNavigator({ isAuthenticated, isInitializing }: RootNavigatorProps) {
|
export function RootNavigator({ isAuthenticated, isInitializing }: RootNavigatorProps) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 简单的移动端 Tab Navigator
|
* 简单的移动端 Tab Navigator
|
||||||
*
|
*
|
||||||
* 不使用 React Navigation 的 Tab Navigator,而是使用一个简单的自定义实现
|
* 不使用 React Navigation 的 Tab Navigator,而是使用一个简单的自定义实现
|
||||||
* 这样可以完全避免 React Navigation 状态恢复的问题
|
* 这样可以完全避免 React Navigation 状态恢复的问题
|
||||||
*/
|
*/
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
|
|
||||||
import { colors, shadows } from '../theme';
|
import { colors, shadows } from '../theme';
|
||||||
import { useTotalUnreadCount } from '../stores';
|
import { useTotalUnreadCount } from '../stores';
|
||||||
@@ -33,6 +34,15 @@ import { ProfileScreen, SettingsScreen, EditProfileScreen, NotificationSettingsS
|
|||||||
// ==================== 类型定义 ====================
|
// ==================== 类型定义 ====================
|
||||||
type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab';
|
type TabName = 'HomeTab' | 'MessageTab' | 'ScheduleTab' | 'ProfileTab';
|
||||||
|
|
||||||
|
// Schedule Stack 类型定义
|
||||||
|
export type ScheduleStackParamList = {
|
||||||
|
Schedule: undefined;
|
||||||
|
CourseDetail: { course: any; relatedCourses?: any[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== Stack Navigators ====================
|
||||||
|
const ScheduleStack = createNativeStackNavigator<ScheduleStackParamList>();
|
||||||
|
|
||||||
// ==================== 常量 ====================
|
// ==================== 常量 ====================
|
||||||
const TAB_BAR_HEIGHT = 64;
|
const TAB_BAR_HEIGHT = 64;
|
||||||
const TAB_BAR_MARGIN = 14;
|
const TAB_BAR_MARGIN = 14;
|
||||||
@@ -41,19 +51,32 @@ const TAB_BAR_FLOATING_MARGIN = 12;
|
|||||||
// ==================== 组件 ====================
|
// ==================== 组件 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 简化的 Stack Navigator 包装
|
* Schedule Stack Navigator 组件
|
||||||
*/
|
*/
|
||||||
function SimpleStackNavigator({
|
function ScheduleStackNavigatorComponent() {
|
||||||
children,
|
|
||||||
screenName
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
screenName: string;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.stackContainer}>
|
<ScheduleStack.Navigator
|
||||||
{children}
|
screenOptions={{
|
||||||
</View>
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ScheduleStack.Screen
|
||||||
|
name="Schedule"
|
||||||
|
component={ScheduleScreen}
|
||||||
|
options={{
|
||||||
|
title: '课表',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ScheduleStack.Screen
|
||||||
|
name="CourseDetail"
|
||||||
|
component={CourseDetailScreen}
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
presentation: 'transparentModal',
|
||||||
|
animation: 'fade',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ScheduleStack.Navigator>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +106,7 @@ export function SimpleMobileTabNavigator() {
|
|||||||
case 'MessageTab':
|
case 'MessageTab':
|
||||||
return <MessageListScreen />;
|
return <MessageListScreen />;
|
||||||
case 'ScheduleTab':
|
case 'ScheduleTab':
|
||||||
return <ScheduleScreen />;
|
return <ScheduleStackNavigatorComponent />;
|
||||||
case 'ProfileTab':
|
case 'ProfileTab':
|
||||||
return <ProfileScreen />;
|
return <ProfileScreen />;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ export type RootStackParamList = {
|
|||||||
userName?: string;
|
userName?: string;
|
||||||
userAvatar?: string | null;
|
userAvatar?: string | null;
|
||||||
};
|
};
|
||||||
|
QRCodeConfirm: { sessionId: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==================== 全局类型声明 ====================
|
// ==================== 全局类型声明 ====================
|
||||||
|
|||||||
@@ -1,223 +0,0 @@
|
|||||||
# useResponsive 拆分迁移指南
|
|
||||||
|
|
||||||
## 概述
|
|
||||||
|
|
||||||
原有的 `useResponsive.ts` (485行) 已被拆分为多个专注的 hooks,以提高代码的可维护性和性能。
|
|
||||||
|
|
||||||
## 新目录结构
|
|
||||||
|
|
||||||
```
|
|
||||||
src/presentation/hooks/responsive/
|
|
||||||
├── types.ts # 共享类型定义
|
|
||||||
├── useBreakpoint.ts # 断点检测
|
|
||||||
├── useScreenSize.ts # 屏幕尺寸
|
|
||||||
├── useOrientation.ts # 方向检测
|
|
||||||
├── usePlatform.ts # 平台检测
|
|
||||||
├── useResponsiveValue.ts # 响应式值映射
|
|
||||||
├── useResponsiveSpacing.ts # 响应式间距
|
|
||||||
├── useColumnCount.ts # 列数计算
|
|
||||||
├── useMediaQuery.ts # 媒体查询
|
|
||||||
├── useBreakpointCheck.ts # 断点范围检查 (兼容层)
|
|
||||||
├── useResponsive.ts # 兼容层 (保持向后兼容)
|
|
||||||
└── index.ts # 统一导出
|
|
||||||
```
|
|
||||||
|
|
||||||
## 新 Hook API 说明
|
|
||||||
|
|
||||||
### useBreakpoint - 断点检测
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useBreakpoint, useFineBreakpoint } from '../presentation/hooks/responsive';
|
|
||||||
|
|
||||||
// 基础断点: 'mobile' | 'tablet' | 'desktop' | 'wide'
|
|
||||||
const breakpoint = useBreakpoint();
|
|
||||||
|
|
||||||
// 细粒度断点: 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl'
|
|
||||||
const fineBreakpoint = useFineBreakpoint();
|
|
||||||
```
|
|
||||||
|
|
||||||
### useScreenSize - 屏幕尺寸
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useScreenSize, useWindowDimensions } from '../presentation/hooks/responsive';
|
|
||||||
|
|
||||||
// 完整屏幕尺寸信息
|
|
||||||
const {
|
|
||||||
width, height,
|
|
||||||
breakpoint, fineBreakpoint,
|
|
||||||
isMobile, isTablet, isDesktop, isWide
|
|
||||||
} = useScreenSize();
|
|
||||||
|
|
||||||
// 仅获取窗口尺寸
|
|
||||||
const { width, height, scale, fontScale } = useWindowDimensions();
|
|
||||||
```
|
|
||||||
|
|
||||||
### useOrientation - 方向检测
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useOrientation } from '../presentation/hooks/responsive';
|
|
||||||
|
|
||||||
const { orientation, isPortrait, isLandscape } = useOrientation();
|
|
||||||
```
|
|
||||||
|
|
||||||
### usePlatform - 平台检测
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { usePlatform } from '../presentation/hooks/responsive';
|
|
||||||
|
|
||||||
const { OS, isWeb, isIOS, isAndroid, isNative } = usePlatform();
|
|
||||||
```
|
|
||||||
|
|
||||||
### useResponsiveValue - 响应式值
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useResponsiveValue } from '../presentation/hooks/responsive';
|
|
||||||
|
|
||||||
// 根据断点返回不同值
|
|
||||||
const padding = useResponsiveValue({ xs: 8, md: 16, lg: 24 });
|
|
||||||
|
|
||||||
// 响应式样式
|
|
||||||
const style = useResponsiveStyle({
|
|
||||||
padding: { xs: 8, md: 16, lg: 24 },
|
|
||||||
fontSize: { xs: 14, lg: 16 }
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
### useResponsiveSpacing - 响应式间距
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useResponsiveSpacing } from '../presentation/hooks/responsive';
|
|
||||||
|
|
||||||
const gap = useResponsiveSpacing({ xs: 8, md: 16, lg: 24 });
|
|
||||||
```
|
|
||||||
|
|
||||||
### useColumnCount - 列数计算
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useColumnCount } from '../presentation/hooks/responsive';
|
|
||||||
|
|
||||||
const columns = useColumnCount({ xs: 1, sm: 2, md: 3, lg: 4 });
|
|
||||||
```
|
|
||||||
|
|
||||||
### useBreakpointGTE / useBreakpointLT - 断点范围检查
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useBreakpointGTE, useBreakpointLT, useBreakpointBetween } from '../presentation/hooks/responsive';
|
|
||||||
|
|
||||||
const isMediumUp = useBreakpointGTE('md');
|
|
||||||
const isMobileOnly = useBreakpointLT('lg');
|
|
||||||
const isTabletRange = useBreakpointBetween('md', 'lg');
|
|
||||||
```
|
|
||||||
|
|
||||||
### useMediaQuery - 媒体查询
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useMediaQuery } from '../presentation/hooks/responsive';
|
|
||||||
|
|
||||||
const isMinWidth768 = useMediaQuery({ minWidth: 768 });
|
|
||||||
const isPortrait = useMediaQuery({ orientation: 'portrait' });
|
|
||||||
```
|
|
||||||
|
|
||||||
## 迁移示例
|
|
||||||
|
|
||||||
### 示例 1: 使用 useResponsive (旧)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../hooks';
|
|
||||||
|
|
||||||
function Component() {
|
|
||||||
const { width, isMobile, isTablet, isDesktop, isWide } = useResponsive();
|
|
||||||
const padding = useResponsiveSpacing({ xs: 8, md: 16 });
|
|
||||||
|
|
||||||
return <View style={{ padding }} />;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 示例 2: 使用新专注 hooks (推荐)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {
|
|
||||||
useScreenSize,
|
|
||||||
useResponsiveSpacing
|
|
||||||
} from '../presentation/hooks/responsive';
|
|
||||||
|
|
||||||
function Component() {
|
|
||||||
const { width, isMobile, isTablet, isDesktop, isWide } = useScreenSize();
|
|
||||||
const padding = useResponsiveSpacing({ xs: 8, md: 16 });
|
|
||||||
|
|
||||||
return <View style={{ padding }} />;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 示例 3: 仅使用部分功能 (性能优化)
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useBreakpoint, usePlatform } from '../presentation/hooks/responsive';
|
|
||||||
|
|
||||||
function Component() {
|
|
||||||
// 只订阅断点变化,不订阅尺寸变化
|
|
||||||
const breakpoint = useBreakpoint();
|
|
||||||
const { isIOS } = usePlatform(); // 平台不会变化,只计算一次
|
|
||||||
|
|
||||||
return <View />;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 性能优势
|
|
||||||
|
|
||||||
1. **按需订阅**: 只使用需要的功能,避免不必要的重渲染
|
|
||||||
2. **细粒度更新**: 每个 hook 独立管理状态
|
|
||||||
3. **平台常量**: usePlatform 返回常量,不会触发重渲染
|
|
||||||
|
|
||||||
## 向后兼容
|
|
||||||
|
|
||||||
原有的 `useResponsive` 仍然可用,但标记为 `@deprecated`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { useResponsive } from '../hooks'; // 仍然可用
|
|
||||||
|
|
||||||
function Component() {
|
|
||||||
const { width, height, isMobile, platform } = useResponsive(); // 仍然可用
|
|
||||||
return <View />;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 工具函数
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {
|
|
||||||
getBreakpoint,
|
|
||||||
getFineBreakpoint,
|
|
||||||
isBreakpointGTE,
|
|
||||||
isBreakpointLT
|
|
||||||
} from '../presentation/hooks/responsive';
|
|
||||||
|
|
||||||
// 非 hooks 版本,用于工具函数
|
|
||||||
const breakpoint = getBreakpoint(800);
|
|
||||||
const fineBreakpoint = getFineBreakpoint(800);
|
|
||||||
const isGTE = isBreakpointGTE('md', 'sm');
|
|
||||||
```
|
|
||||||
|
|
||||||
## 类型导出
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import type {
|
|
||||||
BreakpointKey,
|
|
||||||
FineBreakpointKey,
|
|
||||||
ResponsiveValue,
|
|
||||||
Orientation,
|
|
||||||
PlatformInfo,
|
|
||||||
ScreenSize,
|
|
||||||
MediaQueryOptions,
|
|
||||||
ResponsiveInfo, // 兼容层
|
|
||||||
} from '../presentation/hooks/responsive';
|
|
||||||
```
|
|
||||||
|
|
||||||
## 常量
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { BREAKPOINTS, FINE_BREAKPOINTS } from '../presentation/hooks/responsive';
|
|
||||||
|
|
||||||
// BREAKPOINTS = { mobile: 0, tablet: 768, desktop: 1024, wide: 1440 }
|
|
||||||
// FINE_BREAKPOINTS = { xs: 0, sm: 375, md: 414, lg: 768, xl: 1024, '2xl': 1280, '3xl': 1440, '4xl': 1920 }
|
|
||||||
```
|
|
||||||
@@ -188,7 +188,7 @@ export const LoginScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 品牌名称 - 优化大屏排版 */}
|
{/* 品牌名称 - 优化大屏排版 */}
|
||||||
<Text style={[styles.appName, isSplitLayout && styles.splitAppName]}>胡萝卜</Text>
|
<Text style={[styles.appName, isSplitLayout && styles.splitAppName]}>萝卜社区</Text>
|
||||||
<Text style={[styles.subtitle, isSplitLayout && styles.splitSubtitle]}>发现有趣的内容,结识志同道合的朋友</Text>
|
<Text style={[styles.subtitle, isSplitLayout && styles.splitSubtitle]}>发现有趣的内容,结识志同道合的朋友</Text>
|
||||||
|
|
||||||
{/* 装饰元素 - 大屏模式改为水平排列 + 大幅放大 */}
|
{/* 装饰元素 - 大屏模式改为水平排列 + 大幅放大 */}
|
||||||
@@ -431,7 +431,7 @@ export const LoginScreen: React.FC = () => {
|
|||||||
color="#FFF"
|
color="#FFF"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.appName, { fontSize: appNameSize }]}>胡萝卜</Text>
|
<Text style={[styles.appName, { fontSize: appNameSize }]}>萝卜社区</Text>
|
||||||
<Text style={styles.subtitle}>发现有趣的内容,结识志同道合的朋友</Text>
|
<Text style={styles.subtitle}>发现有趣的内容,结识志同道合的朋友</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
|
|||||||
288
src/screens/auth/QRCodeConfirmScreen.tsx
Normal file
288
src/screens/auth/QRCodeConfirmScreen.tsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
Image,
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
|
||||||
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
|
import { RootStackParamList } from '../../navigation/types';
|
||||||
|
import { qrcodeApi } from '../../services/authService';
|
||||||
|
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||||
|
|
||||||
|
type QRCodeConfirmRouteProp = RouteProp<RootStackParamList, 'QRCodeConfirm'>;
|
||||||
|
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
|
export const QRCodeConfirmScreen: React.FC = () => {
|
||||||
|
const route = useRoute<QRCodeConfirmRouteProp>();
|
||||||
|
const navigation = useNavigation<NavigationProp>();
|
||||||
|
const { sessionId } = route.params;
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [userInfo, setUserInfo] = useState<{ id: string; nickname: string; avatar: string } | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 扫码
|
||||||
|
handleScan();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleScan = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await qrcodeApi.scan(sessionId);
|
||||||
|
setUserInfo(response.user);
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMsg = err.response?.data?.message || '扫码失败';
|
||||||
|
setError(errorMsg);
|
||||||
|
Alert.alert('扫码失败', errorMsg, [
|
||||||
|
{ text: '确定', onPress: () => navigation.goBack() }
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await qrcodeApi.confirm(sessionId);
|
||||||
|
Alert.alert('登录成功', '网页端已登录', [
|
||||||
|
{ text: '确定', onPress: () => navigation.goBack() }
|
||||||
|
]);
|
||||||
|
} catch (err: any) {
|
||||||
|
const errorMsg = err.response?.data?.message || '确认登录失败';
|
||||||
|
Alert.alert('登录失败', errorMsg);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = async () => {
|
||||||
|
try {
|
||||||
|
await qrcodeApi.cancel(sessionId);
|
||||||
|
} catch (err) {
|
||||||
|
// 忽略取消错误
|
||||||
|
}
|
||||||
|
navigation.goBack();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading && !userInfo) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||||
|
<Text style={styles.loadingText}>正在扫码...</Text>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<MaterialCommunityIcons name="alert-circle" size={64} color={colors.error.main} />
|
||||||
|
<Text style={styles.errorText}>{error}</Text>
|
||||||
|
<TouchableOpacity style={styles.backButton} onPress={() => navigation.goBack()}>
|
||||||
|
<Text style={styles.backButtonText}>返回</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={handleCancel} style={styles.closeButton}>
|
||||||
|
<MaterialCommunityIcons name="close" size={24} color="#666" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>确认登录</Text>
|
||||||
|
<View style={styles.placeholder} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.content}>
|
||||||
|
<View style={styles.infoCard}>
|
||||||
|
<Text style={styles.infoText}>您正在登录网页端</Text>
|
||||||
|
|
||||||
|
{userInfo && (
|
||||||
|
<View style={styles.userInfo}>
|
||||||
|
{userInfo.avatar ? (
|
||||||
|
<Image source={{ uri: userInfo.avatar }} style={styles.avatar} />
|
||||||
|
) : (
|
||||||
|
<View style={styles.avatarPlaceholder}>
|
||||||
|
<MaterialCommunityIcons name="account" size={40} color="#999" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<Text style={styles.nickname}>{userInfo.nickname}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.buttonContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.confirmButton]}
|
||||||
|
onPress={handleConfirm}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.confirmButtonText}>确认登录</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.button, styles.cancelButton]}
|
||||||
|
onPress={handleCancel}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelButtonText}>取消</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#eee',
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: spacing.sm,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: fontSizes.lg,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
width: 40,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
padding: spacing.lg,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
infoCard: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
padding: spacing.xl,
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.1,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 3,
|
||||||
|
},
|
||||||
|
infoText: {
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
color: '#666',
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
userInfo: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
avatarPlaceholder: {
|
||||||
|
width: 80,
|
||||||
|
height: 80,
|
||||||
|
borderRadius: 40,
|
||||||
|
backgroundColor: '#f0f0f0',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
nickname: {
|
||||||
|
fontSize: fontSizes.lg,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#333',
|
||||||
|
},
|
||||||
|
buttonContainer: {
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
paddingHorizontal: spacing.xl,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
confirmButton: {
|
||||||
|
backgroundColor: colors.primary.main,
|
||||||
|
},
|
||||||
|
confirmButtonText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#ddd',
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
color: '#666',
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
marginTop: spacing.md,
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
color: '#666',
|
||||||
|
},
|
||||||
|
errorContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: spacing.xl,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
marginTop: spacing.md,
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
color: '#666',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
marginTop: spacing.xl,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
paddingHorizontal: spacing.xl,
|
||||||
|
backgroundColor: colors.primary.main,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
},
|
||||||
|
backButtonText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default QRCodeConfirmScreen;
|
||||||
@@ -268,8 +268,8 @@ export const RegisterScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 品牌名称 - 优化大屏排版 */}
|
{/* 品牌名称 - 优化大屏排版 */}
|
||||||
<Text style={[styles.title, isSplitLayout && styles.splitTitle]}>创建账号</Text>
|
<Text style={[styles.title, isSplitLayout && styles.splitTitle]}>加入萝卜社区</Text>
|
||||||
<Text style={[styles.subtitle, isSplitLayout && styles.splitSubtitle]}>加入胡萝卜,开始你的旅程</Text>
|
<Text style={[styles.subtitle, isSplitLayout && styles.splitSubtitle]}>加入萝卜社区,发现有趣的内容</Text>
|
||||||
|
|
||||||
{/* 装饰元素 - 大屏模式改为水平排列 + 大幅放大 */}
|
{/* 装饰元素 - 大屏模式改为水平排列 + 大幅放大 */}
|
||||||
<View style={[styles.leftPanelDecorBottom, isSplitLayout && styles.splitLeftPanelDecorBottom]}>
|
<View style={[styles.leftPanelDecorBottom, isSplitLayout && styles.splitLeftPanelDecorBottom]}>
|
||||||
@@ -720,8 +720,8 @@ export const RegisterScreen: React.FC = () => {
|
|||||||
color="#FFF"
|
color="#FFF"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
<Text style={[styles.title, { fontSize: titleSize }]}>创建账号</Text>
|
<Text style={[styles.title, { fontSize: titleSize }]}>加入萝卜社区</Text>
|
||||||
<Text style={styles.subtitle}>加入胡萝卜,开始你的旅程</Text>
|
<Text style={styles.subtitle}>加入萝卜社区,发现有趣的内容</Text>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* 表单 */}
|
{/* 表单 */}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
RefreshControl,
|
RefreshControl,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
NativeScrollEvent,
|
|
||||||
NativeSyntheticEvent,
|
NativeSyntheticEvent,
|
||||||
Alert,
|
Alert,
|
||||||
Clipboard,
|
Clipboard,
|
||||||
@@ -33,6 +32,8 @@ import { PostCard, TabBar, SearchBar } from '../../components/business';
|
|||||||
import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common';
|
import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common';
|
||||||
import { HomeStackParamList, RootStackParamList } from '../../navigation/types';
|
import { HomeStackParamList, RootStackParamList } from '../../navigation/types';
|
||||||
import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive';
|
import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive';
|
||||||
|
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||||
|
import { CursorPaginationRequest } from '../../types/dto';
|
||||||
import { SearchScreen } from './SearchScreen';
|
import { SearchScreen } from './SearchScreen';
|
||||||
import { CreatePostScreen } from '../create/CreatePostScreen';
|
import { CreatePostScreen } from '../create/CreatePostScreen';
|
||||||
import { navigationService } from '../../infrastructure/navigation/navigationService';
|
import { navigationService } from '../../infrastructure/navigation/navigationService';
|
||||||
@@ -42,8 +43,6 @@ type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'Home'> & Na
|
|||||||
const TABS = ['推荐', '关注', '热门', '最新'];
|
const TABS = ['推荐', '关注', '热门', '最新'];
|
||||||
const TAB_ICONS = ['compass-outline', 'account-heart-outline', 'fire', 'clock-outline'];
|
const TAB_ICONS = ['compass-outline', 'account-heart-outline', 'fire', 'clock-outline'];
|
||||||
const DEFAULT_PAGE_SIZE = 20;
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
const SCROLL_BOTTOM_THRESHOLD = 240;
|
|
||||||
const LOAD_MORE_COOLDOWN_MS = 800;
|
|
||||||
const SWIPE_TRANSLATION_THRESHOLD = 40;
|
const SWIPE_TRANSLATION_THRESHOLD = 40;
|
||||||
const SWIPE_COOLDOWN_MS = 300;
|
const SWIPE_COOLDOWN_MS = 300;
|
||||||
const MOBILE_TAB_BAR_HEIGHT = 64;
|
const MOBILE_TAB_BAR_HEIGHT = 64;
|
||||||
@@ -51,11 +50,12 @@ const MOBILE_TAB_FLOATING_MARGIN = 12;
|
|||||||
const MOBILE_FAB_GAP = 12;
|
const MOBILE_FAB_GAP = 12;
|
||||||
|
|
||||||
type ViewMode = 'list' | 'grid';
|
type ViewMode = 'list' | 'grid';
|
||||||
|
type PostType = 'recommend' | 'follow' | 'hot' | 'latest';
|
||||||
|
|
||||||
export const HomeScreen: React.FC = () => {
|
export const HomeScreen: React.FC = () => {
|
||||||
const navigation = useNavigation<NavigationProp>();
|
const navigation = useNavigation<NavigationProp>();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { fetchPosts, likePost, unlikePost, favoritePost, unfavoritePost, posts: storePosts } = useUserStore();
|
const { likePost, unlikePost, favoritePost, unfavoritePost, posts: storePosts } = useUserStore();
|
||||||
const currentUser = useCurrentUser();
|
const currentUser = useCurrentUser();
|
||||||
|
|
||||||
// 使用响应式 hook
|
// 使用响应式 hook
|
||||||
@@ -65,9 +65,6 @@ export const HomeScreen: React.FC = () => {
|
|||||||
isTablet,
|
isTablet,
|
||||||
isDesktop,
|
isDesktop,
|
||||||
isWideScreen,
|
isWideScreen,
|
||||||
breakpoint,
|
|
||||||
orientation,
|
|
||||||
isLandscape
|
|
||||||
} = useResponsive();
|
} = useResponsive();
|
||||||
|
|
||||||
// 响应式间距
|
// 响应式间距
|
||||||
@@ -75,12 +72,6 @@ export const HomeScreen: React.FC = () => {
|
|||||||
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
|
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
|
||||||
|
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const [posts, setPosts] = useState<Post[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [hasMore, setHasMore] = useState(true);
|
|
||||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||||
|
|
||||||
// 图片查看器状态
|
// 图片查看器状态
|
||||||
@@ -95,18 +86,56 @@ export const HomeScreen: React.FC = () => {
|
|||||||
const [showCreatePost, setShowCreatePost] = useState(false);
|
const [showCreatePost, setShowCreatePost] = useState(false);
|
||||||
|
|
||||||
// 用于跟踪当前页面显示的帖子 ID,以便从 store 同步状态
|
// 用于跟踪当前页面显示的帖子 ID,以便从 store 同步状态
|
||||||
const postIdsRef = React.useRef<Set<string>>(new Set());
|
const postIdsRef = useRef<Set<string>>(new Set());
|
||||||
const inFlightRequestKeysRef = React.useRef<Set<string>>(new Set());
|
|
||||||
const lastLoadMoreTriggerAtRef = useRef(0);
|
|
||||||
const lastSwipeAtRef = useRef(0);
|
const lastSwipeAtRef = useRef(0);
|
||||||
|
|
||||||
// 用 ref 同步关键状态,避免 onWaterfallScroll 的陈旧闭包问题
|
// 获取当前 tab 对应的帖子类型
|
||||||
const pageRef = useRef(page);
|
const getPostType = useCallback((): PostType => {
|
||||||
const loadingMoreRef = useRef(loadingMore);
|
switch (activeIndex) {
|
||||||
const hasMoreRef = useRef(hasMore);
|
case 0: return 'recommend';
|
||||||
pageRef.current = page;
|
case 1: return 'follow';
|
||||||
loadingMoreRef.current = loadingMore;
|
case 2: return 'hot';
|
||||||
hasMoreRef.current = hasMore;
|
case 3: return 'latest';
|
||||||
|
default: return 'recommend';
|
||||||
|
}
|
||||||
|
}, [activeIndex]);
|
||||||
|
|
||||||
|
// 使用游标分页获取帖子列表
|
||||||
|
const {
|
||||||
|
items: posts,
|
||||||
|
isLoading,
|
||||||
|
isRefreshing,
|
||||||
|
hasMore,
|
||||||
|
error,
|
||||||
|
loadMore,
|
||||||
|
refresh,
|
||||||
|
} = useCursorPagination<Post, { post_type: PostType }>(
|
||||||
|
async ({ cursor, pageSize, extraParams }) => {
|
||||||
|
const params: CursorPaginationRequest = {
|
||||||
|
cursor,
|
||||||
|
page_size: pageSize,
|
||||||
|
post_type: extraParams?.post_type,
|
||||||
|
};
|
||||||
|
const response = await postService.getPostsCursor(params);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
{ pageSize: DEFAULT_PAGE_SIZE },
|
||||||
|
{ post_type: getPostType() }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Tab 切换时刷新数据
|
||||||
|
useEffect(() => {
|
||||||
|
refresh();
|
||||||
|
}, [activeIndex]);
|
||||||
|
|
||||||
|
// 同步 store 中的帖子状态到本地(用于点赞、收藏等状态更新)
|
||||||
|
useEffect(() => {
|
||||||
|
if (posts.length === 0) return;
|
||||||
|
|
||||||
|
// 更新 postIdsRef
|
||||||
|
const currentPostIds = new Set(posts.map(p => p.id));
|
||||||
|
postIdsRef.current = currentPostIds;
|
||||||
|
}, [posts, storePosts]);
|
||||||
|
|
||||||
// 根据屏幕尺寸确定网格列数
|
// 根据屏幕尺寸确定网格列数
|
||||||
const gridColumns = useMemo(() => {
|
const gridColumns = useMemo(() => {
|
||||||
@@ -154,147 +183,6 @@ export const HomeScreen: React.FC = () => {
|
|||||||
return insets.bottom + MOBILE_TAB_BAR_HEIGHT + MOBILE_TAB_FLOATING_MARGIN + MOBILE_FAB_GAP - MOBILE_TAB_BAR_HEIGHT;
|
return insets.bottom + MOBILE_TAB_BAR_HEIGHT + MOBILE_TAB_FLOATING_MARGIN + MOBILE_FAB_GAP - MOBILE_TAB_BAR_HEIGHT;
|
||||||
}, [isMobile, insets.bottom]);
|
}, [isMobile, insets.bottom]);
|
||||||
|
|
||||||
const appendUniquePosts = useCallback((prevPosts: Post[], incomingPosts: Post[]) => {
|
|
||||||
if (incomingPosts.length === 0) return prevPosts;
|
|
||||||
const seenIds = new Set(prevPosts.map(item => item.id));
|
|
||||||
const dedupedIncoming = incomingPosts.filter(item => {
|
|
||||||
if (seenIds.has(item.id)) return false;
|
|
||||||
seenIds.add(item.id);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
return dedupedIncoming.length > 0 ? [...prevPosts, ...dedupedIncoming] : prevPosts;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const uniquePostsById = useCallback((items: Post[]) => {
|
|
||||||
if (items.length <= 1) return items;
|
|
||||||
const map = new Map<string, Post>();
|
|
||||||
for (const item of items) {
|
|
||||||
map.set(item.id, item);
|
|
||||||
}
|
|
||||||
return Array.from(map.values());
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getPostType = (): 'recommend' | 'follow' | 'hot' | 'latest' => {
|
|
||||||
switch (activeIndex) {
|
|
||||||
case 0: return 'recommend';
|
|
||||||
case 1: return 'follow';
|
|
||||||
case 2: return 'hot';
|
|
||||||
case 3: return 'latest';
|
|
||||||
default: return 'recommend';
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 加载帖子列表
|
|
||||||
const loadPosts = useCallback(async (pageNum: number = 1, isRefresh: boolean = false) => {
|
|
||||||
const postType = getPostType();
|
|
||||||
const requestKey = `${postType}:${pageNum}`;
|
|
||||||
if (inFlightRequestKeysRef.current.has(requestKey)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
inFlightRequestKeysRef.current.add(requestKey);
|
|
||||||
if (isRefresh) {
|
|
||||||
setRefreshing(true);
|
|
||||||
} else if (pageNum === 1) {
|
|
||||||
setLoading(true);
|
|
||||||
} else {
|
|
||||||
setLoadingMore(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await fetchPosts(postType, pageNum);
|
|
||||||
const newPosts = response.list || [];
|
|
||||||
|
|
||||||
if (isRefresh) {
|
|
||||||
setPosts(uniquePostsById(newPosts));
|
|
||||||
setPage(1);
|
|
||||||
} else if (pageNum === 1) {
|
|
||||||
setPosts(uniquePostsById(newPosts));
|
|
||||||
setPage(1);
|
|
||||||
} else {
|
|
||||||
setPosts(prev => appendUniquePosts(prev, newPosts));
|
|
||||||
setPage(pageNum);
|
|
||||||
}
|
|
||||||
|
|
||||||
const hasMoreByPage = response.total_pages > 0 ? response.page < response.total_pages : false;
|
|
||||||
const hasMoreBySize = newPosts.length >= (response.page_size || DEFAULT_PAGE_SIZE);
|
|
||||||
setHasMore(hasMoreByPage || hasMoreBySize);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load posts:', error);
|
|
||||||
} finally {
|
|
||||||
inFlightRequestKeysRef.current.delete(requestKey);
|
|
||||||
setLoading(false);
|
|
||||||
setRefreshing(false);
|
|
||||||
setLoadingMore(false);
|
|
||||||
}
|
|
||||||
}, [fetchPosts, activeIndex, appendUniquePosts, uniquePostsById]);
|
|
||||||
|
|
||||||
// 切换Tab时重新加载
|
|
||||||
useEffect(() => {
|
|
||||||
loadPosts(1, true);
|
|
||||||
}, [activeIndex]);
|
|
||||||
|
|
||||||
// 同步 store 中的帖子状态到本地(用于点赞、收藏等状态更新)
|
|
||||||
useEffect(() => {
|
|
||||||
if (posts.length === 0) return;
|
|
||||||
|
|
||||||
// 更新 postIdsRef
|
|
||||||
const currentPostIds = new Set(posts.map(p => p.id));
|
|
||||||
postIdsRef.current = currentPostIds;
|
|
||||||
|
|
||||||
// 从 store 中找到对应的帖子并同步状态
|
|
||||||
let hasChanges = false;
|
|
||||||
const updatedPosts = posts.map(localPost => {
|
|
||||||
const storePost = storePosts.find(sp => sp.id === localPost.id);
|
|
||||||
if (storePost && (
|
|
||||||
storePost.is_liked !== localPost.is_liked ||
|
|
||||||
storePost.is_favorited !== localPost.is_favorited ||
|
|
||||||
storePost.likes_count !== localPost.likes_count ||
|
|
||||||
storePost.favorites_count !== localPost.favorites_count
|
|
||||||
)) {
|
|
||||||
hasChanges = true;
|
|
||||||
return {
|
|
||||||
...localPost,
|
|
||||||
is_liked: storePost.is_liked,
|
|
||||||
is_favorited: storePost.is_favorited,
|
|
||||||
likes_count: storePost.likes_count,
|
|
||||||
favorites_count: storePost.favorites_count,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return localPost;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (hasChanges) {
|
|
||||||
setPosts(updatedPosts);
|
|
||||||
}
|
|
||||||
}, [storePosts]);
|
|
||||||
|
|
||||||
// 下拉刷新
|
|
||||||
const onRefresh = useCallback(() => {
|
|
||||||
loadPosts(1, true);
|
|
||||||
}, [loadPosts]);
|
|
||||||
|
|
||||||
// 上拉加载更多
|
|
||||||
const onEndReached = useCallback(() => {
|
|
||||||
if (!loadingMoreRef.current && hasMoreRef.current) {
|
|
||||||
loadPosts(pageRef.current + 1);
|
|
||||||
}
|
|
||||||
}, [loadPosts]);
|
|
||||||
|
|
||||||
const onWaterfallScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
||||||
if (loadingMoreRef.current || !hasMoreRef.current) return;
|
|
||||||
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
|
||||||
const distanceToBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height);
|
|
||||||
const now = Date.now();
|
|
||||||
if (distanceToBottom <= SCROLL_BOTTOM_THRESHOLD) {
|
|
||||||
if (now - lastLoadMoreTriggerAtRef.current < LOAD_MORE_COOLDOWN_MS) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
lastLoadMoreTriggerAtRef.current = now;
|
|
||||||
loadPosts(pageRef.current + 1);
|
|
||||||
}
|
|
||||||
}, [loadPosts]);
|
|
||||||
|
|
||||||
// 切换视图模式
|
// 切换视图模式
|
||||||
const toggleViewMode = () => {
|
const toggleViewMode = () => {
|
||||||
setViewMode(prev => prev === 'list' ? 'grid' : 'list');
|
setViewMode(prev => prev === 'list' ? 'grid' : 'list');
|
||||||
@@ -375,27 +263,27 @@ export const HomeScreen: React.FC = () => {
|
|||||||
if (!post?.id) return;
|
if (!post?.id) return;
|
||||||
try {
|
try {
|
||||||
await postService.sharePost(post.id);
|
await postService.sharePost(post.id);
|
||||||
} catch (error) {
|
} catch (shareError) {
|
||||||
console.error('上报分享次数失败:', error);
|
console.error('上报分享次数失败:', shareError);
|
||||||
}
|
}
|
||||||
const postUrl = `https://browser.littlelan.cn/posts/${encodeURIComponent(post.id)}`;
|
const postUrl = `https://browser.littlelan.cn/posts/${encodeURIComponent(post.id)}`;
|
||||||
Clipboard.setString(postUrl);
|
Clipboard.setString(postUrl);
|
||||||
Alert.alert('已复制', '帖子链接已复制到剪贴板');
|
Alert.alert('已复制', '帖子链接已复制到剪贴板');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 删除帖子
|
// 删除帖子 - 由于 posts 来自 Hook,需要刷新列表
|
||||||
const handleDeletePost = async (postId: string) => {
|
const handleDeletePost = async (postId: string) => {
|
||||||
try {
|
try {
|
||||||
const success = await postService.deletePost(postId);
|
const success = await postService.deletePost(postId);
|
||||||
if (success) {
|
if (success) {
|
||||||
// 从列表中移除已删除的帖子
|
// 刷新列表以移除已删除的帖子
|
||||||
setPosts(prev => prev.filter(p => p.id !== postId));
|
refresh();
|
||||||
} else {
|
} else {
|
||||||
console.error('删除帖子失败');
|
console.error('删除帖子失败');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (deleteError) {
|
||||||
console.error('删除帖子失败:', error);
|
console.error('删除帖子失败:', deleteError);
|
||||||
throw error; // 重新抛出错误,让 PostCard 处理错误提示
|
throw deleteError; // 重新抛出错误,让 PostCard 处理错误提示
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -545,12 +433,11 @@ export const HomeScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
onScroll={onWaterfallScroll}
|
|
||||||
scrollEventThrottle={100}
|
scrollEventThrottle={100}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={refreshing}
|
refreshing={isRefreshing}
|
||||||
onRefresh={onRefresh}
|
onRefresh={refresh}
|
||||||
colors={[colors.primary.main]}
|
colors={[colors.primary.main]}
|
||||||
tintColor={colors.primary.main}
|
tintColor={colors.primary.main}
|
||||||
/>
|
/>
|
||||||
@@ -594,7 +481,7 @@ export const HomeScreen: React.FC = () => {
|
|||||||
|
|
||||||
// 渲染空状态
|
// 渲染空状态
|
||||||
const renderEmpty = () => {
|
const renderEmpty = () => {
|
||||||
if (loading) return null;
|
if (isLoading) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -628,16 +515,16 @@ export const HomeScreen: React.FC = () => {
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={refreshing}
|
refreshing={isRefreshing}
|
||||||
onRefresh={onRefresh}
|
onRefresh={refresh}
|
||||||
colors={[colors.primary.main]}
|
colors={[colors.primary.main]}
|
||||||
tintColor={colors.primary.main}
|
tintColor={colors.primary.main}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
onEndReached={onEndReached}
|
onEndReached={loadMore}
|
||||||
onEndReachedThreshold={0.3}
|
onEndReachedThreshold={0.3}
|
||||||
ListEmptyComponent={renderEmpty}
|
ListEmptyComponent={renderEmpty}
|
||||||
ListFooterComponent={loadingMore ? <Loading size="sm" /> : null}
|
ListFooterComponent={isLoading ? <Loading size="sm" /> : null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import { Post, Comment, VoteResultDTO } from '../../types';
|
|||||||
import { useUserStore } from '../../stores';
|
import { useUserStore } from '../../stores';
|
||||||
import { useCurrentUser } from '../../stores/authStore';
|
import { useCurrentUser } from '../../stores/authStore';
|
||||||
import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services';
|
import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services';
|
||||||
|
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||||
import { CommentItem, VoteCard } from '../../components/business';
|
import { CommentItem, VoteCard } from '../../components/business';
|
||||||
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout } from '../../components/common';
|
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout } from '../../components/common';
|
||||||
import { RootStackParamList } from '../../navigation/types';
|
import { RootStackParamList } from '../../navigation/types';
|
||||||
@@ -76,6 +77,30 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
const [comments, setComments] = useState<Comment[]>([]);
|
const [comments, setComments] = useState<Comment[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
// 使用游标分页 Hook 管理评论列表
|
||||||
|
const {
|
||||||
|
items: paginatedComments,
|
||||||
|
isLoading: isCommentsLoading,
|
||||||
|
isRefreshing: isCommentsRefreshing,
|
||||||
|
hasMore: hasMoreComments,
|
||||||
|
loadMore: loadMoreComments,
|
||||||
|
refresh: refreshComments,
|
||||||
|
error: commentsError,
|
||||||
|
} = useCursorPagination(
|
||||||
|
async ({ cursor, pageSize }) => {
|
||||||
|
return await commentService.getPostCommentsCursor(postId, {
|
||||||
|
cursor,
|
||||||
|
page_size: pageSize,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ pageSize: 20 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 同步分页评论到本地状态
|
||||||
|
useEffect(() => {
|
||||||
|
setComments(paginatedComments);
|
||||||
|
}, [paginatedComments]);
|
||||||
const [commentText, setCommentText] = useState('');
|
const [commentText, setCommentText] = useState('');
|
||||||
const [showImageModal, setShowImageModal] = useState(false);
|
const [showImageModal, setShowImageModal] = useState(false);
|
||||||
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
||||||
@@ -165,9 +190,8 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载评论
|
// 加载评论(使用游标分页刷新)
|
||||||
const commentsData = await commentService.getPostComments(postId);
|
await refreshComments();
|
||||||
setComments(commentsData.list);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载帖子详情失败:', error);
|
console.error('加载帖子详情失败:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -261,12 +285,13 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 下拉刷新
|
// 下拉刷新 - 同时刷新帖子和评论
|
||||||
const onRefresh = useCallback(() => {
|
const onRefresh = useCallback(async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
loadPostDetail();
|
await loadPostDetail();
|
||||||
|
await refreshComments();
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}, [loadPostDetail]);
|
}, [loadPostDetail, refreshComments]);
|
||||||
|
|
||||||
const formatDateTime = (dateString?: string | null): string => {
|
const formatDateTime = (dateString?: string | null): string => {
|
||||||
if (!dateString) return '';
|
if (!dateString) return '';
|
||||||
@@ -1375,12 +1400,34 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={refreshing}
|
refreshing={refreshing || isCommentsRefreshing}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
colors={[colors.primary.main]}
|
colors={[colors.primary.main]}
|
||||||
tintColor={colors.primary.main}
|
tintColor={colors.primary.main}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
onEndReached={loadMoreComments}
|
||||||
|
onEndReachedThreshold={0.3}
|
||||||
|
ListFooterComponent={
|
||||||
|
isCommentsLoading ? (
|
||||||
|
<View style={styles.commentsLoadingFooter}>
|
||||||
|
<Loading size="sm" />
|
||||||
|
</View>
|
||||||
|
) : hasMoreComments ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.loadMoreButton}
|
||||||
|
onPress={loadMoreComments}
|
||||||
|
>
|
||||||
|
<Text variant="caption" color={colors.primary.main}>
|
||||||
|
加载更多评论
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : comments.length > 0 ? (
|
||||||
|
<Text variant="caption" color={colors.text.hint} style={styles.noMoreComments}>
|
||||||
|
没有更多评论了
|
||||||
|
</Text>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1433,7 +1480,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={refreshing}
|
refreshing={refreshing || isCommentsRefreshing}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
colors={[colors.primary.main]}
|
colors={[colors.primary.main]}
|
||||||
tintColor={colors.primary.main}
|
tintColor={colors.primary.main}
|
||||||
@@ -1465,7 +1512,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
// 移动端单栏布局
|
// 移动端单栏布局
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView
|
||||||
style={styles.flex}
|
style={styles.flex}
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
keyboardVerticalOffset={88}
|
keyboardVerticalOffset={88}
|
||||||
@@ -1486,12 +1533,34 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={refreshing}
|
refreshing={refreshing || isCommentsRefreshing}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
colors={[colors.primary.main]}
|
colors={[colors.primary.main]}
|
||||||
tintColor={colors.primary.main}
|
tintColor={colors.primary.main}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
onEndReached={loadMoreComments}
|
||||||
|
onEndReachedThreshold={0.3}
|
||||||
|
ListFooterComponent={
|
||||||
|
isCommentsLoading ? (
|
||||||
|
<View style={styles.commentsLoadingFooter}>
|
||||||
|
<Loading size="sm" />
|
||||||
|
</View>
|
||||||
|
) : hasMoreComments ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.loadMoreButton}
|
||||||
|
onPress={loadMoreComments}
|
||||||
|
>
|
||||||
|
<Text variant="caption" color={colors.primary.main}>
|
||||||
|
加载更多评论
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : comments.length > 0 ? (
|
||||||
|
<Text variant="caption" color={colors.text.hint} style={styles.noMoreComments}>
|
||||||
|
没有更多评论了
|
||||||
|
</Text>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 评论输入框 - 跟随键盘 */}
|
{/* 评论输入框 - 跟随键盘 */}
|
||||||
@@ -1905,4 +1974,22 @@ const styles = StyleSheet.create({
|
|||||||
marginTop: spacing.md,
|
marginTop: spacing.md,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
},
|
},
|
||||||
|
// 评论加载更多样式
|
||||||
|
commentsLoadingFooter: {
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
loadMoreButton: {
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: colors.background.paper,
|
||||||
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderTopColor: colors.divider,
|
||||||
|
},
|
||||||
|
noMoreComments: {
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,13 +4,14 @@
|
|||||||
* 支持响应式布局,宽屏下显示更大的搜索结果区域
|
* 支持响应式布局,宽屏下显示更大的搜索结果区域
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
FlatList,
|
FlatList,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
RefreshControl,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
@@ -21,13 +22,15 @@ import { Post, User } from '../../types';
|
|||||||
import { useUserStore } from '../../stores';
|
import { useUserStore } from '../../stores';
|
||||||
import { postService, authService } from '../../services';
|
import { postService, authService } from '../../services';
|
||||||
import { PostCard, TabBar, SearchBar } from '../../components/business';
|
import { PostCard, TabBar, SearchBar } from '../../components/business';
|
||||||
import { Avatar, EmptyState, Text, ResponsiveGrid } from '../../components/common';
|
import { Avatar, EmptyState, Text, ResponsiveGrid, Loading } from '../../components/common';
|
||||||
import { HomeStackParamList } from '../../navigation/types';
|
import { HomeStackParamList } from '../../navigation/types';
|
||||||
import { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks/useResponsive';
|
import { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks/useResponsive';
|
||||||
|
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||||
|
|
||||||
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'Search'>;
|
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'Search'>;
|
||||||
|
|
||||||
const TABS = ['帖子', '用户'];
|
const TABS = ['帖子', '用户'];
|
||||||
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
type SearchType = 'posts' | 'users';
|
type SearchType = 'posts' | 'users';
|
||||||
|
|
||||||
@@ -74,17 +77,47 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
|
|||||||
|
|
||||||
const [searchText, setSearchText] = useState('');
|
const [searchText, setSearchText] = useState('');
|
||||||
const [activeIndex, setActiveIndex] = useState(0);
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
const [searchResults, setSearchResults] = useState<{
|
|
||||||
posts: Post[];
|
|
||||||
users: User[];
|
|
||||||
}>({
|
|
||||||
posts: [],
|
|
||||||
users: [],
|
|
||||||
});
|
|
||||||
const [hasSearched, setHasSearched] = useState(false);
|
const [hasSearched, setHasSearched] = useState(false);
|
||||||
// 保存当前搜索关键词,用于Tab切换时重新搜索
|
// 保存当前搜索关键词,用于Tab切换时重新搜索
|
||||||
const [currentKeyword, setCurrentKeyword] = useState('');
|
const [currentKeyword, setCurrentKeyword] = useState('');
|
||||||
|
|
||||||
|
// 使用游标分页进行帖子搜索
|
||||||
|
const {
|
||||||
|
items: searchResults,
|
||||||
|
isLoading,
|
||||||
|
isRefreshing,
|
||||||
|
hasMore,
|
||||||
|
loadMore,
|
||||||
|
refresh,
|
||||||
|
reset,
|
||||||
|
} = useCursorPagination<Post, { query: string }>(
|
||||||
|
async ({ cursor, pageSize, extraParams }) => {
|
||||||
|
if (!extraParams?.query) {
|
||||||
|
return { items: [], next_cursor: null, prev_cursor: null, has_more: false };
|
||||||
|
}
|
||||||
|
const response = await postService.searchPostsCursor(extraParams.query, {
|
||||||
|
cursor,
|
||||||
|
page_size: pageSize,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
{ pageSize: DEFAULT_PAGE_SIZE },
|
||||||
|
{ query: '' }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 用户搜索结果(保持原有分页方式)
|
||||||
|
const [userResults, setUserResults] = useState<User[]>([]);
|
||||||
|
const [userLoading, setUserLoading] = useState(false);
|
||||||
|
|
||||||
|
// 当搜索词变化时重置
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentKeyword) {
|
||||||
|
refresh();
|
||||||
|
} else {
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
}, [currentKeyword]);
|
||||||
|
|
||||||
// 执行搜索 - 根据当前Tab执行对应类型的搜索
|
// 执行搜索 - 根据当前Tab执行对应类型的搜索
|
||||||
const performSearch = useCallback(async (keyword: string) => {
|
const performSearch = useCallback(async (keyword: string) => {
|
||||||
if (!keyword.trim()) return;
|
if (!keyword.trim()) return;
|
||||||
@@ -101,26 +134,20 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
|
|||||||
const searchType = getSearchType();
|
const searchType = getSearchType();
|
||||||
|
|
||||||
if (searchType === 'posts') {
|
if (searchType === 'posts') {
|
||||||
// 搜索帖子
|
// 帖子搜索由 useCursorPagination 处理,这里只需触发刷新
|
||||||
const postsResponse = await postService.searchPosts(trimmedKeyword, 1, 20);
|
refresh();
|
||||||
setSearchResults(prev => ({
|
|
||||||
...prev,
|
|
||||||
posts: postsResponse.list
|
|
||||||
}));
|
|
||||||
} else if (searchType === 'users') {
|
} else if (searchType === 'users') {
|
||||||
// 搜索用户
|
// 用户搜索保持原有方式
|
||||||
|
setUserLoading(true);
|
||||||
const usersResponse = await authService.searchUsers(trimmedKeyword, 1, 20);
|
const usersResponse = await authService.searchUsers(trimmedKeyword, 1, 20);
|
||||||
setSearchResults(prev => ({
|
setUserResults(usersResponse.list || []);
|
||||||
...prev,
|
|
||||||
users: usersResponse.list
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('搜索失败:', error);
|
console.error('搜索失败:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
setHasSearched(true);
|
setHasSearched(true);
|
||||||
}, [addSearchHistory, activeIndex]);
|
}, [addSearchHistory, activeIndex, refresh]);
|
||||||
|
|
||||||
// 处理搜索提交
|
// 处理搜索提交
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
@@ -159,9 +186,9 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
|
|||||||
|
|
||||||
// 渲染帖子搜索结果(使用响应式网格)
|
// 渲染帖子搜索结果(使用响应式网格)
|
||||||
const renderPostResults = () => {
|
const renderPostResults = () => {
|
||||||
const posts = searchResults.posts;
|
const posts = searchResults;
|
||||||
|
|
||||||
if (posts.length === 0) {
|
if (posts.length === 0 && !isLoading) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="未找到相关帖子"
|
title="未找到相关帖子"
|
||||||
@@ -177,6 +204,14 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
|
|||||||
<ScrollView
|
<ScrollView
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={{ paddingBottom: responsivePadding }}
|
contentContainerStyle={{ paddingBottom: responsivePadding }}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isRefreshing}
|
||||||
|
onRefresh={refresh}
|
||||||
|
colors={[colors.primary.main]}
|
||||||
|
tintColor={colors.primary.main}
|
||||||
|
/>
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<ResponsiveGrid
|
<ResponsiveGrid
|
||||||
columns={{ xs: 1, sm: 2, md: 2, lg: 3, xl: 3, '2xl': 4, '3xl': 4, '4xl': 5 }}
|
columns={{ xs: 1, sm: 2, md: 2, lg: 3, xl: 3, '2xl': 4, '3xl': 4, '4xl': 5 }}
|
||||||
@@ -197,6 +232,11 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ResponsiveGrid>
|
</ResponsiveGrid>
|
||||||
|
{isLoading && (
|
||||||
|
<View style={styles.loadingMore}>
|
||||||
|
<Loading size="sm" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -220,15 +260,26 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
|
|||||||
keyExtractor={item => item.id}
|
keyExtractor={item => item.id}
|
||||||
contentContainerStyle={{ paddingBottom: responsivePadding }}
|
contentContainerStyle={{ paddingBottom: responsivePadding }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isRefreshing}
|
||||||
|
onRefresh={refresh}
|
||||||
|
colors={[colors.primary.main]}
|
||||||
|
tintColor={colors.primary.main}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onEndReached={loadMore}
|
||||||
|
onEndReachedThreshold={0.3}
|
||||||
|
ListFooterComponent={isLoading ? <Loading size="sm" /> : null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 渲染用户搜索结果
|
// 渲染用户搜索结果
|
||||||
const renderUserResults = () => {
|
const renderUserResults = () => {
|
||||||
const users = searchResults.users;
|
const users = userResults;
|
||||||
|
|
||||||
if (users.length === 0) {
|
if (users.length === 0 && !userLoading) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="未找到相关用户"
|
title="未找到相关用户"
|
||||||
@@ -290,6 +341,11 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
))}
|
))}
|
||||||
</ResponsiveGrid>
|
</ResponsiveGrid>
|
||||||
|
{userLoading && (
|
||||||
|
<View style={styles.loadingMore}>
|
||||||
|
<Loading size="sm" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -329,6 +385,7 @@ export const SearchScreen: React.FC<SearchScreenProps> = ({ onBack, navigation:
|
|||||||
keyExtractor={item => item.id}
|
keyExtractor={item => item.id}
|
||||||
contentContainerStyle={{ paddingVertical: responsiveGap }}
|
contentContainerStyle={{ paddingVertical: responsiveGap }}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
|
ListFooterComponent={userLoading ? <Loading size="sm" /> : null}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -501,20 +558,20 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
tabWrapper: {
|
tabWrapper: {
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.paper,
|
||||||
paddingTop: spacing.xs,
|
borderBottomWidth: 1,
|
||||||
paddingBottom: spacing.xs,
|
borderBottomColor: `${colors.divider}50`,
|
||||||
},
|
},
|
||||||
suggestionsContainer: {
|
suggestionsContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
section: {
|
section: {
|
||||||
marginTop: spacing.lg,
|
marginTop: spacing.md,
|
||||||
},
|
},
|
||||||
sectionHeader: {
|
sectionHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
marginBottom: spacing.md,
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.sm,
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
@@ -528,17 +585,32 @@ const styles = StyleSheet.create({
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.paper,
|
||||||
borderRadius: borderRadius.md,
|
borderRadius: borderRadius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.divider,
|
||||||
},
|
},
|
||||||
tagText: {
|
tagText: {
|
||||||
marginLeft: spacing.xs,
|
marginLeft: spacing.xs,
|
||||||
},
|
},
|
||||||
// 移动端用户列表样式
|
userCard: {
|
||||||
|
backgroundColor: colors.background.paper,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
userCardInfo: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: spacing.md,
|
||||||
|
},
|
||||||
|
userCardName: {
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text.primary,
|
||||||
|
},
|
||||||
userItem: {
|
userItem: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.paper,
|
||||||
borderRadius: borderRadius.md,
|
borderRadius: borderRadius.lg,
|
||||||
},
|
},
|
||||||
userInfo: {
|
userInfo: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -549,28 +621,15 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.text.primary,
|
color: colors.text.primary,
|
||||||
},
|
},
|
||||||
followingBadge: {
|
followingBadge: {
|
||||||
width: 24,
|
width: 20,
|
||||||
height: 24,
|
height: 20,
|
||||||
borderRadius: 12,
|
borderRadius: 10,
|
||||||
backgroundColor: colors.primary.light + '30',
|
backgroundColor: `${colors.primary.main}14`,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
// 桌面端用户卡片样式
|
loadingMore: {
|
||||||
userCard: {
|
paddingVertical: spacing.md,
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
borderRadius: borderRadius.lg,
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
|
||||||
minHeight: 180,
|
|
||||||
},
|
|
||||||
userCardInfo: {
|
|
||||||
alignItems: 'center',
|
|
||||||
marginTop: spacing.md,
|
|
||||||
},
|
|
||||||
userCardName: {
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.text.primary,
|
|
||||||
marginBottom: spacing.xs,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,15 +19,15 @@
|
|||||||
* - ChatInput.tsx: 输入框组件
|
* - ChatInput.tsx: 输入框组件
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
import React, { useState, useEffect, useMemo, useCallback, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
FlatList,
|
|
||||||
TouchableWithoutFeedback,
|
TouchableWithoutFeedback,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
KeyboardAvoidingView,
|
KeyboardAvoidingView,
|
||||||
Platform,
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import { FlashList } from "@shopify/flash-list";
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
@@ -224,6 +224,7 @@ export const ChatScreen: React.FC = () => {
|
|||||||
formatTime={formatTime}
|
formatTime={formatTime}
|
||||||
shouldShowTime={shouldShowTime}
|
shouldShowTime={shouldShowTime}
|
||||||
onImagePress={handleImagePress}
|
onImagePress={handleImagePress}
|
||||||
|
onReply={handleReplyMessage}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -268,25 +269,23 @@ export const ChatScreen: React.FC = () => {
|
|||||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<FlatList
|
<FlashList
|
||||||
ref={flatListRef}
|
ref={flatListRef}
|
||||||
data={messages}
|
data={messages}
|
||||||
renderItem={renderMessage}
|
renderItem={renderMessage}
|
||||||
keyExtractor={item => String(item.id)}
|
keyExtractor={(item: any) => String(item.id)}
|
||||||
contentContainerStyle={listContentStyle}
|
contentContainerStyle={listContentStyle as any}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
refreshing={loadingMore}
|
refreshing={loadingMore}
|
||||||
onRefresh={hasMoreHistory ? loadMoreHistory : undefined}
|
onRefresh={hasMoreHistory ? loadMoreHistory : undefined}
|
||||||
progressViewOffset={0}
|
// @ts-ignore FlashList 类型定义问题:estimatedItemSize 是必需属性但类型定义缺失
|
||||||
onContentSizeChange={handleMessageListContentSizeChange}
|
estimatedItemSize={80}
|
||||||
|
maintainVisibleContentPosition={{
|
||||||
|
minIndexForVisible: 0,
|
||||||
|
autoscrollToTopThreshold: undefined,
|
||||||
|
} as any}
|
||||||
onScroll={handleMessageListScroll}
|
onScroll={handleMessageListScroll}
|
||||||
scrollEventThrottle={16}
|
|
||||||
// 优化渲染性能
|
|
||||||
initialNumToRender={15}
|
|
||||||
maxToRenderPerBatch={10}
|
|
||||||
windowSize={10}
|
|
||||||
removeClippedSubviews={true}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* GroupMembersScreen 群成员管理界面
|
* GroupMembersScreen 群成员管理界面
|
||||||
* 显示群成员列表,支持管理员管理成员
|
* 显示群成员列表,支持管理员管理成员
|
||||||
* 支持响应式网格布局
|
* 支持响应式网格布局
|
||||||
|
* 使用游标分页
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
@@ -27,6 +28,7 @@ import { groupService } from '../../services/groupService';
|
|||||||
import { groupManager } from '../../stores/groupManager';
|
import { groupManager } from '../../stores/groupManager';
|
||||||
import { Avatar, Text, Button, Loading, EmptyState, Divider, ResponsiveContainer } from '../../components/common';
|
import { Avatar, Text, Button, Loading, EmptyState, Divider, ResponsiveContainer } from '../../components/common';
|
||||||
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
|
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
|
||||||
|
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||||
import {
|
import {
|
||||||
GroupMemberResponse,
|
GroupMemberResponse,
|
||||||
GroupRole,
|
GroupRole,
|
||||||
@@ -68,12 +70,34 @@ const GroupMembersScreen: React.FC = () => {
|
|||||||
return GRID_CONFIG.mobile;
|
return GRID_CONFIG.mobile;
|
||||||
}, [width]);
|
}, [width]);
|
||||||
|
|
||||||
// 成员列表状态
|
// 使用游标分页 Hook 管理成员列表
|
||||||
const [members, setMembers] = useState<GroupMemberResponse[]>([]);
|
const {
|
||||||
|
items: members,
|
||||||
|
isLoading,
|
||||||
|
isRefreshing,
|
||||||
|
hasMore,
|
||||||
|
loadMore,
|
||||||
|
refresh,
|
||||||
|
error,
|
||||||
|
} = useCursorPagination(
|
||||||
|
async ({ cursor, pageSize }) => {
|
||||||
|
return await groupService.getGroupMembersCursor(groupId, {
|
||||||
|
cursor,
|
||||||
|
page_size: pageSize,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ pageSize: 50 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 本地成员状态(用于乐观更新)
|
||||||
|
const [localMembers, setLocalMembers] = useState<GroupMemberResponse[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const [page, setPage] = useState(1);
|
// 同步分页数据到本地状态
|
||||||
const [hasMore, setHasMore] = useState(true);
|
useEffect(() => {
|
||||||
|
setLocalMembers(members);
|
||||||
|
setLoading(false);
|
||||||
|
}, [members]);
|
||||||
|
|
||||||
// 当前用户的成员信息
|
// 当前用户的成员信息
|
||||||
const [currentMember, setCurrentMember] = useState<GroupMemberResponse | null>(null);
|
const [currentMember, setCurrentMember] = useState<GroupMemberResponse | null>(null);
|
||||||
@@ -91,65 +115,31 @@ const GroupMembersScreen: React.FC = () => {
|
|||||||
const isOwner = currentMember?.role === 'owner';
|
const isOwner = currentMember?.role === 'owner';
|
||||||
const isAdmin = currentMember?.role === 'admin' || isOwner;
|
const isAdmin = currentMember?.role === 'admin' || isOwner;
|
||||||
|
|
||||||
// 加载成员列表
|
// 查找当前用户的成员信息
|
||||||
const loadMembers = useCallback(
|
useEffect(() => {
|
||||||
async (
|
const myMember = localMembers.find(m => m.user_id === currentUser?.id);
|
||||||
pageNum: number = 1,
|
if (myMember) {
|
||||||
refresh: boolean = false,
|
setCurrentMember(myMember);
|
||||||
forceRefresh: boolean = false
|
|
||||||
) => {
|
|
||||||
if (!hasMore && !refresh) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await groupManager.getMembers(groupId, pageNum, 50, forceRefresh);
|
|
||||||
|
|
||||||
if (refresh) {
|
|
||||||
setMembers(response.list);
|
|
||||||
setPage(1);
|
|
||||||
} else {
|
|
||||||
setMembers(prev => [...prev, ...response.list]);
|
|
||||||
}
|
|
||||||
|
|
||||||
setHasMore(response.list.length === 50);
|
|
||||||
|
|
||||||
// 查找当前用户的成员信息
|
|
||||||
const myMember = response.list.find(m => m.user_id === currentUser?.id);
|
|
||||||
if (myMember) {
|
|
||||||
setCurrentMember(myMember);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载成员列表失败:', error);
|
|
||||||
}
|
}
|
||||||
|
}, [localMembers, currentUser]);
|
||||||
|
|
||||||
|
// 下拉刷新
|
||||||
|
const onRefresh = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
await refresh();
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
}, [refresh]);
|
||||||
}, [groupId, currentUser, hasMore]);
|
|
||||||
|
|
||||||
// 初始加载
|
// 初始加载
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadMembers(1, true, true);
|
refresh();
|
||||||
}, [groupId]);
|
}, [groupId]);
|
||||||
|
|
||||||
// 下拉刷新
|
|
||||||
const onRefresh = useCallback(() => {
|
|
||||||
setRefreshing(true);
|
|
||||||
setHasMore(true);
|
|
||||||
loadMembers(1, true, true);
|
|
||||||
}, [loadMembers]);
|
|
||||||
|
|
||||||
// 加载更多
|
|
||||||
const loadMore = useCallback(() => {
|
|
||||||
if (!loading && hasMore) {
|
|
||||||
const nextPage = page + 1;
|
|
||||||
setPage(nextPage);
|
|
||||||
loadMembers(nextPage);
|
|
||||||
}
|
|
||||||
}, [loading, hasMore, page, loadMembers]);
|
|
||||||
|
|
||||||
// 按角色分组
|
// 按角色分组
|
||||||
const groupMembers = useCallback((): MemberGroup[] => {
|
const groupMembers = useCallback((): MemberGroup[] => {
|
||||||
const owners = members.filter(m => m.role === 'owner');
|
const owners = localMembers.filter(m => m.role === 'owner');
|
||||||
const admins = members.filter(m => m.role === 'admin');
|
const admins = localMembers.filter(m => m.role === 'admin');
|
||||||
const normalMembers = members.filter(m => m.role === 'member');
|
const normalMembers = localMembers.filter(m => m.role === 'member');
|
||||||
|
|
||||||
const groups: MemberGroup[] = [];
|
const groups: MemberGroup[] = [];
|
||||||
|
|
||||||
@@ -164,7 +154,7 @@ const GroupMembersScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return groups;
|
return groups;
|
||||||
}, [members]);
|
}, [localMembers]);
|
||||||
|
|
||||||
// 打开操作菜单
|
// 打开操作菜单
|
||||||
const openActionModal = (member: GroupMemberResponse) => {
|
const openActionModal = (member: GroupMemberResponse) => {
|
||||||
@@ -203,7 +193,7 @@ const GroupMembersScreen: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 更新本地数据
|
// 更新本地数据
|
||||||
setMembers(prev => prev.map(m => {
|
setLocalMembers(prev => prev.map(m => {
|
||||||
if (m.user_id === selectedMember.user_id) {
|
if (m.user_id === selectedMember.user_id) {
|
||||||
return { ...m, role: newRole };
|
return { ...m, role: newRole };
|
||||||
}
|
}
|
||||||
@@ -245,14 +235,14 @@ const GroupMembersScreen: React.FC = () => {
|
|||||||
await groupService.muteMember(groupId, selectedMember.user_id, newMuted ? -1 : 0);
|
await groupService.muteMember(groupId, selectedMember.user_id, newMuted ? -1 : 0);
|
||||||
|
|
||||||
// 更新本地数据
|
// 更新本地数据
|
||||||
setMembers(prev => prev.map(m => {
|
setLocalMembers(prev => prev.map(m => {
|
||||||
if (m.user_id === selectedMember.user_id) {
|
if (m.user_id === selectedMember.user_id) {
|
||||||
return { ...m, muted: newMuted };
|
return { ...m, muted: newMuted };
|
||||||
}
|
}
|
||||||
return m;
|
return m;
|
||||||
}));
|
}));
|
||||||
// 强制刷新远端状态,避免命中旧缓存导致解禁后仍显示禁言
|
// 强制刷新远端状态,避免命中旧缓存导致解禁后仍显示禁言
|
||||||
await loadMembers(1, true, true);
|
await refresh();
|
||||||
|
|
||||||
setActionModalVisible(false);
|
setActionModalVisible(false);
|
||||||
Alert.alert('成功', `已${actionText}`);
|
Alert.alert('成功', `已${actionText}`);
|
||||||
@@ -286,7 +276,7 @@ const GroupMembersScreen: React.FC = () => {
|
|||||||
await groupService.removeMember(groupId, selectedMember.user_id);
|
await groupService.removeMember(groupId, selectedMember.user_id);
|
||||||
|
|
||||||
// 更新本地数据
|
// 更新本地数据
|
||||||
setMembers(prev => prev.filter(m => m.user_id !== selectedMember.user_id));
|
setLocalMembers(prev => prev.filter(m => m.user_id !== selectedMember.user_id));
|
||||||
|
|
||||||
setActionModalVisible(false);
|
setActionModalVisible(false);
|
||||||
Alert.alert('成功', '已移除成员');
|
Alert.alert('成功', '已移除成员');
|
||||||
@@ -320,7 +310,7 @@ const GroupMembersScreen: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 更新本地数据
|
// 更新本地数据
|
||||||
setMembers(prev => prev.map(m => {
|
setLocalMembers(prev => prev.map(m => {
|
||||||
if (m.user_id === selectedMember.user_id) {
|
if (m.user_id === selectedMember.user_id) {
|
||||||
return { ...m, nickname: newNickname.trim() };
|
return { ...m, nickname: newNickname.trim() };
|
||||||
}
|
}
|
||||||
@@ -422,7 +412,7 @@ const GroupMembersScreen: React.FC = () => {
|
|||||||
|
|
||||||
// 渲染空状态
|
// 渲染空状态
|
||||||
const renderEmpty = () => {
|
const renderEmpty = () => {
|
||||||
if (loading) return <Loading />;
|
if (loading || isLoading) return <Loading />;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@@ -589,7 +579,7 @@ const GroupMembersScreen: React.FC = () => {
|
|||||||
keyExtractor={(item) => item.title}
|
keyExtractor={(item) => item.title}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={refreshing}
|
refreshing={isRefreshing}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
colors={[colors.primary.main]}
|
colors={[colors.primary.main]}
|
||||||
tintColor={colors.primary.main}
|
tintColor={colors.primary.main}
|
||||||
@@ -598,6 +588,23 @@ const GroupMembersScreen: React.FC = () => {
|
|||||||
onEndReached={loadMore}
|
onEndReached={loadMore}
|
||||||
onEndReachedThreshold={0.3}
|
onEndReachedThreshold={0.3}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
|
ListFooterComponent={
|
||||||
|
isLoading ? (
|
||||||
|
<View style={styles.loadingFooter}>
|
||||||
|
<Loading size="sm" />
|
||||||
|
</View>
|
||||||
|
) : hasMore ? (
|
||||||
|
<TouchableOpacity style={styles.loadMoreBtn} onPress={loadMore}>
|
||||||
|
<Text variant="caption" color={colors.primary.main}>
|
||||||
|
加载更多成员
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : localMembers.length > 0 ? (
|
||||||
|
<Text variant="caption" color={colors.text.hint} style={styles.noMoreText}>
|
||||||
|
没有更多成员了
|
||||||
|
</Text>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
renderItem={({ item: group }) => (
|
renderItem={({ item: group }) => (
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
{renderSectionHeader(group.title, group.data.length)}
|
{renderSectionHeader(group.title, group.data.length)}
|
||||||
@@ -724,6 +731,19 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
marginHorizontal: spacing.xs,
|
marginHorizontal: spacing.xs,
|
||||||
},
|
},
|
||||||
|
// 分页加载样式
|
||||||
|
loadingFooter: {
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loadMoreBtn: {
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
noMoreText: {
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default GroupMembersScreen;
|
export default GroupMembersScreen;
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
import { View, StyleSheet, TextInput, TouchableOpacity, Alert, ActivityIndicator, Clipboard } from 'react-native';
|
import {
|
||||||
|
View,
|
||||||
|
StyleSheet,
|
||||||
|
TextInput,
|
||||||
|
TouchableOpacity,
|
||||||
|
Alert,
|
||||||
|
ActivityIndicator,
|
||||||
|
Clipboard,
|
||||||
|
FlatList,
|
||||||
|
RefreshControl,
|
||||||
|
} from 'react-native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
@@ -11,6 +21,8 @@ import { groupService } from '../../services/groupService';
|
|||||||
import { groupManager } from '../../stores/groupManager';
|
import { groupManager } from '../../stores/groupManager';
|
||||||
import { RootStackParamList } from '../../navigation/types';
|
import { RootStackParamList } from '../../navigation/types';
|
||||||
import { GroupResponse, JoinType } from '../../types/dto';
|
import { GroupResponse, JoinType } from '../../types/dto';
|
||||||
|
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||||
|
import { EmptyState } from '../../components/common';
|
||||||
|
|
||||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
@@ -18,10 +30,29 @@ const JoinGroupScreen: React.FC = () => {
|
|||||||
const navigation = useNavigation<NavigationProp>();
|
const navigation = useNavigation<NavigationProp>();
|
||||||
const [keyword, setKeyword] = useState('');
|
const [keyword, setKeyword] = useState('');
|
||||||
const [searching, setSearching] = useState(false);
|
const [searching, setSearching] = useState(false);
|
||||||
const [joining, setJoining] = useState(false);
|
const [joiningGroupId, setJoiningGroupId] = useState<string | null>(null);
|
||||||
const [group, setGroup] = useState<GroupResponse | null>(null);
|
const [searchedGroup, setSearchedGroup] = useState<GroupResponse | null>(null);
|
||||||
const [searched, setSearched] = useState(false);
|
const [searched, setSearched] = useState(false);
|
||||||
|
|
||||||
|
// 使用游标分页 Hook 管理群组列表
|
||||||
|
const {
|
||||||
|
items: groups,
|
||||||
|
isLoading,
|
||||||
|
isRefreshing,
|
||||||
|
hasMore,
|
||||||
|
loadMore,
|
||||||
|
refresh,
|
||||||
|
error,
|
||||||
|
} = useCursorPagination(
|
||||||
|
async ({ cursor, pageSize }) => {
|
||||||
|
return await groupService.getGroupsCursor({
|
||||||
|
cursor,
|
||||||
|
page_size: pageSize,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ pageSize: 20 }
|
||||||
|
);
|
||||||
|
|
||||||
const getJoinTypeText = (joinType: JoinType) => {
|
const getJoinTypeText = (joinType: JoinType) => {
|
||||||
if (joinType === 0) return '允许加入';
|
if (joinType === 0) return '允许加入';
|
||||||
if (joinType === 1) return '需要审批';
|
if (joinType === 1) return '需要审批';
|
||||||
@@ -39,9 +70,9 @@ const JoinGroupScreen: React.FC = () => {
|
|||||||
setSearched(true);
|
setSearched(true);
|
||||||
try {
|
try {
|
||||||
const result = await groupManager.getGroup(trimmed, true);
|
const result = await groupManager.getGroup(trimmed, true);
|
||||||
setGroup(result);
|
setSearchedGroup(result);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setGroup(null);
|
setSearchedGroup(null);
|
||||||
const message = error?.response?.data?.message || error?.message || '';
|
const message = error?.response?.data?.message || error?.message || '';
|
||||||
if (String(message).includes('不存在') || error?.response?.status === 404) {
|
if (String(message).includes('不存在') || error?.response?.status === 404) {
|
||||||
Alert.alert('未找到', '未搜索到该群聊,请确认群ID是否正确');
|
Alert.alert('未找到', '未搜索到该群聊,请确认群ID是否正确');
|
||||||
@@ -53,9 +84,9 @@ const JoinGroupScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleJoin = async () => {
|
const handleJoin = async (group: GroupResponse) => {
|
||||||
if (!group?.id) return;
|
if (!group?.id) return;
|
||||||
setJoining(true);
|
setJoiningGroupId(String(group.id));
|
||||||
try {
|
try {
|
||||||
await groupService.joinGroup(group.id);
|
await groupService.joinGroup(group.id);
|
||||||
Alert.alert('成功', '操作已提交', [
|
Alert.alert('成功', '操作已提交', [
|
||||||
@@ -71,13 +102,12 @@ const JoinGroupScreen: React.FC = () => {
|
|||||||
'操作失败,请稍后重试';
|
'操作失败,请稍后重试';
|
||||||
Alert.alert('操作失败', String(message));
|
Alert.alert('操作失败', String(message));
|
||||||
} finally {
|
} finally {
|
||||||
setJoining(false);
|
setJoiningGroupId(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCopyGroupId = () => {
|
const handleCopyGroupId = (groupId: string | number) => {
|
||||||
if (!group?.id) return;
|
Clipboard.setString(String(groupId));
|
||||||
Clipboard.setString(String(group.id));
|
|
||||||
Alert.alert('已复制', '群号已复制到剪贴板');
|
Alert.alert('已复制', '群号已复制到剪贴板');
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -87,6 +117,99 @@ const JoinGroupScreen: React.FC = () => {
|
|||||||
return `${raw.slice(0, 6)}...${raw.slice(-4)}`;
|
return `${raw.slice(0, 6)}...${raw.slice(-4)}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const renderGroupItem = ({ item: group }: { item: GroupResponse }) => {
|
||||||
|
const isJoining = joiningGroupId === String(group.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.groupCard}>
|
||||||
|
<View style={styles.groupHeader}>
|
||||||
|
<Avatar source={group.avatar} size={52} name={group.name} />
|
||||||
|
<View style={styles.groupMeta}>
|
||||||
|
<Text variant="body" style={styles.groupName} numberOfLines={1}>
|
||||||
|
{group.name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{!!group.description && (
|
||||||
|
<Text variant="caption" color={colors.text.secondary} style={styles.groupDesc}>
|
||||||
|
{group.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View style={styles.groupInfoRow}>
|
||||||
|
<Text variant="caption" color={colors.text.secondary}>
|
||||||
|
成员 {group.member_count}/{group.max_members}
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption" color={colors.text.secondary}>
|
||||||
|
{getJoinTypeText(group.join_type)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.groupNoRow}>
|
||||||
|
<Text variant="caption" color={colors.text.secondary}>
|
||||||
|
群号:{formatGroupNo(group.id)}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity style={styles.copyBtn} onPress={() => handleCopyGroupId(group.id)}>
|
||||||
|
<MaterialCommunityIcons name="content-copy" size={14} color={colors.primary.main} />
|
||||||
|
<Text variant="caption" color={colors.primary.main} style={styles.copyBtnText}>
|
||||||
|
复制
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.submitBtn, isJoining && styles.submitBtnDisabled]}
|
||||||
|
onPress={() => handleJoin(group)}
|
||||||
|
disabled={isJoining}
|
||||||
|
>
|
||||||
|
{isJoining ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MaterialCommunityIcons name="send-outline" size={18} color="#fff" />
|
||||||
|
<Text variant="body" color="#fff" style={styles.submitText}>
|
||||||
|
申请入群
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEmptyList = () => {
|
||||||
|
if (isLoading) return null;
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title="暂无群组"
|
||||||
|
description="还没有可加入的群组"
|
||||||
|
icon="account-group-outline"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderSearchResult = () => {
|
||||||
|
if (!searched) return null;
|
||||||
|
|
||||||
|
if (searchedGroup) {
|
||||||
|
return (
|
||||||
|
<View style={styles.searchResultSection}>
|
||||||
|
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||||||
|
搜索结果
|
||||||
|
</Text>
|
||||||
|
{renderGroupItem({ item: searchedGroup })}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!searching) {
|
||||||
|
return (
|
||||||
|
<Text variant="caption" color={colors.text.secondary} style={styles.emptyText}>
|
||||||
|
暂无搜索结果,请检查群ID后重试
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.heroCard}>
|
<View style={styles.heroCard}>
|
||||||
@@ -100,23 +223,25 @@ const JoinGroupScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.formCard}>
|
<View style={styles.formCard}>
|
||||||
<Text variant="caption" color={colors.text.secondary} style={styles.label}>搜索群聊(群ID)</Text>
|
<Text variant="caption" color={colors.text.secondary} style={styles.label}>
|
||||||
|
搜索群聊(群ID)
|
||||||
|
</Text>
|
||||||
<View style={styles.searchRow}>
|
<View style={styles.searchRow}>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={keyword}
|
value={keyword}
|
||||||
onChangeText={setKeyword}
|
onChangeText={setKeyword}
|
||||||
placeholder="例如:7391234567890"
|
placeholder="例如:7391234567890"
|
||||||
placeholderTextColor={colors.text.hint}
|
placeholderTextColor={colors.text.hint}
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
editable={!searching && !joining}
|
editable={!searching && !joiningGroupId}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
cursorColor={colors.primary.main}
|
cursorColor={colors.primary.main}
|
||||||
selectionColor={`${colors.primary.main}40`}
|
selectionColor={`${colors.primary.main}40`}
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.searchBtn, (!keyword.trim() || searching || joining) && styles.submitBtnDisabled]}
|
style={[styles.searchBtn, (!keyword.trim() || searching || !!joiningGroupId) && styles.submitBtnDisabled]}
|
||||||
onPress={handleSearch}
|
onPress={handleSearch}
|
||||||
disabled={!keyword.trim() || searching || joining}
|
disabled={!keyword.trim() || searching || !!joiningGroupId}
|
||||||
>
|
>
|
||||||
{searching ? (
|
{searching ? (
|
||||||
<ActivityIndicator color="#fff" />
|
<ActivityIndicator color="#fff" />
|
||||||
@@ -126,58 +251,49 @@ const JoinGroupScreen: React.FC = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{group && (
|
{/* 搜索结果 */}
|
||||||
<View style={styles.groupCard}>
|
{renderSearchResult()}
|
||||||
<View style={styles.groupHeader}>
|
|
||||||
<Avatar source={group.avatar} size={52} name={group.name} />
|
|
||||||
<View style={styles.groupMeta}>
|
|
||||||
<Text variant="body" style={styles.groupName} numberOfLines={1}>{group.name}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
{!!group.description && (
|
|
||||||
<Text variant="caption" color={colors.text.secondary} style={styles.groupDesc}>
|
|
||||||
{group.description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
<View style={styles.groupInfoRow}>
|
|
||||||
<Text variant="caption" color={colors.text.secondary}>
|
|
||||||
成员 {group.member_count}/{group.max_members}
|
|
||||||
</Text>
|
|
||||||
<Text variant="caption" color={colors.text.secondary}>
|
|
||||||
{getJoinTypeText(group.join_type)}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.groupNoRow}>
|
|
||||||
<Text variant="caption" color={colors.text.secondary}>
|
|
||||||
群号:{formatGroupNo(group.id)}
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity style={styles.copyBtn} onPress={handleCopyGroupId}>
|
|
||||||
<MaterialCommunityIcons name="content-copy" size={14} color={colors.primary.main} />
|
|
||||||
<Text variant="caption" color={colors.primary.main} style={styles.copyBtnText}>复制</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.submitBtn, joining && styles.submitBtnDisabled]}
|
|
||||||
onPress={handleJoin}
|
|
||||||
disabled={joining}
|
|
||||||
>
|
|
||||||
{joining ? (
|
|
||||||
<ActivityIndicator color="#fff" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<MaterialCommunityIcons name="send-outline" size={18} color="#fff" />
|
|
||||||
<Text variant="body" color="#fff" style={styles.submitText}>申请入群</Text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{searched && !group && !searching && (
|
{/* 群组列表 */}
|
||||||
<Text variant="caption" color={colors.text.secondary} style={styles.emptyText}>
|
<View style={styles.listSection}>
|
||||||
暂无搜索结果,请检查群ID后重试
|
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||||||
|
推荐群组
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
<FlatList
|
||||||
|
data={groups}
|
||||||
|
renderItem={renderGroupItem}
|
||||||
|
keyExtractor={(item) => String(item.id)}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isRefreshing}
|
||||||
|
onRefresh={refresh}
|
||||||
|
colors={[colors.primary.main]}
|
||||||
|
tintColor={colors.primary.main}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onEndReached={loadMore}
|
||||||
|
onEndReachedThreshold={0.3}
|
||||||
|
ListEmptyComponent={renderEmptyList}
|
||||||
|
ListFooterComponent={
|
||||||
|
isLoading ? (
|
||||||
|
<View style={styles.loadingFooter}>
|
||||||
|
<ActivityIndicator color={colors.primary.main} />
|
||||||
|
</View>
|
||||||
|
) : hasMore ? (
|
||||||
|
<TouchableOpacity style={styles.loadMoreBtn} onPress={loadMore}>
|
||||||
|
<Text variant="caption" color={colors.primary.main}>
|
||||||
|
加载更多
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : groups.length > 0 ? (
|
||||||
|
<Text variant="caption" color={colors.text.hint} style={styles.noMoreText}>
|
||||||
|
没有更多群组了
|
||||||
|
</Text>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
@@ -214,6 +330,7 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.paper,
|
||||||
borderRadius: borderRadius.lg,
|
borderRadius: borderRadius.lg,
|
||||||
padding: spacing.lg,
|
padding: spacing.lg,
|
||||||
|
flex: 1,
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
marginBottom: spacing.xs,
|
marginBottom: spacing.xs,
|
||||||
@@ -242,12 +359,23 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
marginLeft: spacing.sm,
|
marginLeft: spacing.sm,
|
||||||
},
|
},
|
||||||
|
searchResultSection: {
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
listSection: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
groupCard: {
|
groupCard: {
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: colors.divider,
|
borderColor: colors.divider,
|
||||||
borderRadius: borderRadius.md,
|
borderRadius: borderRadius.md,
|
||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
backgroundColor: colors.background.default,
|
backgroundColor: colors.background.default,
|
||||||
|
marginBottom: spacing.md,
|
||||||
},
|
},
|
||||||
groupHeader: {
|
groupHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -259,6 +387,7 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
groupName: {
|
groupName: {
|
||||||
marginBottom: spacing.xs,
|
marginBottom: spacing.xs,
|
||||||
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
groupDesc: {
|
groupDesc: {
|
||||||
marginTop: spacing.sm,
|
marginTop: spacing.sm,
|
||||||
@@ -303,6 +432,19 @@ const styles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
emptyText: {
|
emptyText: {
|
||||||
marginTop: spacing.sm,
|
marginTop: spacing.sm,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
loadingFooter: {
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
loadMoreBtn: {
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
noMoreText: {
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingVertical: spacing.md,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -30,18 +30,21 @@ import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
|||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { colors, spacing, fontSizes, shadows, borderRadius } from '../../theme';
|
import { colors, spacing, fontSizes, shadows, borderRadius } from '../../theme';
|
||||||
import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments, extractTextFromSegmentsAsync, MessageSegment } from '../../types/dto';
|
import { ConversationResponse, UserDTO, MessageResponse, extractTextFromSegments, extractTextFromSegmentsAsync, MessageSegment } from '../../types/dto';
|
||||||
import { authService } from '../../services';
|
import { authService, messageService } from '../../services';
|
||||||
import { useUserStore, useAuthStore } from '../../stores';
|
import { useUserStore, useAuthStore } from '../../stores';
|
||||||
// 【新架构】使用MessageManager hooks
|
// 【新架构】使用MessageManager hooks
|
||||||
import { useMessageList, messageManager, useMessageListRefresh, useCreateConversation } from '../../stores';
|
import { messageManager, useMessageListRefresh, useCreateConversation, useUnreadCount, useMarkAsRead } from '../../stores';
|
||||||
import { Avatar, Text, EmptyState, ResponsiveContainer } from '../../components/common';
|
import { Avatar, Text, EmptyState, ResponsiveContainer } from '../../components/common';
|
||||||
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
|
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
|
||||||
|
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||||
import { RootStackParamList, MessageStackParamList } from '../../navigation/types';
|
import { RootStackParamList, MessageStackParamList } from '../../navigation/types';
|
||||||
import { getUserCache } from '../../services/database';
|
import { getUserCache } from '../../services/database';
|
||||||
// 导入 EmbeddedChat 组件用于桌面端双栏布局
|
// 导入 EmbeddedChat 组件用于桌面端双栏布局
|
||||||
import { EmbeddedChat } from './components/EmbeddedChat';
|
import { EmbeddedChat } from './components/EmbeddedChat';
|
||||||
// 导入 NotificationsScreen 用于在内部显示
|
// 导入 NotificationsScreen 用于在内部显示
|
||||||
import { NotificationsScreen } from './NotificationsScreen';
|
import { NotificationsScreen } from './NotificationsScreen';
|
||||||
|
// 导入扫码组件
|
||||||
|
import { QRCodeScanner } from '../../components/business/QRCodeScanner';
|
||||||
|
|
||||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||||
type MessageNavProp = NativeStackNavigationProp<MessageStackParamList>;
|
type MessageNavProp = NativeStackNavigationProp<MessageStackParamList>;
|
||||||
@@ -158,22 +161,43 @@ export const MessageListScreen: React.FC = () => {
|
|||||||
const { isDesktop, isTablet, width } = useResponsive();
|
const { isDesktop, isTablet, width } = useResponsive();
|
||||||
const isWideScreen = useBreakpointGTE('lg');
|
const isWideScreen = useBreakpointGTE('lg');
|
||||||
|
|
||||||
// 【新架构】使用MessageManager的hook获取数据
|
// 【游标分页】使用 useCursorPagination hook 获取会话列表
|
||||||
const {
|
const {
|
||||||
conversations,
|
items: conversations,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
isRefreshing,
|
||||||
|
hasMore,
|
||||||
|
loadMore,
|
||||||
refresh,
|
refresh,
|
||||||
totalUnreadCount,
|
error: paginationError,
|
||||||
systemUnreadCount,
|
} = useCursorPagination(
|
||||||
markAllAsRead,
|
async ({ cursor, pageSize }) => {
|
||||||
isMarking,
|
return await messageService.getConversationsCursor({
|
||||||
} = useMessageList();
|
cursor,
|
||||||
|
page_size: pageSize,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ pageSize: 20 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 使用 MessageManager 获取未读数和系统通知数
|
||||||
|
const { totalUnreadCount, systemUnreadCount } = useUnreadCount();
|
||||||
|
const { markAllAsRead, isMarking } = useMarkAsRead(null);
|
||||||
|
|
||||||
// 【新架构】使用MessageManager的hook创建会话
|
// 【新架构】使用MessageManager的hook创建会话
|
||||||
const { createConversation } = useCreateConversation();
|
const { createConversation } = useCreateConversation();
|
||||||
|
|
||||||
// 本地刷新状态(仅用于下拉刷新的UI显示)
|
// 本地刷新状态(仅用于下拉刷新的UI显示)
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
|
||||||
|
// 上拉加载更多
|
||||||
|
const onEndReached = useCallback(async () => {
|
||||||
|
if (isLoading || loadingMore || !hasMore) return;
|
||||||
|
setLoadingMore(true);
|
||||||
|
await loadMore();
|
||||||
|
setLoadingMore(false);
|
||||||
|
}, [isLoading, loadingMore, hasMore, loadMore]);
|
||||||
|
|
||||||
// 搜索相关状态
|
// 搜索相关状态
|
||||||
const [isSearchMode, setIsSearchMode] = useState(false);
|
const [isSearchMode, setIsSearchMode] = useState(false);
|
||||||
@@ -182,6 +206,7 @@ export const MessageListScreen: React.FC = () => {
|
|||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
const [activeSearchTab, setActiveSearchTab] = useState<'chat' | 'user'>('chat');
|
const [activeSearchTab, setActiveSearchTab] = useState<'chat' | 'user'>('chat');
|
||||||
const [actionMenuVisible, setActionMenuVisible] = useState(false);
|
const [actionMenuVisible, setActionMenuVisible] = useState(false);
|
||||||
|
const [scannerVisible, setScannerVisible] = useState(false);
|
||||||
|
|
||||||
// 系统通知显示状态 - 用于在移动端显示通知页面
|
// 系统通知显示状态 - 用于在移动端显示通知页面
|
||||||
const [showNotifications, setShowNotifications] = useState(false);
|
const [showNotifications, setShowNotifications] = useState(false);
|
||||||
@@ -356,7 +381,7 @@ export const MessageListScreen: React.FC = () => {
|
|||||||
setActionMenuVisible(true);
|
setActionMenuVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const runMenuAction = (action: 'join' | 'create' | 'readAll') => {
|
const runMenuAction = (action: 'join' | 'create' | 'readAll' | 'scanQRCode') => {
|
||||||
setActionMenuVisible(false);
|
setActionMenuVisible(false);
|
||||||
if (action === 'join') {
|
if (action === 'join') {
|
||||||
handleJoinGroup();
|
handleJoinGroup();
|
||||||
@@ -366,6 +391,10 @@ export const MessageListScreen: React.FC = () => {
|
|||||||
handleCreateGroup();
|
handleCreateGroup();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (action === 'scanQRCode') {
|
||||||
|
setScannerVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
handleMarkAllRead();
|
handleMarkAllRead();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -878,7 +907,7 @@ export const MessageListScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading && conversations.length === 0 ? (
|
||||||
<View style={styles.loadingContainer}>
|
<View style={styles.loadingContainer}>
|
||||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||||
</View>
|
</View>
|
||||||
@@ -894,6 +923,8 @@ export const MessageListScreen: React.FC = () => {
|
|||||||
]}
|
]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
ListEmptyComponent={renderEmpty}
|
ListEmptyComponent={renderEmpty}
|
||||||
|
onEndReached={onEndReached}
|
||||||
|
onEndReachedThreshold={0.5}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
refreshing={refreshing}
|
refreshing={refreshing}
|
||||||
@@ -902,6 +933,14 @@ export const MessageListScreen: React.FC = () => {
|
|||||||
tintColor={colors.primary.main}
|
tintColor={colors.primary.main}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
ListFooterComponent={
|
||||||
|
loadingMore ? (
|
||||||
|
<View style={styles.loadingMoreContainer}>
|
||||||
|
<ActivityIndicator size="small" color={colors.primary.main} />
|
||||||
|
<Text style={styles.loadingMoreText}>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -911,6 +950,10 @@ export const MessageListScreen: React.FC = () => {
|
|||||||
<Modal visible={actionMenuVisible} transparent animationType="fade" onRequestClose={() => setActionMenuVisible(false)}>
|
<Modal visible={actionMenuVisible} transparent animationType="fade" onRequestClose={() => setActionMenuVisible(false)}>
|
||||||
<Pressable style={styles.actionMenuBackdrop} onPress={() => setActionMenuVisible(false)}>
|
<Pressable style={styles.actionMenuBackdrop} onPress={() => setActionMenuVisible(false)}>
|
||||||
<View style={styles.actionMenu}>
|
<View style={styles.actionMenu}>
|
||||||
|
<TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('scanQRCode')}>
|
||||||
|
<MaterialCommunityIcons name="qrcode-scan" size={21} color="#444" />
|
||||||
|
<Text style={styles.actionMenuText}>扫码登录</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
<TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('join')}>
|
<TouchableOpacity style={styles.actionMenuItem} onPress={() => runMenuAction('join')}>
|
||||||
<MaterialCommunityIcons name="account-plus-outline" size={21} color="#444" />
|
<MaterialCommunityIcons name="account-plus-outline" size={21} color="#444" />
|
||||||
<Text style={styles.actionMenuText}>加入群聊</Text>
|
<Text style={styles.actionMenuText}>加入群聊</Text>
|
||||||
@@ -980,6 +1023,7 @@ export const MessageListScreen: React.FC = () => {
|
|||||||
renderConversationList()
|
renderConversationList()
|
||||||
)}
|
)}
|
||||||
{renderActionMenu()}
|
{renderActionMenu()}
|
||||||
|
<QRCodeScanner visible={scannerVisible} onClose={() => setScannerVisible(false)} />
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1249,6 +1293,17 @@ const styles = StyleSheet.create({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: spacing.xl * 2,
|
paddingVertical: spacing.xl * 2,
|
||||||
},
|
},
|
||||||
|
loadingMoreContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
},
|
||||||
|
loadingMoreText: {
|
||||||
|
marginLeft: spacing.sm,
|
||||||
|
fontSize: 14,
|
||||||
|
color: '#999',
|
||||||
|
},
|
||||||
searchModeContainer: {
|
searchModeContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#FAFAFA',
|
backgroundColor: '#FAFAFA',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* 通知页 NotificationsScreen
|
* 通知页 NotificationsScreen
|
||||||
* 胡萝卜BBS - 系统消息列表
|
* 胡萝卜BBS - 系统消息列表
|
||||||
* 使用新的系统消息API
|
* 【游标分页】使用 messageService.getSystemMessagesCursor
|
||||||
* 支持响应式布局
|
* 支持响应式布局
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -26,6 +26,7 @@ import { messageService } from '../../services/messageService';
|
|||||||
import { commentService } from '../../services/commentService';
|
import { commentService } from '../../services/commentService';
|
||||||
import { SystemMessageItem } from '../../components/business';
|
import { SystemMessageItem } from '../../components/business';
|
||||||
import { Text, EmptyState, ResponsiveContainer } from '../../components/common';
|
import { Text, EmptyState, ResponsiveContainer } from '../../components/common';
|
||||||
|
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||||
import { RootStackParamList } from '../../navigation/types';
|
import { RootStackParamList } from '../../navigation/types';
|
||||||
import { useMessageManagerSystemUnreadCount, useUserStore } from '../../stores';
|
import { useMessageManagerSystemUnreadCount, useUserStore } from '../../stores';
|
||||||
|
|
||||||
@@ -70,14 +71,32 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
|||||||
// Web端使用更大的容器宽度
|
// Web端使用更大的容器宽度
|
||||||
const containerMaxWidth = isDesktop ? 1200 : isTablet ? 1000 : 900;
|
const containerMaxWidth = isDesktop ? 1200 : isTablet ? 1000 : 900;
|
||||||
|
|
||||||
const [messages, setMessages] = useState<SystemMessageResponse[]>([]);
|
|
||||||
const [activeType, setActiveType] = useState('all');
|
const [activeType, setActiveType] = useState('all');
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [hasMore, setHasMore] = useState(true);
|
|
||||||
const [loadingMore, setLoadingMore] = useState(false);
|
|
||||||
const [unreadCount, setUnreadCount] = useState(0);
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
|
||||||
|
// 【游标分页】使用 useCursorPagination hook 获取系统消息列表
|
||||||
|
const {
|
||||||
|
items: messages,
|
||||||
|
isLoading,
|
||||||
|
isRefreshing,
|
||||||
|
hasMore,
|
||||||
|
loadMore,
|
||||||
|
refresh,
|
||||||
|
error: paginationError,
|
||||||
|
} = useCursorPagination(
|
||||||
|
async ({ cursor, pageSize }) => {
|
||||||
|
return await messageService.getSystemMessagesCursor({
|
||||||
|
cursor,
|
||||||
|
page_size: pageSize,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ pageSize: 20 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 本地刷新状态(仅用于下拉刷新的UI显示)
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const [loadingMore, setLoadingMore] = useState(false);
|
||||||
|
|
||||||
// 同一 flag 只要有人审批过,就将待处理消息同步展示为已处理状态
|
// 同一 flag 只要有人审批过,就将待处理消息同步展示为已处理状态
|
||||||
const displayMessages = useMemo(() => {
|
const displayMessages = useMemo(() => {
|
||||||
const reviewedByFlag = new Map<string, SystemMessageResponse>();
|
const reviewedByFlag = new Map<string, SystemMessageResponse>();
|
||||||
@@ -116,21 +135,6 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
|||||||
});
|
});
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
// 获取系统消息数据
|
|
||||||
const fetchMessages = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const response = await messageService.getSystemMessages(50, 1);
|
|
||||||
// 添加防御性检查,确保 messages 数组存在
|
|
||||||
setMessages(response.messages || []);
|
|
||||||
setHasMore(response.has_more ?? false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取系统消息失败:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 获取未读数
|
// 获取未读数
|
||||||
const fetchUnreadCount = useCallback(async () => {
|
const fetchUnreadCount = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -145,27 +149,25 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
|||||||
const handleMarkAllRead = useCallback(async () => {
|
const handleMarkAllRead = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
await messageService.markAllSystemMessagesRead();
|
await messageService.markAllSystemMessagesRead();
|
||||||
setMessages(prev => prev.map(m => ({ ...m, is_read: true })));
|
// 刷新消息列表
|
||||||
|
await refresh();
|
||||||
setUnreadCount(0);
|
setUnreadCount(0);
|
||||||
setSystemUnreadCount(0);
|
setSystemUnreadCount(0);
|
||||||
// 同步更新全局 TabBar 红点
|
// 同步更新全局 TabBar 红点
|
||||||
fetchMessageUnreadCount();
|
fetchMessageUnreadCount();
|
||||||
// 刷新消息列表
|
|
||||||
fetchMessages();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('一键已读失败:', error);
|
console.error('一键已读失败:', error);
|
||||||
}
|
}
|
||||||
}, [fetchMessages, fetchMessageUnreadCount, setSystemUnreadCount]);
|
}, [refresh, fetchMessageUnreadCount, setSystemUnreadCount]);
|
||||||
|
|
||||||
// 页面加载和获得焦点时刷新,并自动标记所有消息为已读
|
// 页面加载和获得焦点时刷新,并自动标记所有消息为已读
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isFocused) {
|
if (isFocused) {
|
||||||
fetchMessages();
|
|
||||||
fetchUnreadCount();
|
fetchUnreadCount();
|
||||||
// 进入界面自动标记所有消息为已读
|
// 进入界面自动标记所有消息为已读
|
||||||
handleMarkAllRead();
|
handleMarkAllRead();
|
||||||
}
|
}
|
||||||
}, [isFocused, fetchMessages, fetchUnreadCount, handleMarkAllRead]);
|
}, [isFocused, fetchUnreadCount, handleMarkAllRead]);
|
||||||
|
|
||||||
// 屏幕失去焦点时,如果有 onBack 回调则调用它(用于内嵌模式)
|
// 屏幕失去焦点时,如果有 onBack 回调则调用它(用于内嵌模式)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -190,29 +192,17 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
|||||||
// 下拉刷新
|
// 下拉刷新
|
||||||
const onRefresh = useCallback(async () => {
|
const onRefresh = useCallback(async () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
await Promise.all([fetchMessages(), fetchUnreadCount()]);
|
await Promise.all([refresh(), fetchUnreadCount()]);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}, [fetchMessages, fetchUnreadCount]);
|
}, [refresh, fetchUnreadCount]);
|
||||||
|
|
||||||
// 加载更多
|
// 加载更多
|
||||||
const loadMore = useCallback(async () => {
|
const onEndReached = useCallback(async () => {
|
||||||
if (loadingMore || !hasMore || messages.length === 0) return;
|
if (isLoading || loadingMore || !hasMore) return;
|
||||||
|
setLoadingMore(true);
|
||||||
try {
|
await loadMore();
|
||||||
setLoadingMore(true);
|
setLoadingMore(false);
|
||||||
// 使用时间戳或seq作为游标分页(后端使用page分页)
|
}, [isLoading, loadingMore, hasMore, loadMore]);
|
||||||
const nextPage = Math.floor((messages.length / 20)) + 1;
|
|
||||||
const response = await messageService.getSystemMessages(20, nextPage);
|
|
||||||
// 添加防御性检查
|
|
||||||
const newMessages = response.messages || [];
|
|
||||||
setMessages(prev => [...prev, ...newMessages]);
|
|
||||||
setHasMore(response.has_more ?? false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('加载更多失败:', error);
|
|
||||||
} finally {
|
|
||||||
setLoadingMore(false);
|
|
||||||
}
|
|
||||||
}, [loadingMore, hasMore, messages]);
|
|
||||||
|
|
||||||
// 标记单条消息已读并处理导航
|
// 标记单条消息已读并处理导航
|
||||||
const extractPostIdFromActionUrl = (actionUrl?: string): string | null => {
|
const extractPostIdFromActionUrl = (actionUrl?: string): string | null => {
|
||||||
@@ -262,9 +252,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
|||||||
const messageId = String(message.id);
|
const messageId = String(message.id);
|
||||||
const wasUnread = message.is_read !== true;
|
const wasUnread = message.is_read !== true;
|
||||||
await messageService.markSystemMessageRead(messageId);
|
await messageService.markSystemMessageRead(messageId);
|
||||||
setMessages(prev =>
|
// 【游标分页】不再直接修改 messages 状态,而是通过刷新获取最新数据
|
||||||
prev.map(m => (String(m.id) === messageId ? { ...m, is_read: true } : m))
|
|
||||||
);
|
|
||||||
if (wasUnread) {
|
if (wasUnread) {
|
||||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||||
decrementSystemUnreadCount(1);
|
decrementSystemUnreadCount(1);
|
||||||
@@ -431,7 +419,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 消息列表 */}
|
{/* 消息列表 */}
|
||||||
{loading ? (
|
{isLoading && messages.length === 0 ? (
|
||||||
<View style={styles.loadingContainer}>
|
<View style={styles.loadingContainer}>
|
||||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||||
</View>
|
</View>
|
||||||
@@ -444,7 +432,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
ListEmptyComponent={renderEmpty}
|
ListEmptyComponent={renderEmpty}
|
||||||
ListFooterComponent={renderFooter}
|
ListFooterComponent={renderFooter}
|
||||||
onEndReached={loadMore}
|
onEndReached={onEndReached}
|
||||||
onEndReachedThreshold={0.3}
|
onEndReachedThreshold={0.3}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
@@ -505,7 +493,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 消息列表 */}
|
{/* 消息列表 */}
|
||||||
{loading ? (
|
{isLoading && messages.length === 0 ? (
|
||||||
<View style={styles.loadingContainer}>
|
<View style={styles.loadingContainer}>
|
||||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||||
</View>
|
</View>
|
||||||
@@ -518,7 +506,7 @@ export const NotificationsScreen: React.FC<{ onBack?: () => void }> = ({ onBack
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
ListEmptyComponent={renderEmpty}
|
ListEmptyComponent={renderEmpty}
|
||||||
ListFooterComponent={renderFooter}
|
ListFooterComponent={renderFooter}
|
||||||
onEndReached={loadMore}
|
onEndReached={onEndReached}
|
||||||
onEndReachedThreshold={0.3}
|
onEndReachedThreshold={0.3}
|
||||||
refreshControl={
|
refreshControl={
|
||||||
<RefreshControl
|
<RefreshControl
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
* 支持响应式布局(宽屏下优化显示)
|
* 支持响应式布局(宽屏下优化显示)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useRef, useMemo } from 'react';
|
import React, { useRef, useMemo, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
@@ -22,6 +22,14 @@ import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive
|
|||||||
import { MessageBubbleProps, SenderInfo, MenuPosition, GroupMessage } from './types';
|
import { MessageBubbleProps, SenderInfo, MenuPosition, GroupMessage } from './types';
|
||||||
import { MessageSegmentsRenderer } from './SegmentRenderer';
|
import { MessageSegmentsRenderer } from './SegmentRenderer';
|
||||||
import { MessageSegment, ImageSegmentData, extractTextFromSegments } from '../../../../types/dto';
|
import { MessageSegment, ImageSegmentData, extractTextFromSegments } from '../../../../types/dto';
|
||||||
|
import { SwipeableMessageBubble } from './SwipeableMessageBubble';
|
||||||
|
import {
|
||||||
|
getBubbleBorderRadius,
|
||||||
|
getMessageGroupPosition,
|
||||||
|
shouldShowSenderInfo,
|
||||||
|
bubbleColors,
|
||||||
|
bubbleStyles,
|
||||||
|
} from './bubbleStyles';
|
||||||
|
|
||||||
// 获取屏幕宽度
|
// 获取屏幕宽度
|
||||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||||
@@ -50,6 +58,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
formatTime,
|
formatTime,
|
||||||
shouldShowTime,
|
shouldShowTime,
|
||||||
onImagePress,
|
onImagePress,
|
||||||
|
onReply,
|
||||||
}) => {
|
}) => {
|
||||||
const bubbleRef = useRef<View>(null);
|
const bubbleRef = useRef<View>(null);
|
||||||
const pressPositionRef = useRef({ x: 0, y: 0 });
|
const pressPositionRef = useRef({ x: 0, y: 0 });
|
||||||
@@ -343,6 +352,13 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理滑动回复
|
||||||
|
const handleSwipeReply = useCallback(() => {
|
||||||
|
if (onReply) {
|
||||||
|
onReply(message);
|
||||||
|
}
|
||||||
|
}, [onReply, message]);
|
||||||
|
|
||||||
// 系统通知消息渲染
|
// 系统通知消息渲染
|
||||||
if (isSystemNotice) {
|
if (isSystemNotice) {
|
||||||
return (
|
return (
|
||||||
@@ -361,7 +377,8 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// 消息内容渲染
|
||||||
|
const messageContent = (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPressIn={handlePressIn}
|
onPressIn={handlePressIn}
|
||||||
onLongPress={handleLongPress}
|
onLongPress={handleLongPress}
|
||||||
@@ -444,6 +461,17 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 使用 SwipeableMessageBubble 包裹消息内容
|
||||||
|
return (
|
||||||
|
<SwipeableMessageBubble
|
||||||
|
isMe={isMe}
|
||||||
|
onReply={handleSwipeReply}
|
||||||
|
enabled={!!onReply}
|
||||||
|
>
|
||||||
|
{messageContent}
|
||||||
|
</SwipeableMessageBubble>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Segment 消息的特殊样式 - 自适应宽度
|
// Segment 消息的特殊样式 - 自适应宽度
|
||||||
@@ -460,4 +488,16 @@ const segmentStyles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default MessageBubble;
|
// 使用 React.memo 优化渲染性能
|
||||||
|
export default React.memo(MessageBubble, (prevProps, nextProps) => {
|
||||||
|
// 自定义比较逻辑:只比较关键属性
|
||||||
|
return (
|
||||||
|
prevProps.message.id === nextProps.message.id &&
|
||||||
|
prevProps.message.status === nextProps.message.status &&
|
||||||
|
prevProps.selectedMessageId === nextProps.selectedMessageId &&
|
||||||
|
prevProps.currentUserId === nextProps.currentUserId &&
|
||||||
|
prevProps.isGroupChat === nextProps.isGroupChat &&
|
||||||
|
prevProps.otherUserLastReadSeq === nextProps.otherUserLastReadSeq &&
|
||||||
|
prevProps.index === nextProps.index
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -290,6 +290,7 @@ const ImageSegment: React.FC<{
|
|||||||
contentFit="cover"
|
contentFit="cover"
|
||||||
cachePolicy="disk"
|
cachePolicy="disk"
|
||||||
priority="normal"
|
priority="normal"
|
||||||
|
transition={200}
|
||||||
onError={() => {
|
onError={() => {
|
||||||
setLoadError(true);
|
setLoadError(true);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* 可滑动的消息气泡容器
|
||||||
|
* 实现滑动回复手势功能 - 严格单向滑动,无抖动
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useRef, useCallback } from 'react';
|
||||||
|
import { View, StyleSheet, Animated } from 'react-native';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { PanGestureHandler, State } from 'react-native-gesture-handler';
|
||||||
|
import * as Haptics from 'expo-haptics';
|
||||||
|
import { colors } from '../../../../theme';
|
||||||
|
|
||||||
|
// 滑动阈值
|
||||||
|
const SWIPE_THRESHOLD = 30;
|
||||||
|
const MAX_SWIPE_DISTANCE = 50;
|
||||||
|
|
||||||
|
interface SwipeableMessageBubbleProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
isMe: boolean;
|
||||||
|
onReply: () => void;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SwipeableMessageBubble: React.FC<SwipeableMessageBubbleProps> = ({
|
||||||
|
children,
|
||||||
|
isMe,
|
||||||
|
onReply,
|
||||||
|
enabled = true,
|
||||||
|
}) => {
|
||||||
|
const translateX = useRef(new Animated.Value(0)).current;
|
||||||
|
const hasTriggeredRef = useRef(false);
|
||||||
|
|
||||||
|
// 处理手势事件 - 使用原生驱动,不干预值
|
||||||
|
const onGestureEvent = Animated.event(
|
||||||
|
[{ nativeEvent: { translationX: translateX } }],
|
||||||
|
{ useNativeDriver: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
// 处理手势状态变化
|
||||||
|
const onHandlerStateChange = useCallback((event: any) => {
|
||||||
|
const { nativeEvent } = event;
|
||||||
|
|
||||||
|
if (nativeEvent.state === State.END) {
|
||||||
|
const translationX = nativeEvent.translationX;
|
||||||
|
|
||||||
|
// 只允许特定方向的滑动
|
||||||
|
const shouldTrigger = isMe
|
||||||
|
? translationX < -SWIPE_THRESHOLD // 自己消息只能向左滑
|
||||||
|
: translationX > SWIPE_THRESHOLD; // 对方消息只能向右滑
|
||||||
|
|
||||||
|
if (shouldTrigger && !hasTriggeredRef.current) {
|
||||||
|
hasTriggeredRef.current = true;
|
||||||
|
|
||||||
|
// 触觉反馈
|
||||||
|
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||||
|
|
||||||
|
// 立即触发回复
|
||||||
|
onReply();
|
||||||
|
|
||||||
|
// 重置标记
|
||||||
|
setTimeout(() => {
|
||||||
|
hasTriggeredRef.current = false;
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 立即回到原位
|
||||||
|
translateX.setValue(0);
|
||||||
|
}
|
||||||
|
}, [isMe, onReply]);
|
||||||
|
|
||||||
|
// 计算回复图标的透明度 - 只在允许的方向显示
|
||||||
|
const iconOpacity = translateX.interpolate({
|
||||||
|
inputRange: isMe
|
||||||
|
? [-MAX_SWIPE_DISTANCE, -SWIPE_THRESHOLD, 0, 10]
|
||||||
|
: [-10, 0, SWIPE_THRESHOLD, MAX_SWIPE_DISTANCE],
|
||||||
|
outputRange: isMe ? [1, 0.8, 0, 0] : [0, 0, 0.8, 1],
|
||||||
|
extrapolate: 'clamp',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算消息的位移 - 限制在允许的方向
|
||||||
|
const clampedTranslateX = translateX.interpolate({
|
||||||
|
inputRange: isMe
|
||||||
|
? [-MAX_SWIPE_DISTANCE - 10, -MAX_SWIPE_DISTANCE, 0, 10]
|
||||||
|
: [-10, 0, MAX_SWIPE_DISTANCE, MAX_SWIPE_DISTANCE + 10],
|
||||||
|
outputRange: isMe
|
||||||
|
? [-MAX_SWIPE_DISTANCE, -MAX_SWIPE_DISTANCE, 0, 0]
|
||||||
|
: [0, 0, MAX_SWIPE_DISTANCE, MAX_SWIPE_DISTANCE],
|
||||||
|
extrapolate: 'clamp',
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果禁用手势,直接返回子元素
|
||||||
|
if (!enabled) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* 回复图标 - 在消息后面 */}
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.replyIconContainer,
|
||||||
|
isMe ? styles.replyIconLeft : styles.replyIconRight,
|
||||||
|
{
|
||||||
|
opacity: iconOpacity,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="reply"
|
||||||
|
size={20}
|
||||||
|
color={colors.primary.main}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* 可滑动的消息内容 */}
|
||||||
|
<PanGestureHandler
|
||||||
|
onGestureEvent={onGestureEvent}
|
||||||
|
onHandlerStateChange={onHandlerStateChange}
|
||||||
|
activeOffsetX={isMe ? [-10, 9999] : [-9999, 10]}
|
||||||
|
failOffsetY={[-20, 20]}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={{
|
||||||
|
transform: [{ translateX: clampedTranslateX }],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Animated.View>
|
||||||
|
</PanGestureHandler>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
position: 'relative',
|
||||||
|
},
|
||||||
|
replyIconContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '50%',
|
||||||
|
marginTop: -15,
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
borderRadius: 15,
|
||||||
|
backgroundColor: 'rgba(255, 107, 53, 0.1)',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
replyIconLeft: {
|
||||||
|
left: 8,
|
||||||
|
},
|
||||||
|
replyIconRight: {
|
||||||
|
right: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SwipeableMessageBubble;
|
||||||
270
src/screens/message/components/ChatScreen/bubbleStyles.ts
Normal file
270
src/screens/message/components/ChatScreen/bubbleStyles.ts
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
/**
|
||||||
|
* 消息气泡样式工具
|
||||||
|
* 参考 Element X 设计,实现动态圆角和现代化气泡样式
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { StyleSheet, ViewStyle, TextStyle } from 'react-native';
|
||||||
|
import { colors, spacing } from '../../../../theme';
|
||||||
|
|
||||||
|
// 气泡圆角大小(参考 Element X: 12pt)
|
||||||
|
export const BUBBLE_RADIUS = 16;
|
||||||
|
|
||||||
|
// 气泡颜色方案(参考 Element X 的语义化颜色)
|
||||||
|
export const bubbleColors = {
|
||||||
|
// 自己发送的消息
|
||||||
|
outgoing: {
|
||||||
|
background: '#E8E8E8', // Element X 风格:灰色系
|
||||||
|
text: '#1A1A1A',
|
||||||
|
},
|
||||||
|
// 收到的消息
|
||||||
|
incoming: {
|
||||||
|
background: '#FFFFFF',
|
||||||
|
text: '#1A1A1A',
|
||||||
|
},
|
||||||
|
// 被回复的消息高亮
|
||||||
|
replyHighlight: {
|
||||||
|
background: 'rgba(255, 107, 53, 0.08)',
|
||||||
|
borderLeft: '#FF6B35',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 消息在组中的位置类型
|
||||||
|
export type MessageGroupPosition = 'single' | 'first' | 'middle' | 'last';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据消息在组中的位置获取圆角样式
|
||||||
|
* 参考 Element X 的动态圆角逻辑
|
||||||
|
*/
|
||||||
|
export const getBubbleBorderRadius = (
|
||||||
|
isMe: boolean,
|
||||||
|
position: MessageGroupPosition
|
||||||
|
): ViewStyle => {
|
||||||
|
const radius = BUBBLE_RADIUS;
|
||||||
|
|
||||||
|
if (isMe) {
|
||||||
|
// 自己发送的消息
|
||||||
|
switch (position) {
|
||||||
|
case 'single':
|
||||||
|
return {
|
||||||
|
borderTopLeftRadius: radius,
|
||||||
|
borderTopRightRadius: radius,
|
||||||
|
borderBottomLeftRadius: radius,
|
||||||
|
borderBottomRightRadius: 4, // 尖角在右下
|
||||||
|
};
|
||||||
|
case 'first':
|
||||||
|
return {
|
||||||
|
borderTopLeftRadius: radius,
|
||||||
|
borderTopRightRadius: radius,
|
||||||
|
borderBottomLeftRadius: radius,
|
||||||
|
borderBottomRightRadius: 4,
|
||||||
|
};
|
||||||
|
case 'middle':
|
||||||
|
return {
|
||||||
|
borderTopLeftRadius: radius,
|
||||||
|
borderTopRightRadius: 4,
|
||||||
|
borderBottomLeftRadius: radius,
|
||||||
|
borderBottomRightRadius: 4,
|
||||||
|
};
|
||||||
|
case 'last':
|
||||||
|
return {
|
||||||
|
borderTopLeftRadius: radius,
|
||||||
|
borderTopRightRadius: 4,
|
||||||
|
borderBottomLeftRadius: radius,
|
||||||
|
borderBottomRightRadius: radius,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 收到的消息
|
||||||
|
switch (position) {
|
||||||
|
case 'single':
|
||||||
|
return {
|
||||||
|
borderTopLeftRadius: 4, // 尖角在左上
|
||||||
|
borderTopRightRadius: radius,
|
||||||
|
borderBottomLeftRadius: radius,
|
||||||
|
borderBottomRightRadius: radius,
|
||||||
|
};
|
||||||
|
case 'first':
|
||||||
|
return {
|
||||||
|
borderTopLeftRadius: 4,
|
||||||
|
borderTopRightRadius: radius,
|
||||||
|
borderBottomLeftRadius: 4,
|
||||||
|
borderBottomRightRadius: radius,
|
||||||
|
};
|
||||||
|
case 'middle':
|
||||||
|
return {
|
||||||
|
borderTopLeftRadius: 4,
|
||||||
|
borderTopRightRadius: radius,
|
||||||
|
borderBottomLeftRadius: 4,
|
||||||
|
borderBottomRightRadius: radius,
|
||||||
|
};
|
||||||
|
case 'last':
|
||||||
|
return {
|
||||||
|
borderTopLeftRadius: radius,
|
||||||
|
borderTopRightRadius: radius,
|
||||||
|
borderBottomLeftRadius: radius,
|
||||||
|
borderBottomRightRadius: radius,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断消息在组中的位置
|
||||||
|
*/
|
||||||
|
export const getMessageGroupPosition = (
|
||||||
|
index: number,
|
||||||
|
messages: Array<{ sender_id: string }>,
|
||||||
|
currentUserId: string
|
||||||
|
): MessageGroupPosition => {
|
||||||
|
const currentMessage = messages[index];
|
||||||
|
if (!currentMessage) return 'single';
|
||||||
|
|
||||||
|
const prevMessage = index > 0 ? messages[index - 1] : null;
|
||||||
|
const nextMessage = index < messages.length - 1 ? messages[index + 1] : null;
|
||||||
|
|
||||||
|
const isSameSenderAsPrev = prevMessage && prevMessage.sender_id === currentMessage.sender_id;
|
||||||
|
const isSameSenderAsNext = nextMessage && nextMessage.sender_id === currentMessage.sender_id;
|
||||||
|
|
||||||
|
if (!isSameSenderAsPrev && !isSameSenderAsNext) {
|
||||||
|
return 'single';
|
||||||
|
} else if (!isSameSenderAsPrev && isSameSenderAsNext) {
|
||||||
|
return 'first';
|
||||||
|
} else if (isSameSenderAsPrev && isSameSenderAsNext) {
|
||||||
|
return 'middle';
|
||||||
|
} else {
|
||||||
|
return 'last';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否显示发送者信息(只在组的第一条消息显示)
|
||||||
|
*/
|
||||||
|
export const shouldShowSenderInfo = (
|
||||||
|
index: number,
|
||||||
|
messages: Array<{ sender_id: string }>,
|
||||||
|
): boolean => {
|
||||||
|
if (index === 0) return true;
|
||||||
|
|
||||||
|
const currentMessage = messages[index];
|
||||||
|
const prevMessage = messages[index - 1];
|
||||||
|
|
||||||
|
return prevMessage.sender_id !== currentMessage.sender_id;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取气泡基础样式
|
||||||
|
*/
|
||||||
|
export const getBubbleBaseStyle = (isMe: boolean): ViewStyle => ({
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm + 4,
|
||||||
|
minWidth: 60,
|
||||||
|
maxWidth: '75%',
|
||||||
|
backgroundColor: isMe ? bubbleColors.outgoing.background : bubbleColors.incoming.background,
|
||||||
|
// Element X 风格的微妙阴影
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取文本样式
|
||||||
|
*/
|
||||||
|
export const getBubbleTextStyle = (isMe: boolean): TextStyle => ({
|
||||||
|
color: isMe ? bubbleColors.outgoing.text : bubbleColors.incoming.text,
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 23,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
fontWeight: '400',
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 消息气泡样式集合
|
||||||
|
*/
|
||||||
|
export const bubbleStyles = StyleSheet.create({
|
||||||
|
// 基础气泡
|
||||||
|
bubble: {
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm + 4,
|
||||||
|
minWidth: 60,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 自己发送的消息
|
||||||
|
outgoing: {
|
||||||
|
backgroundColor: bubbleColors.outgoing.background,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 收到的消息
|
||||||
|
incoming: {
|
||||||
|
backgroundColor: bubbleColors.incoming.background,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 文本样式
|
||||||
|
text: {
|
||||||
|
fontSize: 16,
|
||||||
|
lineHeight: 23,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
fontWeight: '400',
|
||||||
|
},
|
||||||
|
|
||||||
|
// 阴影样式(Element X 风格)
|
||||||
|
shadow: {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 2,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 长按反馈阴影
|
||||||
|
longPressShadow: {
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 3 },
|
||||||
|
shadowOpacity: 0.12,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 被回复消息的高亮边框
|
||||||
|
replyHighlight: {
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: colors.primary.main,
|
||||||
|
backgroundColor: bubbleColors.replyHighlight.background,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 已撤回消息
|
||||||
|
recalled: {
|
||||||
|
backgroundColor: '#F5F7FA',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#E8E8E8',
|
||||||
|
borderStyle: 'dashed',
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 系统通知
|
||||||
|
systemNotice: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
systemNoticeText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: '#8E8E93',
|
||||||
|
backgroundColor: 'rgba(142, 142, 147, 0.12)',
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default {
|
||||||
|
BUBBLE_RADIUS,
|
||||||
|
bubbleColors,
|
||||||
|
getBubbleBorderRadius,
|
||||||
|
getMessageGroupPosition,
|
||||||
|
shouldShowSenderInfo,
|
||||||
|
getBubbleBaseStyle,
|
||||||
|
getBubbleTextStyle,
|
||||||
|
bubbleStyles,
|
||||||
|
};
|
||||||
@@ -20,6 +20,7 @@ export { ChatHeader } from './ChatHeader';
|
|||||||
export { MessageBubble } from './MessageBubble';
|
export { MessageBubble } from './MessageBubble';
|
||||||
export { ChatInput } from './ChatInput';
|
export { ChatInput } from './ChatInput';
|
||||||
export { GroupInfoPanel } from './GroupInfoPanel';
|
export { GroupInfoPanel } from './GroupInfoPanel';
|
||||||
|
export { SwipeableMessageBubble } from './SwipeableMessageBubble';
|
||||||
|
|
||||||
// Segment 渲染组件
|
// Segment 渲染组件
|
||||||
export {
|
export {
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ export interface MessageBubbleProps {
|
|||||||
shouldShowTime: (index: number) => boolean;
|
shouldShowTime: (index: number) => boolean;
|
||||||
// 图片点击回调
|
// 图片点击回调
|
||||||
onImagePress?: (images: { id: string; url: string; thumbnail_url?: string; width?: number; height?: number }[], index: number) => void;
|
onImagePress?: (images: { id: string; url: string; thumbnail_url?: string; width?: number; height?: number }[], index: number) => void;
|
||||||
|
// 滑动回复回调
|
||||||
|
onReply?: (message: GroupMessage) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 输入框 Props
|
// 输入框 Props
|
||||||
@@ -180,6 +182,14 @@ export interface ReplyPreviewProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 可滑动消息气泡 Props
|
||||||
|
export interface SwipeableMessageBubbleProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
isMe: boolean;
|
||||||
|
onReply: () => void;
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// ChatScreen 状态接口
|
// ChatScreen 状态接口
|
||||||
export interface ChatScreenState {
|
export interface ChatScreenState {
|
||||||
// 基础状态
|
// 基础状态
|
||||||
|
|||||||
@@ -390,6 +390,16 @@ export const useChatScreen = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 禁用自动滚动到底部,防止加载历史消息后滚动位置跳转
|
||||||
|
shouldAutoScrollOnEnterRef.current = false;
|
||||||
|
// 清除所有待执行的自动滚动定时器
|
||||||
|
autoScrollTimersRef.current.forEach(clearTimeout);
|
||||||
|
autoScrollTimersRef.current = [];
|
||||||
|
|
||||||
|
// 保存加载前的滚动位置和内容高度
|
||||||
|
const scrollYBefore = scrollPositionRef.current.scrollY;
|
||||||
|
const contentHeightBefore = scrollPositionRef.current.contentHeight;
|
||||||
|
|
||||||
setLoadingMore(true);
|
setLoadingMore(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -400,6 +410,23 @@ export const useChatScreen = () => {
|
|||||||
const minSeq = Math.min(...messages.map(m => m.seq));
|
const minSeq = Math.min(...messages.map(m => m.seq));
|
||||||
setFirstSeq(minSeq);
|
setFirstSeq(minSeq);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加载完成后,恢复滚动位置
|
||||||
|
// 使用 setTimeout 确保 FlashList 已经更新
|
||||||
|
setTimeout(() => {
|
||||||
|
if (flatListRef.current) {
|
||||||
|
// 计算新的滚动位置,保持相对位置不变
|
||||||
|
const newContentHeight = scrollPositionRef.current.contentHeight;
|
||||||
|
const heightDiff = newContentHeight - contentHeightBefore;
|
||||||
|
const newScrollY = scrollYBefore + heightDiff;
|
||||||
|
|
||||||
|
// 滚动到计算后的位置
|
||||||
|
flatListRef.current.scrollToOffset({
|
||||||
|
offset: newScrollY,
|
||||||
|
animated: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('加载历史消息失败:', error);
|
console.error('加载历史消息失败:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -409,13 +436,19 @@ export const useChatScreen = () => {
|
|||||||
|
|
||||||
// 列表内容尺寸变化后触发首次自动滚动
|
// 列表内容尺寸变化后触发首次自动滚动
|
||||||
const handleMessageListContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
|
const handleMessageListContentSizeChange = useCallback((contentWidth: number, contentHeight: number) => {
|
||||||
|
// 如果是加载更多历史消息,不要自动滚动
|
||||||
|
if (loadingMore) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!shouldAutoScrollOnEnterRef.current) return;
|
if (!shouldAutoScrollOnEnterRef.current) return;
|
||||||
if (loading || loadingMore) return;
|
if (loading) return;
|
||||||
if (messages.length === 0) return;
|
if (messages.length === 0) return;
|
||||||
|
|
||||||
const prevContentHeight = scrollPositionRef.current.contentHeight;
|
const prevContentHeight = scrollPositionRef.current.contentHeight;
|
||||||
scrollPositionRef.current.contentHeight = contentHeight;
|
scrollPositionRef.current.contentHeight = contentHeight;
|
||||||
|
|
||||||
|
// 如果内容高度增加(加载了更多消息),不要自动滚动到底部
|
||||||
if (prevContentHeight > 0 && contentHeight > prevContentHeight) {
|
if (prevContentHeight > 0 && contentHeight > prevContentHeight) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||||
import { useAuthStore } from '../../stores';
|
import { useAuthStore } from '../../stores';
|
||||||
import { Text, ResponsiveContainer } from '../../components/common';
|
import { Text, ResponsiveContainer } from '../../components/common';
|
||||||
@@ -30,6 +31,9 @@ interface SettingsItem {
|
|||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取应用版本号
|
||||||
|
const APP_VERSION = Constants.expoConfig?.version || '1.0.0';
|
||||||
|
|
||||||
// 设置分组配置
|
// 设置分组配置
|
||||||
const SETTINGS_GROUPS = [
|
const SETTINGS_GROUPS = [
|
||||||
{
|
{
|
||||||
@@ -53,7 +57,7 @@ const SETTINGS_GROUPS = [
|
|||||||
title: '关于与帮助',
|
title: '关于与帮助',
|
||||||
icon: 'information-outline',
|
icon: 'information-outline',
|
||||||
items: [
|
items: [
|
||||||
{ key: 'about', title: '关于我们', icon: 'information-outline', showArrow: true, subtitle: '版本 1.0.2' },
|
{ key: 'about', title: '关于我们', icon: 'information-outline', showArrow: true, subtitle: `版本 ${APP_VERSION}` },
|
||||||
{ key: 'help', title: '帮助与反馈', icon: 'help-circle-outline', showArrow: true },
|
{ key: 'help', title: '帮助与反馈', icon: 'help-circle-outline', showArrow: true },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -183,7 +187,7 @@ export const SettingsScreen: React.FC = () => {
|
|||||||
{/* 底部版权信息 */}
|
{/* 底部版权信息 */}
|
||||||
<View style={styles.footer}>
|
<View style={styles.footer}>
|
||||||
<Text variant="caption" color={colors.text.hint}>
|
<Text variant="caption" color={colors.text.hint}>
|
||||||
萝卜社区 v1.0.2
|
萝卜社区 v{APP_VERSION}
|
||||||
</Text>
|
</Text>
|
||||||
<Text variant="caption" color={colors.text.hint} style={styles.copyright}>
|
<Text variant="caption" color={colors.text.hint} style={styles.copyright}>
|
||||||
© 2024 Carrot BBS. All rights reserved.
|
© 2024 Carrot BBS. All rights reserved.
|
||||||
|
|||||||
@@ -432,5 +432,63 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 二维码登录相关接口 ──
|
||||||
|
|
||||||
|
export interface QRCodeSession {
|
||||||
|
session_id: string;
|
||||||
|
qrcode_url: string;
|
||||||
|
expires_in: number;
|
||||||
|
expires_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ScanResponse {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
nickname: string;
|
||||||
|
avatar: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 二维码登录 API(扩展 AuthService)
|
||||||
|
export const qrcodeApi = {
|
||||||
|
/**
|
||||||
|
* 获取二维码
|
||||||
|
*/
|
||||||
|
getQRCode: async (): Promise<QRCodeSession> => {
|
||||||
|
const response = await api.get<QRCodeSession>('/auth/qrcode');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 扫描二维码
|
||||||
|
*/
|
||||||
|
scan: async (sessionId: string): Promise<ScanResponse> => {
|
||||||
|
const response = await api.post<ScanResponse>('/auth/qrcode/scan', {
|
||||||
|
session_id: sessionId,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 确认登录
|
||||||
|
*/
|
||||||
|
confirm: async (sessionId: string): Promise<{ success: boolean }> => {
|
||||||
|
const response = await api.post<{ success: boolean }>('/auth/qrcode/confirm', {
|
||||||
|
session_id: sessionId,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取消登录
|
||||||
|
*/
|
||||||
|
cancel: async (sessionId: string): Promise<{ success: boolean }> => {
|
||||||
|
const response = await api.post<{ success: boolean }>('/auth/qrcode/cancel', {
|
||||||
|
session_id: sessionId,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// 导出认证服务实例
|
// 导出认证服务实例
|
||||||
export const authService = new AuthService();
|
export const authService = new AuthService();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { api, PaginatedData } from './api';
|
import { api, PaginatedData } from './api';
|
||||||
import { Comment, CreateCommentInput } from '../types';
|
import { Comment, CreateCommentInput } from '../types';
|
||||||
|
import { CursorPaginationRequest, CursorPaginationResponse, CommentDTO } from '../types/dto';
|
||||||
|
|
||||||
// 评论列表响应
|
// 评论列表响应
|
||||||
interface CommentListResponse {
|
interface CommentListResponse {
|
||||||
@@ -240,6 +241,62 @@ class CommentService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 游标分页方法 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取帖子评论列表(游标分页)
|
||||||
|
* GET /api/v1/comments/post/:id/cursor
|
||||||
|
* @param postId 帖子ID
|
||||||
|
* @param params 游标分页请求参数
|
||||||
|
*/
|
||||||
|
async getPostCommentsCursor(
|
||||||
|
postId: string,
|
||||||
|
params: CursorPaginationRequest = {}
|
||||||
|
): Promise<CursorPaginationResponse<Comment>> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<CursorPaginationResponse<Comment>>(
|
||||||
|
`/comments/post/${postId}/cursor`,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取帖子评论列表失败:', error);
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
next_cursor: null,
|
||||||
|
prev_cursor: null,
|
||||||
|
has_more: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取评论回复列表(游标分页)
|
||||||
|
* GET /api/v1/comments/:id/replies/cursor
|
||||||
|
* @param commentId 评论ID
|
||||||
|
* @param params 游标分页请求参数
|
||||||
|
*/
|
||||||
|
async getCommentRepliesCursor(
|
||||||
|
commentId: string,
|
||||||
|
params: CursorPaginationRequest = {}
|
||||||
|
): Promise<CursorPaginationResponse<Comment>> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<CursorPaginationResponse<Comment>>(
|
||||||
|
`/comments/${commentId}/replies/cursor`,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取评论回复列表失败:', error);
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
next_cursor: null,
|
||||||
|
prev_cursor: null,
|
||||||
|
has_more: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出评论服务实例
|
// 导出评论服务实例
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
MyMemberInfoResponse,
|
MyMemberInfoResponse,
|
||||||
SetGroupAvatarRequest,
|
SetGroupAvatarRequest,
|
||||||
HandleGroupRequestAction,
|
HandleGroupRequestAction,
|
||||||
|
CursorPaginationRequest,
|
||||||
|
CursorPaginationResponse,
|
||||||
} from '../types/dto';
|
} from '../types/dto';
|
||||||
|
|
||||||
// 群组服务类(纯 API 层)
|
// 群组服务类(纯 API 层)
|
||||||
@@ -321,6 +323,86 @@ class GroupService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 游标分页方法 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取群组列表(游标分页)
|
||||||
|
* GET /api/v1/groups/cursor
|
||||||
|
* @param params 游标分页请求参数
|
||||||
|
*/
|
||||||
|
async getGroupsCursor(
|
||||||
|
params: CursorPaginationRequest = {}
|
||||||
|
): Promise<CursorPaginationResponse<GroupResponse>> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<CursorPaginationResponse<GroupResponse>>('/groups/cursor', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取群组列表失败:', error);
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
next_cursor: null,
|
||||||
|
prev_cursor: null,
|
||||||
|
has_more: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取群组成员列表(游标分页)
|
||||||
|
* GET /api/v1/groups/:id/members/cursor
|
||||||
|
* @param groupId 群组ID
|
||||||
|
* @param params 游标分页请求参数
|
||||||
|
*/
|
||||||
|
async getGroupMembersCursor(
|
||||||
|
groupId: number | string,
|
||||||
|
params: CursorPaginationRequest = {}
|
||||||
|
): Promise<CursorPaginationResponse<GroupMemberResponse>> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<CursorPaginationResponse<GroupMemberResponse>>(
|
||||||
|
`/groups/${encodeURIComponent(String(groupId))}/members/cursor`,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取群组成员列表失败:', error);
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
next_cursor: null,
|
||||||
|
prev_cursor: null,
|
||||||
|
has_more: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取群公告列表(游标分页)
|
||||||
|
* GET /api/v1/groups/:id/announcements/cursor
|
||||||
|
* @param groupId 群组ID
|
||||||
|
* @param params 游标分页请求参数
|
||||||
|
*/
|
||||||
|
async getGroupAnnouncementsCursor(
|
||||||
|
groupId: number | string,
|
||||||
|
params: CursorPaginationRequest = {}
|
||||||
|
): Promise<CursorPaginationResponse<GroupAnnouncementResponse>> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<CursorPaginationResponse<GroupAnnouncementResponse>>(
|
||||||
|
`/groups/${encodeURIComponent(String(groupId))}/announcements/cursor`,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取群公告列表失败:', error);
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
next_cursor: null,
|
||||||
|
prev_cursor: null,
|
||||||
|
has_more: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 兼容旧API的方法(将逐步废弃) ====================
|
// ==================== 兼容旧API的方法(将逐步废弃) ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -15,8 +15,11 @@ import {
|
|||||||
UnreadCountResponse,
|
UnreadCountResponse,
|
||||||
ConversationUnreadCountResponse,
|
ConversationUnreadCountResponse,
|
||||||
SystemMessageListResponse,
|
SystemMessageListResponse,
|
||||||
|
SystemMessageResponse,
|
||||||
SystemUnreadCountResponse,
|
SystemUnreadCountResponse,
|
||||||
MessageSegment,
|
MessageSegment,
|
||||||
|
CursorPaginationRequest,
|
||||||
|
CursorPaginationResponse,
|
||||||
} from '../types/dto';
|
} from '../types/dto';
|
||||||
import {
|
import {
|
||||||
getConversationCache,
|
getConversationCache,
|
||||||
@@ -555,6 +558,85 @@ class MessageService {
|
|||||||
await api.put('/messages/system/read-all');
|
await api.put('/messages/system/read-all');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 游标分页方法 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话列表(游标分页)
|
||||||
|
* GET /api/v1/conversations/cursor
|
||||||
|
* @param params 游标分页请求参数
|
||||||
|
*/
|
||||||
|
async getConversationsCursor(
|
||||||
|
params: CursorPaginationRequest = {}
|
||||||
|
): Promise<CursorPaginationResponse<ConversationResponse>> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<CursorPaginationResponse<ConversationResponse>>(
|
||||||
|
'/conversations/cursor',
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取会话列表失败:', error);
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
next_cursor: null,
|
||||||
|
prev_cursor: null,
|
||||||
|
has_more: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取消息列表(游标分页)
|
||||||
|
* GET /api/v1/conversations/:id/messages/cursor
|
||||||
|
* @param conversationId 会话ID
|
||||||
|
* @param params 游标分页请求参数
|
||||||
|
*/
|
||||||
|
async getMessagesCursor(
|
||||||
|
conversationId: string,
|
||||||
|
params: CursorPaginationRequest = {}
|
||||||
|
): Promise<CursorPaginationResponse<MessageResponse>> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<CursorPaginationResponse<MessageResponse>>(
|
||||||
|
`/conversations/${encodeURIComponent(conversationId)}/messages/cursor`,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取消息列表失败:', error);
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
next_cursor: null,
|
||||||
|
prev_cursor: null,
|
||||||
|
has_more: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取系统消息列表(游标分页)
|
||||||
|
* GET /api/v1/messages/system/cursor
|
||||||
|
* @param params 游标分页请求参数
|
||||||
|
*/
|
||||||
|
async getSystemMessagesCursor(
|
||||||
|
params: CursorPaginationRequest = {}
|
||||||
|
): Promise<CursorPaginationResponse<SystemMessageResponse>> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<CursorPaginationResponse<SystemMessageResponse>>(
|
||||||
|
'/messages/system/cursor',
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取系统消息列表失败:', error);
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
next_cursor: null,
|
||||||
|
prev_cursor: null,
|
||||||
|
has_more: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 兼容旧API的方法(将逐步废弃) ====================
|
// ==================== 兼容旧API的方法(将逐步废弃) ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { api, PaginatedData } from './api';
|
import { api, PaginatedData } from './api';
|
||||||
import { Notification, NotificationBadge, NotificationType } from '../types';
|
import { Notification, NotificationBadge, NotificationType } from '../types';
|
||||||
|
import { CursorPaginationRequest, CursorPaginationResponse } from '../types/dto';
|
||||||
|
|
||||||
// 通知列表响应
|
// 通知列表响应
|
||||||
interface NotificationListResponse {
|
interface NotificationListResponse {
|
||||||
@@ -151,6 +152,33 @@ class NotificationService {
|
|||||||
async getMentionNotifications(page = 1, pageSize = 20): Promise<PaginatedData<Notification>> {
|
async getMentionNotifications(page = 1, pageSize = 20): Promise<PaginatedData<Notification>> {
|
||||||
return this.getNotifications(page, pageSize, 'mention');
|
return this.getNotifications(page, pageSize, 'mention');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 游标分页方法 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取通知列表(游标分页)
|
||||||
|
* GET /api/v1/notifications/cursor
|
||||||
|
* @param params 游标分页请求参数
|
||||||
|
*/
|
||||||
|
async getNotificationsCursor(
|
||||||
|
params: CursorPaginationRequest = {}
|
||||||
|
): Promise<CursorPaginationResponse<Notification>> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<CursorPaginationResponse<Notification>>(
|
||||||
|
'/notifications/cursor',
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取通知列表失败:', error);
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
next_cursor: null,
|
||||||
|
prev_cursor: null,
|
||||||
|
has_more: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出通知服务实例
|
// 导出通知服务实例
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { api, PaginatedData } from './api';
|
import { api, PaginatedData } from './api';
|
||||||
import { Post, CreatePostInput } from '../types';
|
import { Post, CreatePostInput } from '../types';
|
||||||
|
import { CursorPaginationRequest, CursorPaginationResponse } from '../types/dto';
|
||||||
|
|
||||||
// 帖子列表响应
|
// 帖子列表响应
|
||||||
interface PostListResponse {
|
interface PostListResponse {
|
||||||
@@ -281,6 +282,92 @@ class PostService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 游标分页方法 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取帖子列表(游标分页)
|
||||||
|
* GET /api/v1/posts/cursor
|
||||||
|
* @param params 游标分页请求参数(包含 post_type 可选:recommend, follow, hot, latest)
|
||||||
|
*/
|
||||||
|
async getPostsCursor(
|
||||||
|
params: CursorPaginationRequest = {}
|
||||||
|
): Promise<CursorPaginationResponse<Post>> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<CursorPaginationResponse<Post>>('/posts/cursor', {
|
||||||
|
params: {
|
||||||
|
cursor: params.cursor,
|
||||||
|
page_size: params.page_size,
|
||||||
|
post_type: params.post_type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取帖子列表失败:', error);
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
next_cursor: null,
|
||||||
|
prev_cursor: null,
|
||||||
|
has_more: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索帖子(游标分页)
|
||||||
|
* GET /api/v1/posts/search/cursor
|
||||||
|
* @param query 搜索关键词
|
||||||
|
* @param params 游标分页请求参数
|
||||||
|
*/
|
||||||
|
async searchPostsCursor(
|
||||||
|
query: string,
|
||||||
|
params: CursorPaginationRequest = {}
|
||||||
|
): Promise<CursorPaginationResponse<Post>> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<CursorPaginationResponse<Post>>('/posts/search/cursor', {
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
keyword: query,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('搜索帖子失败:', error);
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
next_cursor: null,
|
||||||
|
prev_cursor: null,
|
||||||
|
has_more: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户帖子列表(游标分页)
|
||||||
|
* GET /api/v1/users/:id/posts/cursor
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param params 游标分页请求参数
|
||||||
|
*/
|
||||||
|
async getUserPostsCursor(
|
||||||
|
userId: string,
|
||||||
|
params: CursorPaginationRequest = {}
|
||||||
|
): Promise<CursorPaginationResponse<Post>> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<CursorPaginationResponse<Post>>(
|
||||||
|
`/users/${userId}/posts/cursor`,
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取用户帖子列表失败:', error);
|
||||||
|
return {
|
||||||
|
items: [],
|
||||||
|
next_cursor: null,
|
||||||
|
prev_cursor: null,
|
||||||
|
has_more: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 导出帖子服务实例
|
// 导出帖子服务实例
|
||||||
|
|||||||
@@ -499,6 +499,36 @@ export interface SystemUnreadCountResponse {
|
|||||||
// 设备类型
|
// 设备类型
|
||||||
export type DeviceType = 'ios' | 'android' | 'web';
|
export type DeviceType = 'ios' | 'android' | 'web';
|
||||||
|
|
||||||
|
// ==================== 游标分页相关 DTO ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游标分页请求参数
|
||||||
|
*/
|
||||||
|
export interface CursorPaginationRequest {
|
||||||
|
/** 游标字符串(可选,首次请求不传) */
|
||||||
|
cursor?: string;
|
||||||
|
/** 分页方向:forward 或 backward(默认 forward) */
|
||||||
|
direction?: 'forward' | 'backward';
|
||||||
|
/** 每页数量(默认 20,最大 100) */
|
||||||
|
page_size?: number;
|
||||||
|
/** 帖子类型筛选(可选):recommend, follow, hot, latest */
|
||||||
|
post_type?: 'recommend' | 'follow' | 'hot' | 'latest';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 游标分页响应
|
||||||
|
*/
|
||||||
|
export interface CursorPaginationResponse<T> {
|
||||||
|
/** 数据项列表 */
|
||||||
|
items: T[];
|
||||||
|
/** 下一页游标 */
|
||||||
|
next_cursor: string | null;
|
||||||
|
/** 上一页游标 */
|
||||||
|
prev_cursor: string | null;
|
||||||
|
/** 是否有更多数据 */
|
||||||
|
has_more: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
// 设备Token响应
|
// 设备Token响应
|
||||||
export interface DeviceTokenResponse {
|
export interface DeviceTokenResponse {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -1,287 +0,0 @@
|
|||||||
/**
|
|
||||||
* 乐观更新工具函数测试
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { optimisticUpdate, simpleOptimisticUpdate, createOptimisticUpdaterFactory } from '../optimisticUpdate';
|
|
||||||
|
|
||||||
// 模拟数据
|
|
||||||
interface TestState {
|
|
||||||
posts: Array<{ id: string; likes: number; isLiked: boolean }>;
|
|
||||||
users: Array<{ id: string; isFollowing: boolean }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const initialState: TestState = {
|
|
||||||
posts: [
|
|
||||||
{ id: '1', likes: 10, isLiked: false },
|
|
||||||
{ id: '2', likes: 5, isLiked: true },
|
|
||||||
],
|
|
||||||
users: [
|
|
||||||
{ id: 'user1', isFollowing: false },
|
|
||||||
{ id: 'user2', isFollowing: true },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('optimisticUpdate', () => {
|
|
||||||
let currentState: TestState;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
currentState = JSON.parse(JSON.stringify(initialState));
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('成功场景', () => {
|
|
||||||
it('应该执行乐观更新并在API成功后保持更新', async () => {
|
|
||||||
const mockApiCall = jest.fn().mockResolvedValue({ success: true, likes: 11 });
|
|
||||||
|
|
||||||
const result = await optimisticUpdate<TestState, { success: boolean; likes: number }>({
|
|
||||||
getState: () => currentState,
|
|
||||||
optimisticUpdate: (state) => ({
|
|
||||||
...state,
|
|
||||||
posts: state.posts.map((p) =>
|
|
||||||
p.id === '1' ? { ...p, likes: p.likes + 1, isLiked: true } : p
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
apiCall: mockApiCall,
|
|
||||||
onSuccess: (result, current) => ({
|
|
||||||
...current,
|
|
||||||
posts: current.posts.map((p) =>
|
|
||||||
p.id === '1' ? { ...p, likes: result.likes } : p
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
// 验证API被调用
|
|
||||||
expect(mockApiCall).toHaveBeenCalledTimes(1);
|
|
||||||
// 验证返回结果
|
|
||||||
expect(result).toEqual({ success: true, likes: 11 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该在乐观更新后立即改变状态', async () => {
|
|
||||||
const mockApiCall = jest.fn().mockImplementation(() => {
|
|
||||||
// 在API调用期间验证状态已被乐观更新
|
|
||||||
expect(currentState.posts[0].isLiked).toBe(true);
|
|
||||||
expect(currentState.posts[0].likes).toBe(11);
|
|
||||||
return Promise.resolve({ success: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
await optimisticUpdate<TestState, any>({
|
|
||||||
getState: () => currentState,
|
|
||||||
optimisticUpdate: (state) => {
|
|
||||||
const newState = {
|
|
||||||
...state,
|
|
||||||
posts: state.posts.map((p) =>
|
|
||||||
p.id === '1' ? { ...p, likes: p.likes + 1, isLiked: true } : p
|
|
||||||
),
|
|
||||||
};
|
|
||||||
currentState = newState; // 模拟状态更新
|
|
||||||
return newState;
|
|
||||||
},
|
|
||||||
apiCall: mockApiCall,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('失败回滚场景', () => {
|
|
||||||
it('应该在API失败时回滚到原始状态', async () => {
|
|
||||||
const mockApiCall = jest.fn().mockRejectedValue(new Error('Network error'));
|
|
||||||
const onError = jest.fn((error, original) => original);
|
|
||||||
|
|
||||||
const result = await optimisticUpdate<TestState, any>({
|
|
||||||
getState: () => currentState,
|
|
||||||
optimisticUpdate: (state) => ({
|
|
||||||
...state,
|
|
||||||
posts: state.posts.map((p) =>
|
|
||||||
p.id === '1' ? { ...p, likes: p.likes + 1, isLiked: true } : p
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
apiCall: mockApiCall,
|
|
||||||
onError,
|
|
||||||
errorMessage: '点赞失败',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 验证API被调用
|
|
||||||
expect(mockApiCall).toHaveBeenCalledTimes(1);
|
|
||||||
// 验证onError被调用
|
|
||||||
expect(onError).toHaveBeenCalledTimes(1);
|
|
||||||
expect(onError).toHaveBeenCalledWith(
|
|
||||||
expect.any(Error),
|
|
||||||
initialState
|
|
||||||
);
|
|
||||||
// 验证返回undefined(因为失败了)
|
|
||||||
expect(result).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该在静默模式下不抛出错误', async () => {
|
|
||||||
const mockApiCall = jest.fn().mockRejectedValue(new Error('Network error'));
|
|
||||||
|
|
||||||
// 不应该抛出错误
|
|
||||||
await expect(
|
|
||||||
optimisticUpdate<TestState, any>({
|
|
||||||
getState: () => currentState,
|
|
||||||
optimisticUpdate: (state) => state,
|
|
||||||
apiCall: mockApiCall,
|
|
||||||
silent: true,
|
|
||||||
})
|
|
||||||
).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该在非静默模式下抛出错误', async () => {
|
|
||||||
const mockApiCall = jest.fn().mockRejectedValue(new Error('Network error'));
|
|
||||||
|
|
||||||
// 应该抛出错误
|
|
||||||
await expect(
|
|
||||||
optimisticUpdate<TestState, any>({
|
|
||||||
getState: () => currentState,
|
|
||||||
optimisticUpdate: (state) => state,
|
|
||||||
apiCall: mockApiCall,
|
|
||||||
silent: false,
|
|
||||||
})
|
|
||||||
).rejects.toThrow('Network error');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('错误处理', () => {
|
|
||||||
it('应该记录错误日志', async () => {
|
|
||||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
|
||||||
const mockApiCall = jest.fn().mockRejectedValue(new Error('API Error'));
|
|
||||||
|
|
||||||
await optimisticUpdate<TestState, any>({
|
|
||||||
getState: () => currentState,
|
|
||||||
optimisticUpdate: (state) => state,
|
|
||||||
apiCall: mockApiCall,
|
|
||||||
errorMessage: '自定义错误消息',
|
|
||||||
silent: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(consoleSpy).toHaveBeenCalledWith('自定义错误消息:', expect.any(Error));
|
|
||||||
|
|
||||||
consoleSpy.mockRestore();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('simpleOptimisticUpdate', () => {
|
|
||||||
let currentState: TestState;
|
|
||||||
let setStateMock: jest.Mock;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
currentState = JSON.parse(JSON.stringify(initialState));
|
|
||||||
setStateMock = jest.fn((newState) => {
|
|
||||||
currentState = newState;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该成功执行乐观更新', async () => {
|
|
||||||
const mockApiCall = jest.fn().mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
await simpleOptimisticUpdate<TestState>({
|
|
||||||
getState: () => currentState,
|
|
||||||
setState: setStateMock,
|
|
||||||
optimisticUpdate: (state) => ({
|
|
||||||
...state,
|
|
||||||
posts: state.posts.map((p) =>
|
|
||||||
p.id === '1' ? { ...p, likes: p.likes + 1 } : p
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
rollbackState: (original) => original,
|
|
||||||
apiCall: mockApiCall,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockApiCall).toHaveBeenCalledTimes(1);
|
|
||||||
expect(setStateMock).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该在失败时回滚状态', async () => {
|
|
||||||
const mockApiCall = jest.fn().mockRejectedValue(new Error('Error'));
|
|
||||||
const rollbackMock = jest.fn((original) => original);
|
|
||||||
|
|
||||||
await simpleOptimisticUpdate<TestState>({
|
|
||||||
getState: () => currentState,
|
|
||||||
setState: setStateMock,
|
|
||||||
optimisticUpdate: (state) => ({
|
|
||||||
...state,
|
|
||||||
posts: state.posts.map((p) =>
|
|
||||||
p.id === '1' ? { ...p, likes: p.likes + 1 } : p
|
|
||||||
),
|
|
||||||
}),
|
|
||||||
rollbackState: rollbackMock,
|
|
||||||
apiCall: mockApiCall,
|
|
||||||
silent: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 验证乐观更新和回滚都被调用
|
|
||||||
expect(setStateMock).toHaveBeenCalledTimes(2);
|
|
||||||
expect(rollbackMock).toHaveBeenCalledWith(initialState);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('createOptimisticUpdaterFactory', () => {
|
|
||||||
interface StoreState {
|
|
||||||
posts: Array<{ id: string; likes: number }>;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
let storeState: StoreState;
|
|
||||||
let setMock: jest.Mock;
|
|
||||||
let getMock: jest.Mock;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
storeState = {
|
|
||||||
posts: [{ id: '1', likes: 10 }],
|
|
||||||
count: 0,
|
|
||||||
};
|
|
||||||
setMock = jest.fn((fn) => {
|
|
||||||
storeState = fn(storeState);
|
|
||||||
});
|
|
||||||
getMock = jest.fn(() => storeState);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该创建乐观更新器并正常工作', async () => {
|
|
||||||
const optimisticUpdater = createOptimisticUpdaterFactory<StoreState>(setMock, getMock);
|
|
||||||
const mockApiCall = jest.fn().mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
await optimisticUpdater({
|
|
||||||
key: 'posts',
|
|
||||||
optimisticUpdate: (posts) =>
|
|
||||||
posts.map((p) => (p.id === '1' ? { ...p, likes: p.likes + 1 } : p)),
|
|
||||||
rollbackState: (original) => original,
|
|
||||||
apiCall: mockApiCall,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(mockApiCall).toHaveBeenCalledTimes(1);
|
|
||||||
expect(setMock).toHaveBeenCalledTimes(1);
|
|
||||||
expect(storeState.posts[0].likes).toBe(11);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该在API失败时回滚状态', async () => {
|
|
||||||
const optimisticUpdater = createOptimisticUpdaterFactory<StoreState>(setMock, getMock);
|
|
||||||
const mockApiCall = jest.fn().mockRejectedValue(new Error('Error'));
|
|
||||||
|
|
||||||
await optimisticUpdater({
|
|
||||||
key: 'posts',
|
|
||||||
optimisticUpdate: (posts) =>
|
|
||||||
posts.map((p) => (p.id === '1' ? { ...p, likes: p.likes + 1 } : p)),
|
|
||||||
rollbackState: (original) => original,
|
|
||||||
apiCall: mockApiCall,
|
|
||||||
silent: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 乐观更新 + 回滚 = 2次set调用
|
|
||||||
expect(setMock).toHaveBeenCalledTimes(2);
|
|
||||||
// 最终状态应该和初始状态一样
|
|
||||||
expect(storeState.posts[0].likes).toBe(10);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('应该处理不同的state key', async () => {
|
|
||||||
const optimisticUpdater = createOptimisticUpdaterFactory<StoreState>(setMock, getMock);
|
|
||||||
const mockApiCall = jest.fn().mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
await optimisticUpdater({
|
|
||||||
key: 'count',
|
|
||||||
optimisticUpdate: (count) => count + 1,
|
|
||||||
rollbackState: (original) => original,
|
|
||||||
apiCall: mockApiCall,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(storeState.count).toBe(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user