Compare commits
16 Commits
master
...
e969e5bad4
| Author | SHA1 | Date | |
|---|---|---|---|
| e969e5bad4 | |||
| ebb0c003e3 | |||
|
|
3c071957ce | ||
|
|
fa10ef5116 | ||
|
|
7c33409624 | ||
|
|
f6176c945b | ||
|
|
b19a2ced6f | ||
|
|
db7885086f | ||
|
|
ba99900624 | ||
|
|
4b89b50006 | ||
|
|
b6583e07c8 | ||
|
|
6d1514b2d1 | ||
|
|
d280ad1656 | ||
|
|
c903990aaf | ||
|
|
405cd271db | ||
|
|
9529ea39c4 |
@@ -272,6 +272,42 @@ jobs:
|
|||||||
name: carrot-bbs-android-release-apk
|
name: carrot-bbs-android-release-apk
|
||||||
path: android/app/build/outputs/apk/release/app-release.apk
|
path: android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
|
||||||
|
- name: Resolve runtime version
|
||||||
|
id: runtime
|
||||||
|
run: |
|
||||||
|
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
|
||||||
|
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
||||||
|
|
||||||
|
- name: Upload APK to Updates Server
|
||||||
|
env:
|
||||||
|
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
|
||||||
|
UPDATES_SERVER_URL: https://updates.littlelan.cn
|
||||||
|
run: |
|
||||||
|
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
|
||||||
|
|
||||||
|
APK_PATH="android/app/build/outputs/apk/release/app-release.apk"
|
||||||
|
RUNTIME_VERSION="${{ steps.runtime.outputs.runtime_version }}"
|
||||||
|
|
||||||
|
echo "Uploading APK for runtime version: ${RUNTIME_VERSION}"
|
||||||
|
|
||||||
|
HTTP_CODE=$(curl -s -o response.json -w "%{http_code}" \
|
||||||
|
-X POST \
|
||||||
|
"${UPDATES_SERVER_URL}/admin/apk/upload?runtimeVersion=${RUNTIME_VERSION}" \
|
||||||
|
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
|
||||||
|
-H "Content-Type: application/vnd.android.package-archive" \
|
||||||
|
--data-binary @"${APK_PATH}")
|
||||||
|
|
||||||
|
echo "HTTP Response Code: ${HTTP_CODE}"
|
||||||
|
cat response.json
|
||||||
|
|
||||||
|
if [ "${HTTP_CODE}" -ne 200 ]; then
|
||||||
|
echo "Failed to upload APK"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "APK uploaded successfully!"
|
||||||
|
|
||||||
build-and-push-web:
|
build-and-push-web:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -23,6 +23,29 @@ const devUpdatesUrl =
|
|||||||
|
|
||||||
const expo = appJson.expo;
|
const expo = appJson.expo;
|
||||||
|
|
||||||
|
// 检测是否为 Web 平台
|
||||||
|
const isWeb = process.env.EXPO_TARGET === 'web' || process.env.PLATFORM === 'web';
|
||||||
|
|
||||||
|
// 原生平台特有的插件(在 Web 端会报错)
|
||||||
|
const nativeOnlyPlugins = [
|
||||||
|
'expo-camera',
|
||||||
|
'expo-notifications',
|
||||||
|
'expo-background-fetch',
|
||||||
|
'expo-media-library',
|
||||||
|
'expo-image-picker',
|
||||||
|
'expo-video',
|
||||||
|
'expo-sqlite',
|
||||||
|
'./plugins/withMainActivityConfigChange',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 过滤插件:Web 端排除原生插件
|
||||||
|
const filteredPlugins = isWeb
|
||||||
|
? expo.plugins.filter((plugin) => {
|
||||||
|
const pluginName = Array.isArray(plugin) ? plugin[0] : plugin;
|
||||||
|
return !nativeOnlyPlugins.includes(pluginName);
|
||||||
|
})
|
||||||
|
: expo.plugins;
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
...expo,
|
...expo,
|
||||||
name: isDevVariant ? `${expo.name} Dev` : expo.name,
|
name: isDevVariant ? `${expo.name} Dev` : expo.name,
|
||||||
@@ -40,10 +63,27 @@ module.exports = {
|
|||||||
...expo.android,
|
...expo.android,
|
||||||
package: 'skin.carrot.bbs',
|
package: 'skin.carrot.bbs',
|
||||||
},
|
},
|
||||||
|
// Web 端使用过滤后的插件
|
||||||
|
plugins: filteredPlugins,
|
||||||
extra: {
|
extra: {
|
||||||
...(expo.extra || {}),
|
...(expo.extra || {}),
|
||||||
appVariant: isDevVariant ? 'dev' : 'release',
|
appVariant: isDevVariant ? 'dev' : 'release',
|
||||||
apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl,
|
apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl,
|
||||||
updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
|
updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
|
||||||
},
|
},
|
||||||
|
// Web 端配置
|
||||||
|
...(isWeb && {
|
||||||
|
web: {
|
||||||
|
...expo.web,
|
||||||
|
build: {
|
||||||
|
...expo.web?.build,
|
||||||
|
// 配置 Web 端别名,避免加载原生模块
|
||||||
|
babel: {
|
||||||
|
dangerouslyAddModulePathsToTranspile: [
|
||||||
|
'react-native-webrtc',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
8
app.json
8
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "萝卜社区",
|
"name": "萝卜社区",
|
||||||
"slug": "qojo",
|
"slug": "qojo",
|
||||||
"version": "1.0.11",
|
"version": "1.0.12",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
@@ -15,11 +15,13 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
|
"NSMicrophoneUsageDescription": "允许萝卜社区访问您的麦克风以进行语音通话",
|
||||||
"UIBackgroundModes": [
|
"UIBackgroundModes": [
|
||||||
"fetch",
|
"fetch",
|
||||||
"remote-notification",
|
"remote-notification",
|
||||||
"fetch",
|
"fetch",
|
||||||
"remote-notification"
|
"remote-notification",
|
||||||
|
"audio"
|
||||||
],
|
],
|
||||||
"ITSAppUsesNonExemptEncryption": false
|
"ITSAppUsesNonExemptEncryption": false
|
||||||
},
|
},
|
||||||
@@ -37,6 +39,7 @@
|
|||||||
"versionCode": 6,
|
"versionCode": 6,
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"VIBRATE",
|
"VIBRATE",
|
||||||
|
"RECORD_AUDIO",
|
||||||
"RECEIVE_BOOT_COMPLETED",
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
"WAKE_LOCK",
|
"WAKE_LOCK",
|
||||||
"READ_EXTERNAL_STORAGE",
|
"READ_EXTERNAL_STORAGE",
|
||||||
@@ -59,6 +62,7 @@
|
|||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
"./plugins/withMainActivityConfigChange",
|
||||||
[
|
[
|
||||||
"expo-router",
|
"expo-router",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useCallback } from 'react';
|
||||||
import { Platform, useWindowDimensions } from 'react-native';
|
import { Platform, useWindowDimensions } from 'react-native';
|
||||||
import { Tabs, usePathname } from 'expo-router';
|
import { Tabs, usePathname } from 'expo-router';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
@@ -6,7 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|||||||
|
|
||||||
import { useAppColors, shadows } from '../../../src/theme';
|
import { useAppColors, shadows } from '../../../src/theme';
|
||||||
import { BREAKPOINTS } from '../../../src/hooks/useResponsive';
|
import { BREAKPOINTS } from '../../../src/hooks/useResponsive';
|
||||||
import { useHomeTabBarVisibilityStore, useTotalUnreadCount } from '../../../src/stores';
|
import { useHomeTabBarVisibilityStore, useTotalUnreadCount, useHomeTabPressStore } from '../../../src/stores';
|
||||||
|
|
||||||
const TAB_BAR_HEIGHT = 56;
|
const TAB_BAR_HEIGHT = 56;
|
||||||
const TAB_BAR_FLOATING_MARGIN = 12;
|
const TAB_BAR_FLOATING_MARGIN = 12;
|
||||||
@@ -19,10 +19,17 @@ export default function TabsLayout() {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const unreadCount = useTotalUnreadCount();
|
const unreadCount = useTotalUnreadCount();
|
||||||
const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
|
const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
|
||||||
|
const triggerHomeTabPress = useHomeTabPressStore((s) => s.triggerPress);
|
||||||
const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop;
|
const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop;
|
||||||
|
|
||||||
const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/');
|
const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/');
|
||||||
|
|
||||||
|
const handleHomeTabPress = useCallback(() => {
|
||||||
|
if (pathname === '/home') {
|
||||||
|
triggerHomeTabPress();
|
||||||
|
}
|
||||||
|
}, [pathname, triggerHomeTabPress]);
|
||||||
|
|
||||||
const tabBarStyle = useMemo(() => {
|
const tabBarStyle = useMemo(() => {
|
||||||
if (hideTabBar) {
|
if (hideTabBar) {
|
||||||
return { display: 'none' as const, height: 0, overflow: 'hidden' as const };
|
return { display: 'none' as const, height: 0, overflow: 'hidden' as const };
|
||||||
@@ -81,6 +88,9 @@ export default function TabsLayout() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
listeners={{
|
||||||
|
tabPress: handleHomeTabPress,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="apps"
|
name="apps"
|
||||||
|
|||||||
11
app/(app)/(tabs)/apps/materials/_layout.tsx
Normal file
11
app/(app)/(tabs)/apps/materials/_layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function MaterialsStackLayout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="index" />
|
||||||
|
<Stack.Screen name="subject" />
|
||||||
|
<Stack.Screen name="detail" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/apps/materials/detail.tsx
Normal file
5
app/(app)/(tabs)/apps/materials/detail.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { MaterialDetailScreen } from '../../../../../src/screens/material';
|
||||||
|
|
||||||
|
export default function MaterialDetailRoute() {
|
||||||
|
return <MaterialDetailScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/apps/materials/index.tsx
Normal file
5
app/(app)/(tabs)/apps/materials/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { MaterialsScreen } from '../../../../../src/screens/material';
|
||||||
|
|
||||||
|
export default function MaterialsRoute() {
|
||||||
|
return <MaterialsScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/apps/materials/subject.tsx
Normal file
5
app/(app)/(tabs)/apps/materials/subject.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SubjectMaterialsScreen } from '../../../../../src/screens/material';
|
||||||
|
|
||||||
|
export default function SubjectMaterialsRoute() {
|
||||||
|
return <SubjectMaterialsScreen />;
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ import AppPromptBar from '../src/components/common/AppPromptBar';
|
|||||||
import AppDialogHost from '../src/components/common/AppDialogHost';
|
import AppDialogHost from '../src/components/common/AppDialogHost';
|
||||||
import { installAlertOverride } from '../src/services/alertOverride';
|
import { installAlertOverride } from '../src/services/alertOverride';
|
||||||
import { useAuthStore } from '../src/stores';
|
import { useAuthStore } from '../src/stores';
|
||||||
|
import { checkForAPKUpdate } from '../src/services/apkUpdateService';
|
||||||
|
import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call';
|
||||||
|
|
||||||
registerNotificationPresentationHandler();
|
registerNotificationPresentationHandler();
|
||||||
|
|
||||||
@@ -137,6 +139,26 @@ function NotificationBootstrap() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function APKUpdateBootstrap() {
|
||||||
|
const hasChecked = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasChecked.current) return;
|
||||||
|
hasChecked.current = true;
|
||||||
|
|
||||||
|
// 延迟执行,避免与启动流程冲突
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
checkForAPKUpdate().catch((error) => {
|
||||||
|
console.error('APK update check failed:', error);
|
||||||
|
});
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function ThemedStack() {
|
function ThemedStack() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
@@ -148,6 +170,7 @@ function ThemedStack() {
|
|||||||
<SystemChrome />
|
<SystemChrome />
|
||||||
<SessionGate>
|
<SessionGate>
|
||||||
<NotificationBootstrap />
|
<NotificationBootstrap />
|
||||||
|
<APKUpdateBootstrap />
|
||||||
<Stack screenOptions={{ headerShown: false }}>
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
||||||
@@ -197,6 +220,9 @@ export default function RootLayout() {
|
|||||||
<ThemedStack />
|
<ThemedStack />
|
||||||
<AppPromptBar />
|
<AppPromptBar />
|
||||||
<AppDialogHost />
|
<AppDialogHost />
|
||||||
|
<IncomingCallModal />
|
||||||
|
<CallScreen />
|
||||||
|
<FloatingCallWindow />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ThemedProviders>
|
</ThemedProviders>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
|
|||||||
64
package-lock.json
generated
64
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "carrot_bbs",
|
"name": "carrot_bbs",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "carrot_bbs",
|
"name": "carrot_bbs",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.1.1",
|
"@expo/vector-icons": "^15.1.1",
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"expo-haptics": "~55.0.8",
|
"expo-haptics": "~55.0.8",
|
||||||
"expo-image": "^55.0.5",
|
"expo-image": "^55.0.5",
|
||||||
"expo-image-picker": "^55.0.10",
|
"expo-image-picker": "^55.0.10",
|
||||||
|
"expo-intent-launcher": "~55.0.9",
|
||||||
"expo-linear-gradient": "^55.0.8",
|
"expo-linear-gradient": "^55.0.8",
|
||||||
"expo-media-library": "~55.0.9",
|
"expo-media-library": "~55.0.9",
|
||||||
"expo-notifications": "^55.0.10",
|
"expo-notifications": "^55.0.10",
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
"react-native-screens": "~4.23.0",
|
"react-native-screens": "~4.23.0",
|
||||||
"react-native-sse": "^1.2.1",
|
"react-native-sse": "^1.2.1",
|
||||||
"react-native-web": "^0.21.0",
|
"react-native-web": "^0.21.0",
|
||||||
|
"react-native-webrtc": "^124.0.7",
|
||||||
"react-native-webview": "13.16.0",
|
"react-native-webview": "13.16.0",
|
||||||
"react-native-worklets": "0.7.2",
|
"react-native-worklets": "0.7.2",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
@@ -5747,6 +5749,15 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-intent-launcher": {
|
||||||
|
"version": "55.0.9",
|
||||||
|
"resolved": "https://registry.npmmirror.com/expo-intent-launcher/-/expo-intent-launcher-55.0.9.tgz",
|
||||||
|
"integrity": "sha512-s8k8dF8PfgzN0c+xzNTd9ceHh+/6mt2ncnMuqFaIIry2BeDGITbsgVijHr39eB5vS+KopCcOYYBYr+O1epx4TA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-json-utils": {
|
"node_modules/expo-json-utils": {
|
||||||
"version": "55.0.0",
|
"version": "55.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/expo-json-utils/-/expo-json-utils-55.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/expo-json-utils/-/expo-json-utils-55.0.0.tgz",
|
||||||
@@ -9789,6 +9800,55 @@
|
|||||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-webrtc": {
|
||||||
|
"version": "124.0.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/react-native-webrtc/-/react-native-webrtc-124.0.7.tgz",
|
||||||
|
"integrity": "sha512-gnXPdbUS8IkKHq9WNaWptW/yy5s6nMyI6cNn90LXdobPVCgYSk6NA2uUGdT4c4J14BRgaFA95F+cR28tUPkMVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "1.5.1",
|
||||||
|
"debug": "4.3.4",
|
||||||
|
"event-target-shim": "6.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-native": ">=0.60.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-native-webrtc/node_modules/debug": {
|
||||||
|
"version": "4.3.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.3.4.tgz",
|
||||||
|
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-native-webrtc/node_modules/event-target-shim": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/mysticatea"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-native-webrtc/node_modules/ms": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/react-native-webview": {
|
"node_modules/react-native-webview": {
|
||||||
"version": "13.16.0",
|
"version": "13.16.0",
|
||||||
"resolved": "https://registry.npmmirror.com/react-native-webview/-/react-native-webview-13.16.0.tgz",
|
"resolved": "https://registry.npmmirror.com/react-native-webview/-/react-native-webview-13.16.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "carrot_bbs",
|
"name": "carrot_bbs",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
"expo-haptics": "~55.0.8",
|
"expo-haptics": "~55.0.8",
|
||||||
"expo-image": "^55.0.5",
|
"expo-image": "^55.0.5",
|
||||||
"expo-image-picker": "^55.0.10",
|
"expo-image-picker": "^55.0.10",
|
||||||
|
"expo-intent-launcher": "~55.0.9",
|
||||||
"expo-linear-gradient": "^55.0.8",
|
"expo-linear-gradient": "^55.0.8",
|
||||||
"expo-media-library": "~55.0.9",
|
"expo-media-library": "~55.0.9",
|
||||||
"expo-notifications": "^55.0.10",
|
"expo-notifications": "^55.0.10",
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
"react-native-screens": "~4.23.0",
|
"react-native-screens": "~4.23.0",
|
||||||
"react-native-sse": "^1.2.1",
|
"react-native-sse": "^1.2.1",
|
||||||
"react-native-web": "^0.21.0",
|
"react-native-web": "^0.21.0",
|
||||||
|
"react-native-webrtc": "^124.0.7",
|
||||||
"react-native-webview": "13.16.0",
|
"react-native-webview": "13.16.0",
|
||||||
"react-native-worklets": "0.7.2",
|
"react-native-worklets": "0.7.2",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
|
|||||||
73
plugins/withMainActivityConfigChange.js
Normal file
73
plugins/withMainActivityConfigChange.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
const { withMainActivity } = require('@expo/config-plugins');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 MainActivity 中添加 onConfigurationChanged 方法
|
||||||
|
* 用于监听系统深色模式变化并通知 React Native
|
||||||
|
*/
|
||||||
|
const withMainActivityConfigChange = (config) => {
|
||||||
|
return withMainActivity(config, async (config) => {
|
||||||
|
const { contents } = config.modResults;
|
||||||
|
|
||||||
|
// 检查是否已经添加了 onConfigurationChanged
|
||||||
|
if (contents.includes('onConfigurationChanged')) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 需要添加的 imports
|
||||||
|
const importsToAdd = `import android.content.Intent
|
||||||
|
import android.content.res.Configuration`;
|
||||||
|
|
||||||
|
// 需要添加的方法
|
||||||
|
const methodToAdd = `
|
||||||
|
/**
|
||||||
|
* 监听系统配置变化(包括深色模式切换),并广播给 React Native
|
||||||
|
* 这对于 Appearance API 正确检测系统主题至关重要
|
||||||
|
*/
|
||||||
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||||
|
super.onConfigurationChanged(newConfig)
|
||||||
|
val intent = Intent("onConfigurationChanged")
|
||||||
|
intent.putExtra("newConfig", newConfig)
|
||||||
|
sendBroadcast(intent)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
let newContents = contents;
|
||||||
|
|
||||||
|
// 添加 imports(在已有 imports 后面)
|
||||||
|
if (!newContents.includes('import android.content.Intent')) {
|
||||||
|
// 找到最后一个 import 语句
|
||||||
|
const importRegex = /import [^\n]+\n/g;
|
||||||
|
const imports = newContents.match(importRegex);
|
||||||
|
if (imports && imports.length > 0) {
|
||||||
|
const lastImport = imports[imports.length - 1];
|
||||||
|
const lastImportIndex = newContents.lastIndexOf(lastImport);
|
||||||
|
const insertPosition = lastImportIndex + lastImport.length;
|
||||||
|
newContents =
|
||||||
|
newContents.slice(0, insertPosition) +
|
||||||
|
importsToAdd +
|
||||||
|
'\n' +
|
||||||
|
newContents.slice(insertPosition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在类体的开头添加 onConfigurationChanged 方法
|
||||||
|
// 找到类定义后的第一个方法或属性
|
||||||
|
const classBodyRegex = /class MainActivity\s*:\s*ReactActivity\s*\(\)\s*\{([\s\S]*)/;
|
||||||
|
const match = newContents.match(classBodyRegex);
|
||||||
|
if (match) {
|
||||||
|
// 找到 onCreate 方法之前插入
|
||||||
|
const onCreateIndex = newContents.indexOf('override fun onCreate');
|
||||||
|
if (onCreateIndex !== -1) {
|
||||||
|
newContents =
|
||||||
|
newContents.slice(0, onCreateIndex) +
|
||||||
|
methodToAdd +
|
||||||
|
newContents.slice(onCreateIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.modResults.contents = newContents;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = withMainActivityConfigChange;
|
||||||
@@ -18,7 +18,7 @@ interface CommentItemProps {
|
|||||||
comment: Comment;
|
comment: Comment;
|
||||||
onUserPress: () => void;
|
onUserPress: () => void;
|
||||||
onReply: () => void;
|
onReply: () => void;
|
||||||
onLike: () => void;
|
onLike: (comment: Comment) => void; // 点赞回调,传入评论对象
|
||||||
floorNumber?: number; // 楼层号
|
floorNumber?: number; // 楼层号
|
||||||
isAuthor?: boolean; // 是否是楼主
|
isAuthor?: boolean; // 是否是楼主
|
||||||
replyToUser?: string; // 回复给哪位用户
|
replyToUser?: string; // 回复给哪位用户
|
||||||
@@ -504,6 +504,25 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
回复
|
回复
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
{/* 点赞按钮 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.subActionButton}
|
||||||
|
onPress={() => onLike?.(reply)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={reply.is_liked ? 'heart' : 'heart-outline'}
|
||||||
|
size={12}
|
||||||
|
color={reply.is_liked ? colors.error.main : colors.text.hint}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
variant="caption"
|
||||||
|
color={reply.is_liked ? colors.error.main : colors.text.hint}
|
||||||
|
style={styles.subActionText}
|
||||||
|
>
|
||||||
|
{reply.likes_count > 0 ? formatNumber(reply.likes_count) : '赞'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
{/* 删除按钮 - 子评论作者可见 */}
|
{/* 删除按钮 - 子评论作者可见 */}
|
||||||
{isSubReplyAuthor && (
|
{isSubReplyAuthor && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -592,7 +611,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
{/* 操作按钮 - 更紧凑 */}
|
{/* 操作按钮 - 更紧凑 */}
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
{/* 点赞 */}
|
{/* 点赞 */}
|
||||||
<TouchableOpacity style={styles.actionButton} onPress={onLike}>
|
<TouchableOpacity style={styles.actionButton} onPress={() => onLike(comment)}>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name={comment.is_liked ? 'heart' : 'heart-outline'}
|
name={comment.is_liked ? 'heart' : 'heart-outline'}
|
||||||
size={14}
|
size={14}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Modal,
|
Modal,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
@@ -133,22 +133,74 @@ function createQrScannerStyles(colors: AppColors) {
|
|||||||
fontSize: fontSizes.md,
|
fontSize: fontSizes.md,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
|
webNotSupportedContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: spacing.xl,
|
||||||
|
backgroundColor: '#000',
|
||||||
|
},
|
||||||
|
webNotSupportedText: {
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
color: 'rgba(255,255,255,0.72)',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
// Web 端不支持扫码组件
|
||||||
|
const QRCodeScannerWeb: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||||
|
const themeColors = useAppColors();
|
||||||
|
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||||
|
<MaterialCommunityIcons name="close" size={24} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>扫码登录</Text>
|
||||||
|
<View style={styles.placeholder} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.webNotSupportedContainer}>
|
||||||
|
<MaterialCommunityIcons name="web-off" size={64} color="rgba(255,255,255,0.5)" />
|
||||||
|
<Text style={styles.webNotSupportedText}>
|
||||||
|
扫码登录功能暂不支持网页端使用{'\n'}
|
||||||
|
请下载手机 App 体验完整功能
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity style={styles.permissionButton} onPress={onClose}>
|
||||||
|
<Text style={styles.permissionButtonText}>知道了</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 原生端扫码组件
|
||||||
|
const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const themeColors = useAppColors();
|
const themeColors = useAppColors();
|
||||||
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
||||||
const [permission, requestPermission] = useCameraPermissions();
|
|
||||||
|
// 动态导入 expo-camera,避免 Web 端加载
|
||||||
|
const [cameraModule, setCameraModule] = useState<typeof import('expo-camera') | null>(null);
|
||||||
|
const [permission, setPermission] = useState<any>(null);
|
||||||
const [scanned, setScanned] = useState(false);
|
const [scanned, setScanned] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible && Platform.OS !== 'web') {
|
||||||
setScanned(false);
|
import('expo-camera').then((module) => {
|
||||||
if (!permission?.granted) {
|
setCameraModule(module);
|
||||||
requestPermission();
|
const [perm, requestPerm] = module.useCameraPermissions();
|
||||||
}
|
setPermission(perm);
|
||||||
|
if (!perm?.granted) {
|
||||||
|
requestPerm();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
@@ -178,7 +230,7 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!permission?.granted) {
|
if (!cameraModule || !permission?.granted) {
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -192,7 +244,10 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
|||||||
<View style={styles.permissionContainer}>
|
<View style={styles.permissionContainer}>
|
||||||
<MaterialCommunityIcons name="camera-off" size={64} color="rgba(255,255,255,0.5)" />
|
<MaterialCommunityIcons name="camera-off" size={64} color="rgba(255,255,255,0.5)" />
|
||||||
<Text style={styles.permissionText}>需要相机权限才能扫码</Text>
|
<Text style={styles.permissionText}>需要相机权限才能扫码</Text>
|
||||||
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
|
<TouchableOpacity
|
||||||
|
style={styles.permissionButton}
|
||||||
|
onPress={() => cameraModule?.useCameraPermissions()[1]()}
|
||||||
|
>
|
||||||
<Text style={styles.permissionButtonText}>授予权限</Text>
|
<Text style={styles.permissionButtonText}>授予权限</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -201,6 +256,8 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { CameraView } = cameraModule;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -243,4 +300,9 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 根据平台导出不同组件
|
||||||
|
export const QRCodeScanner: React.FC<QRCodeScannerProps> = Platform.OS === 'web'
|
||||||
|
? QRCodeScannerWeb
|
||||||
|
: QRCodeScannerNative;
|
||||||
|
|
||||||
export default QRCodeScanner;
|
export default QRCodeScanner;
|
||||||
|
|||||||
412
src/components/call/CallScreen.tsx
Normal file
412
src/components/call/CallScreen.tsx
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
Image,
|
||||||
|
StatusBar,
|
||||||
|
} from 'react-native';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { callStore } from '../../stores/callStore';
|
||||||
|
import { RTCView } from 'react-native-webrtc';
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number): string => {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const CallScreen: React.FC = () => {
|
||||||
|
const currentCall = callStore((s) => s.currentCall);
|
||||||
|
const callDuration = callStore((s) => s.callDuration);
|
||||||
|
const peerStream = callStore((s) => s.peerStream);
|
||||||
|
const localStream = callStore((s) => s.localStream);
|
||||||
|
const endCall = callStore((s) => s.endCall);
|
||||||
|
const toggleMute = callStore((s) => s.toggleMute);
|
||||||
|
const toggleSpeaker = callStore((s) => s.toggleSpeaker);
|
||||||
|
const toggleVideo = callStore((s) => s.toggleVideo);
|
||||||
|
const isMinimized = callStore((s) => s.isMinimized);
|
||||||
|
const toggleMinimize = callStore((s) => s.toggleMinimize);
|
||||||
|
|
||||||
|
// Track whether peer has video
|
||||||
|
const [hasPeerVideo, setHasPeerVideo] = useState(false);
|
||||||
|
// Track whether local has video
|
||||||
|
const [hasLocalVideo, setHasLocalVideo] = useState(false);
|
||||||
|
|
||||||
|
// Check peer stream for video tracks
|
||||||
|
useEffect(() => {
|
||||||
|
if (peerStream) {
|
||||||
|
const videoTracks = peerStream.getVideoTracks();
|
||||||
|
const hasVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled);
|
||||||
|
setHasPeerVideo(hasVideo);
|
||||||
|
} else {
|
||||||
|
setHasPeerVideo(false);
|
||||||
|
}
|
||||||
|
}, [peerStream]);
|
||||||
|
// Check local stream for video tracks
|
||||||
|
useEffect(() => {
|
||||||
|
if (localStream) {
|
||||||
|
const videoTracks = localStream.getVideoTracks();
|
||||||
|
const hasVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled);
|
||||||
|
setHasLocalVideo(hasVideo);
|
||||||
|
} else {
|
||||||
|
setHasLocalVideo(false);
|
||||||
|
}
|
||||||
|
}, [localStream]);
|
||||||
|
// Don't render full screen when minimized or no call
|
||||||
|
if (!currentCall || isMinimized) return null;
|
||||||
|
const getStatusText = (): string => {
|
||||||
|
switch (currentCall.status) {
|
||||||
|
case 'calling':
|
||||||
|
return '正在等待对方接听...';
|
||||||
|
case 'ringing':
|
||||||
|
return '来电响铃中...';
|
||||||
|
case 'connecting':
|
||||||
|
return '连接中...';
|
||||||
|
case 'connected':
|
||||||
|
return formatDuration(callDuration);
|
||||||
|
case 'reconnecting':
|
||||||
|
return '网络重连中...';
|
||||||
|
case 'ended':
|
||||||
|
return '通话已结束';
|
||||||
|
case 'failed':
|
||||||
|
return '连接失败';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const handleEndCall = () => {
|
||||||
|
endCall('hangup');
|
||||||
|
};
|
||||||
|
const handleToggleMute = () => {
|
||||||
|
toggleMute();
|
||||||
|
};
|
||||||
|
const handleToggleSpeaker = () => {
|
||||||
|
toggleSpeaker();
|
||||||
|
};
|
||||||
|
const handleToggleVideo = () => {
|
||||||
|
toggleVideo();
|
||||||
|
};
|
||||||
|
const handleMinimize = () => {
|
||||||
|
toggleMinimize();
|
||||||
|
};
|
||||||
|
// Determine if we should show video UI
|
||||||
|
const showRemoteVideo = hasPeerVideo;
|
||||||
|
// Use currentCall.isVideoEnabled directly for local video
|
||||||
|
// This is more reliable than checking localStream.getVideoTracks()
|
||||||
|
// because the stream object reference may not trigger useEffect properly
|
||||||
|
const showLocalVideo = currentCall?.isVideoEnabled && localStream;
|
||||||
|
const isVideoCallActive = showRemoteVideo || showLocalVideo;
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
||||||
|
{/* Background */}
|
||||||
|
<View style={styles.background} />
|
||||||
|
{/* Remote video - full screen when peer has video */}
|
||||||
|
{showRemoteVideo && peerStream && (
|
||||||
|
<RTCView
|
||||||
|
streamURL={peerStream.toURL()}
|
||||||
|
style={styles.fullScreenVideo}
|
||||||
|
objectFit="cover"
|
||||||
|
mirror={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{/* Local video - picture in picture */}
|
||||||
|
{showLocalVideo && localStream && (
|
||||||
|
<View style={styles.localVideoContainer}>
|
||||||
|
<RTCView
|
||||||
|
streamURL={localStream.toURL()}
|
||||||
|
style={styles.localVideo}
|
||||||
|
objectFit="cover"
|
||||||
|
mirror={true}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{/* Top area: minimize button */}
|
||||||
|
<View style={styles.topBar}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.topButton}
|
||||||
|
onPress={handleMinimize}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name="chevron-down" size={28} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{/* Center: Peer info - only show when no video */}
|
||||||
|
{!isVideoCallActive && (
|
||||||
|
<View style={styles.centerArea}>
|
||||||
|
<View style={styles.avatarOuter}>
|
||||||
|
{currentCall.peerAvatar ? (
|
||||||
|
<Image source={{ uri: currentCall.peerAvatar }} style={styles.avatar} />
|
||||||
|
) : (
|
||||||
|
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
||||||
|
<Text style={styles.avatarText}>
|
||||||
|
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<Text style={styles.peerName} numberOfLines={1}>
|
||||||
|
{currentCall.peerName || '未知用户'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.status}>{getStatusText()}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{/* Peer name overlay when video is active */}
|
||||||
|
{isVideoCallActive && (
|
||||||
|
<View style={styles.videoOverlayInfo}>
|
||||||
|
<Text style={styles.videoPeerName} numberOfLines={1}>
|
||||||
|
{currentCall.peerName || '未知用户'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.videoStatus}>{getStatusText()}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{/* Bottom controls */}
|
||||||
|
<View style={styles.controls}>
|
||||||
|
<View style={styles.controlRow}>
|
||||||
|
{/* Mute */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.controlButton, currentCall.isMuted && styles.controlButtonActive]}
|
||||||
|
onPress={handleToggleMute}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={[styles.controlCircle, currentCall.isMuted && styles.controlCircleActive]}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={currentCall.isMuted ? 'microphone-off' : 'microphone'}
|
||||||
|
size={26}
|
||||||
|
color={currentCall.isMuted ? '#333' : '#fff'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.controlLabel, currentCall.isMuted && styles.controlLabelActive]}>
|
||||||
|
{currentCall.isMuted ? '取消静音' : '静音'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{/* Video toggle */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.controlButton, hasLocalVideo && styles.controlButtonActive]}
|
||||||
|
onPress={handleToggleVideo}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={[styles.controlCircle, hasLocalVideo && styles.controlCircleActive]}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={hasLocalVideo ? 'video' : 'video-off'}
|
||||||
|
size={26}
|
||||||
|
color={hasLocalVideo ? '#333' : '#fff'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.controlLabel, hasLocalVideo && styles.controlLabelActive]}>
|
||||||
|
{hasLocalVideo ? '关闭摄像头' : '打开摄像头'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
{/* Speaker */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.controlButton, currentCall.isSpeakerOn && styles.controlButtonActive]}
|
||||||
|
onPress={handleToggleSpeaker}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<View style={[styles.controlCircle, currentCall.isSpeakerOn && styles.controlCircleActive]}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={currentCall.isSpeakerOn ? 'volume-high' : 'cellphone'}
|
||||||
|
size={26}
|
||||||
|
color={currentCall.isSpeakerOn ? '#333' : '#fff'}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.controlLabel, currentCall.isSpeakerOn && styles.controlLabelActive]}>
|
||||||
|
{currentCall.isSpeakerOn ? '免提' : '听筒'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{/* End call - prominent red button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.endCallButton}
|
||||||
|
onPress={handleEndCall}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<View style={styles.endCallCircle}>
|
||||||
|
<MaterialCommunityIcons name="phone-hangup" size={32} color="#fff" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.endCallLabel}>挂断</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const CONTROL_CIRCLE_SIZE = 56;
|
||||||
|
const END_CALL_SIZE = 64;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: '#1A1A2E',
|
||||||
|
zIndex: 9999,
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: '#1A1A2E',
|
||||||
|
},
|
||||||
|
topBar: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 50,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
height: 44,
|
||||||
|
zIndex: 100,
|
||||||
|
},
|
||||||
|
topButton: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
centerArea: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '28%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 30,
|
||||||
|
},
|
||||||
|
avatarOuter: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
borderRadius: 50,
|
||||||
|
backgroundColor: '#3A3A5C',
|
||||||
|
},
|
||||||
|
avatarPlaceholder: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
fontSize: 40,
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
peerName: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
marginBottom: 6,
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: 280,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'rgba(255, 255, 255, 0.5)',
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
},
|
||||||
|
fullScreenVideo: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: '#1A1A2E',
|
||||||
|
},
|
||||||
|
localVideoContainer: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 100,
|
||||||
|
right: 20,
|
||||||
|
width: 120,
|
||||||
|
height: 160,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: '#2A2A4E',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||||
|
zIndex: 50,
|
||||||
|
},
|
||||||
|
localVideo: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
},
|
||||||
|
videoOverlayInfo: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 100,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 50,
|
||||||
|
},
|
||||||
|
videoPeerName: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
textShadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
textShadowOffset: { width: 0, height: 1 },
|
||||||
|
textShadowRadius: 2,
|
||||||
|
},
|
||||||
|
videoStatus: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'rgba(255, 255, 255, 0.8)',
|
||||||
|
marginTop: 4,
|
||||||
|
textShadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||||
|
textShadowOffset: { width: 0, height: 1 },
|
||||||
|
textShadowRadius: 2,
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 60,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
zIndex: 100,
|
||||||
|
},
|
||||||
|
controlRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 24,
|
||||||
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
controlButton: {
|
||||||
|
alignItems: 'center',
|
||||||
|
width: 70,
|
||||||
|
},
|
||||||
|
controlCircle: {
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.12)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
controlCircleActive: {
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
},
|
||||||
|
controlButtonActive: {},
|
||||||
|
controlLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'rgba(255, 255, 255, 0.55)',
|
||||||
|
marginTop: 8,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
controlLabelActive: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
endCallButton: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
endCallCircle: {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 32,
|
||||||
|
backgroundColor: '#E54D42',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
endCallLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'rgba(255, 255, 255, 0.55)',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
export default CallScreen;
|
||||||
160
src/components/call/CallScreen.web.tsx
Normal file
160
src/components/call/CallScreen.web.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
StatusBar,
|
||||||
|
} from 'react-native';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { callStore } from '../../stores/callStore';
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number): string => {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CallScreen: React.FC = () => {
|
||||||
|
const currentCall = callStore((s) => s.currentCall);
|
||||||
|
const callDuration = callStore((s) => s.callDuration);
|
||||||
|
const endCall = callStore((s) => s.endCall);
|
||||||
|
const isMinimized = callStore((s) => s.isMinimized);
|
||||||
|
|
||||||
|
// Don't render full screen when minimized or no call
|
||||||
|
if (!currentCall || isMinimized) return null;
|
||||||
|
|
||||||
|
const getStatusText = (): string => {
|
||||||
|
switch (currentCall.status) {
|
||||||
|
case 'ringing':
|
||||||
|
return '通话功能暂不支持网页端';
|
||||||
|
case 'connecting':
|
||||||
|
return '通话功能暂不支持网页端';
|
||||||
|
case 'connected':
|
||||||
|
return '通话功能暂不支持网页端';
|
||||||
|
case 'ending':
|
||||||
|
return '通话已结束';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEndCall = () => {
|
||||||
|
endCall('hangup');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
||||||
|
|
||||||
|
{/* Background - single consistent color */}
|
||||||
|
<View style={styles.background} />
|
||||||
|
|
||||||
|
{/* Center: Peer info */}
|
||||||
|
<View style={styles.centerArea}>
|
||||||
|
<View style={styles.avatarOuter}>
|
||||||
|
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
||||||
|
<MaterialCommunityIcons name="web-off" size={50} color="#fff" />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.peerName} numberOfLines={1}>
|
||||||
|
{currentCall.peerName || '未知用户'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.status}>{getStatusText()}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bottom controls */}
|
||||||
|
<View style={styles.controls}>
|
||||||
|
{/* End call - prominent red button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.endCallButton}
|
||||||
|
onPress={handleEndCall}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<View style={styles.endCallCircle}>
|
||||||
|
<MaterialCommunityIcons name="phone-hangup" size={32} color="#fff" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.endCallLabel}>关闭</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const END_CALL_SIZE = 64;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: '#1A1A2E',
|
||||||
|
zIndex: 9999,
|
||||||
|
},
|
||||||
|
background: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: '#1A1A2E',
|
||||||
|
},
|
||||||
|
centerArea: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '28%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 30,
|
||||||
|
},
|
||||||
|
avatarOuter: {
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
borderRadius: 50,
|
||||||
|
backgroundColor: '#3A3A5C',
|
||||||
|
},
|
||||||
|
avatarPlaceholder: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
fontSize: 40,
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
peerName: {
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
marginBottom: 6,
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: 280,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'rgba(255, 255, 255, 0.5)',
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 60,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
endCallButton: {
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
endCallCircle: {
|
||||||
|
width: END_CALL_SIZE,
|
||||||
|
height: END_CALL_SIZE,
|
||||||
|
borderRadius: END_CALL_SIZE / 2,
|
||||||
|
backgroundColor: '#E54D42',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
endCallLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
color: 'rgba(255, 255, 255, 0.55)',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default CallScreen;
|
||||||
159
src/components/call/FloatingCallWindow.tsx
Normal file
159
src/components/call/FloatingCallWindow.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
Image,
|
||||||
|
} from 'react-native';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { callStore } from '../../stores/callStore';
|
||||||
|
|
||||||
|
const formatDuration = (seconds: number): string => {
|
||||||
|
const mins = Math.floor(seconds / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FloatingCallWindow: React.FC = () => {
|
||||||
|
const currentCall = callStore((s) => s.currentCall);
|
||||||
|
const callDuration = callStore((s) => s.callDuration);
|
||||||
|
const isMinimized = callStore((s) => s.isMinimized);
|
||||||
|
const toggleMinimize = callStore((s) => s.toggleMinimize);
|
||||||
|
const endCall = callStore((s) => s.endCall);
|
||||||
|
|
||||||
|
// Only show when there's an active call and it's minimized
|
||||||
|
if (!currentCall || !isMinimized) return null;
|
||||||
|
|
||||||
|
const getStatusText = (): string => {
|
||||||
|
switch (currentCall.status) {
|
||||||
|
case 'calling':
|
||||||
|
return '等待接听...';
|
||||||
|
case 'ringing':
|
||||||
|
return '来电响铃...';
|
||||||
|
case 'connecting':
|
||||||
|
return '连接中...';
|
||||||
|
case 'connected':
|
||||||
|
return formatDuration(callDuration);
|
||||||
|
case 'reconnecting':
|
||||||
|
return '重连中...';
|
||||||
|
case 'ended':
|
||||||
|
return '已结束';
|
||||||
|
case 'failed':
|
||||||
|
return '连接失败';
|
||||||
|
default:
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.window}
|
||||||
|
onPress={toggleMinimize}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
>
|
||||||
|
{/* Avatar */}
|
||||||
|
<View style={styles.avatarContainer}>
|
||||||
|
{currentCall.peerAvatar ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: currentCall.peerAvatar }}
|
||||||
|
style={styles.avatar}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
||||||
|
<Text style={styles.avatarText}>
|
||||||
|
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Info */}
|
||||||
|
<View style={styles.info}>
|
||||||
|
<Text style={styles.name} numberOfLines={1}>
|
||||||
|
{currentCall.peerName || '未知用户'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.status}>{getStatusText()}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* End call button */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.endButton}
|
||||||
|
onPress={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
endCall('hangup');
|
||||||
|
}}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name="phone-hangup" size={20} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 100,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
zIndex: 10000,
|
||||||
|
},
|
||||||
|
window: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#2A2A4E',
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 12,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
avatarContainer: {
|
||||||
|
marginRight: 12,
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: '#3A3A5C',
|
||||||
|
},
|
||||||
|
avatarPlaceholder: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
fontSize: 18,
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
status: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'rgba(255, 255, 255, 0.6)',
|
||||||
|
},
|
||||||
|
endButton: {
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
backgroundColor: '#E54D42',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginLeft: 8,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default FloatingCallWindow;
|
||||||
276
src/components/call/IncomingCallModal.tsx
Normal file
276
src/components/call/IncomingCallModal.tsx
Normal file
@@ -0,0 +1,276 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
Image,
|
||||||
|
Animated,
|
||||||
|
Modal,
|
||||||
|
StatusBar,
|
||||||
|
Dimensions,
|
||||||
|
} from 'react-native';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { callStore } from '../../stores/callStore';
|
||||||
|
|
||||||
|
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
|
||||||
|
const IncomingCallModal: React.FC = () => {
|
||||||
|
const incomingCall = callStore((s) => s.incomingCall);
|
||||||
|
const acceptCall = callStore((s) => s.acceptCall);
|
||||||
|
const rejectCall = callStore((s) => s.rejectCall);
|
||||||
|
|
||||||
|
const pulseAnim = useRef(new Animated.Value(1)).current;
|
||||||
|
const rippleAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (incomingCall) {
|
||||||
|
const pulse = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(pulseAnim, {
|
||||||
|
toValue: 1.06,
|
||||||
|
duration: 1000,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(pulseAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 1000,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
const ripple = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(rippleAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 2000,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(rippleAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 0,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
Animated.parallel([pulse, ripple]).start();
|
||||||
|
return () => {
|
||||||
|
pulse.stop();
|
||||||
|
ripple.stop();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [incomingCall, pulseAnim, rippleAnim]);
|
||||||
|
|
||||||
|
const handleAccept = () => {
|
||||||
|
acceptCall();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = () => {
|
||||||
|
rejectCall();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!incomingCall) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={!!incomingCall}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
statusBarTranslucent
|
||||||
|
>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
||||||
|
|
||||||
|
{/* Avatar with ripple effect - positioned in upper area */}
|
||||||
|
<View style={styles.avatarSection}>
|
||||||
|
{/* Ripple rings */}
|
||||||
|
{[0, 1, 2].map((i) => (
|
||||||
|
<Animated.View
|
||||||
|
key={i}
|
||||||
|
style={[
|
||||||
|
styles.rippleRing,
|
||||||
|
{
|
||||||
|
transform: [
|
||||||
|
{
|
||||||
|
scale: rippleAnim.interpolate({
|
||||||
|
inputRange: [0, 1],
|
||||||
|
outputRange: [1, 1.5 + i * 0.2],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
opacity: rippleAnim.interpolate({
|
||||||
|
inputRange: [0, 0.4, 1],
|
||||||
|
outputRange: [0.25, 0.12, 0],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
style={[styles.avatarContainer, { transform: [{ scale: pulseAnim }] }]}
|
||||||
|
>
|
||||||
|
{incomingCall.callerAvatar ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: incomingCall.callerAvatar }}
|
||||||
|
style={styles.avatar}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
||||||
|
<Text style={styles.avatarText}>
|
||||||
|
{incomingCall.callerName?.charAt(0).toUpperCase() || '?'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Caller info */}
|
||||||
|
<View style={styles.infoSection}>
|
||||||
|
<Text style={styles.callerName} numberOfLines={1}>
|
||||||
|
{incomingCall.callerName || '未知用户'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.callType}>
|
||||||
|
{incomingCall.callType === 'video' ? '视频通话' : '语音通话'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Bottom controls */}
|
||||||
|
<View style={styles.controls}>
|
||||||
|
{/* Decline */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={handleReject}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<View style={styles.declineCircle}>
|
||||||
|
<MaterialCommunityIcons name="phone-hangup" size={28} color="#fff" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.actionLabel}>拒绝</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* Accept */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={handleAccept}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<View style={styles.acceptCircle}>
|
||||||
|
<MaterialCommunityIcons name="phone" size={28} color="#fff" />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.actionLabel}>接听</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const AVATAR_SIZE = 100;
|
||||||
|
const ACTION_CIRCLE_SIZE = 60;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#0D0D0D',
|
||||||
|
},
|
||||||
|
avatarSection: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '18%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: AVATAR_SIZE + 60,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
rippleRing: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: AVATAR_SIZE + 16,
|
||||||
|
height: AVATAR_SIZE + 16,
|
||||||
|
borderRadius: (AVATAR_SIZE + 16) / 2,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#4CD964',
|
||||||
|
},
|
||||||
|
avatarContainer: {
|
||||||
|
width: AVATAR_SIZE + 8,
|
||||||
|
height: AVATAR_SIZE + 8,
|
||||||
|
borderRadius: (AVATAR_SIZE + 8) / 2,
|
||||||
|
backgroundColor: '#4CD964',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: AVATAR_SIZE,
|
||||||
|
height: AVATAR_SIZE,
|
||||||
|
borderRadius: AVATAR_SIZE / 2,
|
||||||
|
backgroundColor: '#3A3A5C',
|
||||||
|
},
|
||||||
|
avatarPlaceholder: {
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
fontSize: 36,
|
||||||
|
color: '#fff',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
infoSection: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: '42%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: 40,
|
||||||
|
},
|
||||||
|
callerName: {
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
marginBottom: 6,
|
||||||
|
textAlign: 'center',
|
||||||
|
maxWidth: 280,
|
||||||
|
},
|
||||||
|
callType: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: 'rgba(255, 255, 255, 0.5)',
|
||||||
|
letterSpacing: 0.3,
|
||||||
|
},
|
||||||
|
controls: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: '12%',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 60,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
alignItems: 'center',
|
||||||
|
width: 80,
|
||||||
|
},
|
||||||
|
declineCircle: {
|
||||||
|
width: ACTION_CIRCLE_SIZE,
|
||||||
|
height: ACTION_CIRCLE_SIZE,
|
||||||
|
borderRadius: ACTION_CIRCLE_SIZE / 2,
|
||||||
|
backgroundColor: '#E54D42',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
acceptCircle: {
|
||||||
|
width: ACTION_CIRCLE_SIZE,
|
||||||
|
height: ACTION_CIRCLE_SIZE,
|
||||||
|
borderRadius: ACTION_CIRCLE_SIZE / 2,
|
||||||
|
backgroundColor: '#4CD964',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
actionLabel: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: 'rgba(255, 255, 255, 0.6)',
|
||||||
|
marginTop: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default IncomingCallModal;
|
||||||
3
src/components/call/index.ts
Normal file
3
src/components/call/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as CallScreen } from './CallScreen';
|
||||||
|
export { default as IncomingCallModal } from './IncomingCallModal';
|
||||||
|
export { default as FloatingCallWindow } from './FloatingCallWindow';
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle } from 'react-native';
|
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle, Platform } from 'react-native';
|
||||||
import { useAppColors, fontSizes } from '../../theme';
|
import { useAppColors, fontSizes, fontWeights } from '../../theme';
|
||||||
|
|
||||||
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
|
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
|
||||||
|
|
||||||
@@ -16,38 +16,45 @@ interface CustomTextProps extends Omit<TextProps, 'style'> {
|
|||||||
numberOfLines?: number;
|
numberOfLines?: number;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
style?: TextStyle | TextStyle[];
|
style?: TextStyle | TextStyle[];
|
||||||
|
weight?: 'regular' | 'medium' | 'semibold' | 'bold';
|
||||||
}
|
}
|
||||||
|
|
||||||
const variantStyles: Record<TextVariant, object> = {
|
const variantStyles: Record<TextVariant, object> = {
|
||||||
h1: {
|
h1: {
|
||||||
fontSize: fontSizes['4xl'],
|
fontSize: fontSizes['4xl'],
|
||||||
fontWeight: '700',
|
fontWeight: fontWeights.bold,
|
||||||
lineHeight: fontSizes['4xl'] * 1.4,
|
lineHeight: fontSizes['4xl'] * 1.3,
|
||||||
|
letterSpacing: -0.5,
|
||||||
},
|
},
|
||||||
h2: {
|
h2: {
|
||||||
fontSize: fontSizes['3xl'],
|
fontSize: fontSizes['3xl'],
|
||||||
fontWeight: '600',
|
fontWeight: fontWeights.semibold,
|
||||||
lineHeight: fontSizes['3xl'] * 1.4,
|
lineHeight: fontSizes['3xl'] * 1.3,
|
||||||
|
letterSpacing: -0.3,
|
||||||
},
|
},
|
||||||
h3: {
|
h3: {
|
||||||
fontSize: fontSizes['2xl'],
|
fontSize: fontSizes['2xl'],
|
||||||
fontWeight: '600',
|
fontWeight: fontWeights.semibold,
|
||||||
lineHeight: fontSizes['2xl'] * 1.3,
|
lineHeight: fontSizes['2xl'] * 1.3,
|
||||||
|
letterSpacing: -0.2,
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
fontSize: fontSizes.md,
|
fontSize: fontSizes.md,
|
||||||
fontWeight: '400',
|
fontWeight: fontWeights.regular,
|
||||||
lineHeight: fontSizes.md * 1.5,
|
lineHeight: fontSizes.md * 1.6,
|
||||||
|
letterSpacing: 0.1,
|
||||||
},
|
},
|
||||||
caption: {
|
caption: {
|
||||||
fontSize: fontSizes.sm,
|
fontSize: fontSizes.sm,
|
||||||
fontWeight: '400',
|
fontWeight: fontWeights.regular,
|
||||||
lineHeight: fontSizes.sm * 1.4,
|
lineHeight: fontSizes.sm * 1.5,
|
||||||
|
letterSpacing: 0.2,
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: fontSizes.xs,
|
fontSize: fontSizes.xs,
|
||||||
fontWeight: '500',
|
fontWeight: fontWeights.medium,
|
||||||
lineHeight: fontSizes.xs * 1.4,
|
lineHeight: fontSizes.xs * 1.4,
|
||||||
|
letterSpacing: 0.3,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,6 +62,7 @@ const Text: React.FC<CustomTextProps> = ({
|
|||||||
children,
|
children,
|
||||||
variant = 'body',
|
variant = 'body',
|
||||||
color,
|
color,
|
||||||
|
weight,
|
||||||
numberOfLines,
|
numberOfLines,
|
||||||
onPress,
|
onPress,
|
||||||
style,
|
style,
|
||||||
@@ -64,6 +72,7 @@ const Text: React.FC<CustomTextProps> = ({
|
|||||||
const textStyle = [
|
const textStyle = [
|
||||||
styles.base,
|
styles.base,
|
||||||
variantStyles[variant],
|
variantStyles[variant],
|
||||||
|
weight ? { fontWeight: fontWeights[weight] } : null,
|
||||||
color ? { color } : { color: colors.text.primary },
|
color ? { color } : { color: colors.text.primary },
|
||||||
style,
|
style,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { messageRepository } from '../../data/repositories/MessageRepository';
|
import { messageRepository } from '../../data/repositories/MessageRepository';
|
||||||
import { sseClient, SSEEventType } from '../../data/datasources/SSEClient';
|
import { wsClient, WSEventType } from '../../data/datasources/WSClient';
|
||||||
import {
|
import {
|
||||||
Message,
|
Message,
|
||||||
Conversation,
|
Conversation,
|
||||||
@@ -62,7 +62,7 @@ class ProcessMessageUseCase {
|
|||||||
*/
|
*/
|
||||||
initialize(currentUserId: string | null): void {
|
initialize(currentUserId: string | null): void {
|
||||||
this.currentUserId = currentUserId;
|
this.currentUserId = currentUserId;
|
||||||
this.setupSSEListeners();
|
this.setupWSListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,49 +81,49 @@ class ProcessMessageUseCase {
|
|||||||
/**
|
/**
|
||||||
* 设置SSE监听器
|
* 设置SSE监听器
|
||||||
*/
|
*/
|
||||||
private setupSSEListeners(): void {
|
private setupWSListeners(): void {
|
||||||
// 监听私聊消息
|
// 监听私聊消息
|
||||||
const unsubChat = sseClient.on('chat', (message) => {
|
const unsubChat = wsClient.on('chat', (message) => {
|
||||||
this.handleNewMessage(message);
|
this.handleNewMessage(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊消息
|
// 监听群聊消息
|
||||||
const unsubGroupMessage = sseClient.on('group_message', (message) => {
|
const unsubGroupMessage = wsClient.on('group_message', (message) => {
|
||||||
this.handleNewMessage(message);
|
this.handleNewMessage(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听私聊已读回执
|
// 监听私聊已读回执
|
||||||
const unsubRead = sseClient.on('read', (message) => {
|
const unsubRead = wsClient.on('read', (message) => {
|
||||||
this.handleReadReceipt(message);
|
this.handleReadReceipt(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊已读回执
|
// 监听群聊已读回执
|
||||||
const unsubGroupRead = sseClient.on('group_read', (message) => {
|
const unsubGroupRead = wsClient.on('group_read', (message) => {
|
||||||
this.handleGroupReadReceipt(message);
|
this.handleGroupReadReceipt(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听私聊消息撤回
|
// 监听私聊消息撤回
|
||||||
const unsubRecall = sseClient.on('recall', (message) => {
|
const unsubRecall = wsClient.on('recall', (message) => {
|
||||||
this.handleRecallMessage(message);
|
this.handleRecallMessage(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊消息撤回
|
// 监听群聊消息撤回
|
||||||
const unsubGroupRecall = sseClient.on('group_recall', (message) => {
|
const unsubGroupRecall = wsClient.on('group_recall', (message) => {
|
||||||
this.handleGroupRecallMessage(message);
|
this.handleGroupRecallMessage(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊输入状态
|
// 监听群聊输入状态
|
||||||
const unsubGroupTyping = sseClient.on('group_typing', (message) => {
|
const unsubGroupTyping = wsClient.on('group_typing', (message) => {
|
||||||
this.handleGroupTyping(message);
|
this.handleGroupTyping(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群通知
|
// 监听群通知
|
||||||
const unsubGroupNotice = sseClient.on('group_notice', (message) => {
|
const unsubGroupNotice = wsClient.on('group_notice', (message) => {
|
||||||
this.handleGroupNotice(message);
|
this.handleGroupNotice(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听连接状态
|
// 监听连接状态
|
||||||
const unsubConnected = sseClient.on('connected', () => {
|
const unsubConnected = wsClient.on('connected', () => {
|
||||||
this.notifySubscribers({
|
this.notifySubscribers({
|
||||||
type: 'connection_changed',
|
type: 'connection_changed',
|
||||||
payload: { connected: true },
|
payload: { connected: true },
|
||||||
@@ -131,7 +131,7 @@ class ProcessMessageUseCase {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubDisconnected = sseClient.on('disconnected', () => {
|
const unsubDisconnected = wsClient.on('disconnected', () => {
|
||||||
this.notifySubscribers({
|
this.notifySubscribers({
|
||||||
type: 'connection_changed',
|
type: 'connection_changed',
|
||||||
payload: { connected: false },
|
payload: { connected: false },
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* SSEClient - SSE连接管理
|
* WSClient - WebSocket连接管理
|
||||||
* 只负责SSE连接管理,提供事件驱动架构
|
* 只负责WebSocket连接管理,提供事件驱动架构
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
sseService,
|
wsService,
|
||||||
WSChatMessage,
|
WSChatMessage,
|
||||||
WSGroupChatMessage,
|
WSGroupChatMessage,
|
||||||
WSReadMessage,
|
WSReadMessage,
|
||||||
@@ -14,13 +14,13 @@ import {
|
|||||||
WSGroupTypingMessage,
|
WSGroupTypingMessage,
|
||||||
WSGroupNoticeMessage,
|
WSGroupNoticeMessage,
|
||||||
WSMessageType,
|
WSMessageType,
|
||||||
} from '../../services/sseService';
|
} from '../../services/wsService';
|
||||||
|
|
||||||
// 事件处理器类型
|
// 事件处理器类型
|
||||||
type EventHandler<T> = (data: T) => void;
|
type EventHandler<T> = (data: T) => void;
|
||||||
|
|
||||||
// SSE事件类型
|
// WS事件类型
|
||||||
export interface SSEEvents {
|
export interface WSEvents {
|
||||||
'chat': WSChatMessage;
|
'chat': WSChatMessage;
|
||||||
'group_message': WSGroupChatMessage;
|
'group_message': WSGroupChatMessage;
|
||||||
'read': WSReadMessage;
|
'read': WSReadMessage;
|
||||||
@@ -34,65 +34,65 @@ export interface SSEEvents {
|
|||||||
'error': Error;
|
'error': Error;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SSEEventType = keyof SSEEvents;
|
export type WSEventType = keyof WSEvents;
|
||||||
|
|
||||||
class SSEClient {
|
class WSClient {
|
||||||
private handlers: Map<SSEEventType, Set<EventHandler<any>>> = new Map();
|
private handlers: Map<WSEventType, Set<EventHandler<any>>> = new Map();
|
||||||
private unsubscribeFns: Array<() => void> = [];
|
private unsubscribeFns: Array<() => void> = [];
|
||||||
private isInitialized = false;
|
private isInitialized = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化SSE监听
|
* 初始化WebSocket监听
|
||||||
*/
|
*/
|
||||||
initialize(): void {
|
initialize(): void {
|
||||||
if (this.isInitialized) return;
|
if (this.isInitialized) return;
|
||||||
|
|
||||||
// 监听私聊消息
|
// 监听私聊消息
|
||||||
const unsubChat = sseService.on('chat', (message) => {
|
const unsubChat = wsService.on('chat', (message) => {
|
||||||
this.emit('chat', message);
|
this.emit('chat', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊消息
|
// 监听群聊消息
|
||||||
const unsubGroupMessage = sseService.on('group_message', (message) => {
|
const unsubGroupMessage = wsService.on('group_message', (message) => {
|
||||||
this.emit('group_message', message);
|
this.emit('group_message', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听私聊已读回执
|
// 监听私聊已读回执
|
||||||
const unsubRead = sseService.on('read', (message) => {
|
const unsubRead = wsService.on('read', (message) => {
|
||||||
this.emit('read', message);
|
this.emit('read', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊已读回执
|
// 监听群聊已读回执
|
||||||
const unsubGroupRead = sseService.on('group_read', (message) => {
|
const unsubGroupRead = wsService.on('group_read', (message) => {
|
||||||
this.emit('group_read', message);
|
this.emit('group_read', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听私聊消息撤回
|
// 监听私聊消息撤回
|
||||||
const unsubRecall = sseService.on('recall', (message) => {
|
const unsubRecall = wsService.on('recall', (message) => {
|
||||||
this.emit('recall', message);
|
this.emit('recall', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊消息撤回
|
// 监听群聊消息撤回
|
||||||
const unsubGroupRecall = sseService.on('group_recall', (message) => {
|
const unsubGroupRecall = wsService.on('group_recall', (message) => {
|
||||||
this.emit('group_recall', message);
|
this.emit('group_recall', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊输入状态
|
// 监听群聊输入状态
|
||||||
const unsubGroupTyping = sseService.on('group_typing', (message) => {
|
const unsubGroupTyping = wsService.on('group_typing', (message) => {
|
||||||
this.emit('group_typing', message);
|
this.emit('group_typing', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群通知
|
// 监听群通知
|
||||||
const unsubGroupNotice = sseService.on('group_notice', (message) => {
|
const unsubGroupNotice = wsService.on('group_notice', (message) => {
|
||||||
this.emit('group_notice', message);
|
this.emit('group_notice', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听连接状态
|
// 监听连接状态
|
||||||
const unsubConnect = sseService.onConnect(() => {
|
const unsubConnect = wsService.onConnect(() => {
|
||||||
this.emit('connected', undefined);
|
this.emit('connected', undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubDisconnect = sseService.onDisconnect(() => {
|
const unsubDisconnect = wsService.onDisconnect(() => {
|
||||||
this.emit('disconnected', undefined);
|
this.emit('disconnected', undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ class SSEClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 销毁SSE监听
|
* 销毁WebSocket监听
|
||||||
*/
|
*/
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.unsubscribeFns.forEach((fn) => fn());
|
this.unsubscribeFns.forEach((fn) => fn());
|
||||||
@@ -125,9 +125,9 @@ class SSEClient {
|
|||||||
/**
|
/**
|
||||||
* 订阅事件
|
* 订阅事件
|
||||||
*/
|
*/
|
||||||
on<T extends SSEEventType>(
|
on<T extends WSEventType>(
|
||||||
event: T,
|
event: T,
|
||||||
handler: EventHandler<SSEEvents[T]>
|
handler: EventHandler<WSEvents[T]>
|
||||||
): () => void {
|
): () => void {
|
||||||
if (!this.handlers.has(event)) {
|
if (!this.handlers.has(event)) {
|
||||||
this.handlers.set(event, new Set());
|
this.handlers.set(event, new Set());
|
||||||
@@ -142,9 +142,9 @@ class SSEClient {
|
|||||||
/**
|
/**
|
||||||
* 触发事件
|
* 触发事件
|
||||||
*/
|
*/
|
||||||
private emit<T extends SSEEventType>(
|
private emit<T extends WSEventType>(
|
||||||
event: T,
|
event: T,
|
||||||
data: SSEEvents[T]
|
data: WSEvents[T]
|
||||||
): void {
|
): void {
|
||||||
const handlers = this.handlers.get(event);
|
const handlers = this.handlers.get(event);
|
||||||
if (handlers) {
|
if (handlers) {
|
||||||
@@ -152,7 +152,7 @@ class SSEClient {
|
|||||||
try {
|
try {
|
||||||
handler(data);
|
handler(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[SSEClient] 事件处理器执行失败: ${event}`, error);
|
console.error(`[WSClient] 事件处理器执行失败: ${event}`, error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -162,23 +162,23 @@ class SSEClient {
|
|||||||
* 检查连接状态
|
* 检查连接状态
|
||||||
*/
|
*/
|
||||||
isConnected(): boolean {
|
isConnected(): boolean {
|
||||||
return sseService.isConnected();
|
return wsService.isConnected();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动SSE连接
|
* 启动WebSocket连接
|
||||||
*/
|
*/
|
||||||
async connect(): Promise<boolean> {
|
async connect(): Promise<boolean> {
|
||||||
return sseService.start();
|
return wsService.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 断开SSE连接
|
* 断开WebSocket连接
|
||||||
*/
|
*/
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
sseService.stop();
|
wsService.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sseClient = new SSEClient();
|
export const wsClient = new WSClient();
|
||||||
export default sseClient;
|
export default wsClient;
|
||||||
289
src/data/repositories/MaterialRepository.ts
Normal file
289
src/data/repositories/MaterialRepository.ts
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
/**
|
||||||
|
* 学习资料 Repository
|
||||||
|
* 提供学习资料的数据访问接口
|
||||||
|
*/
|
||||||
|
import type {
|
||||||
|
MaterialSubject,
|
||||||
|
MaterialFile,
|
||||||
|
MaterialFileType,
|
||||||
|
} from '../../types/material';
|
||||||
|
import { api } from '../../services/api';
|
||||||
|
|
||||||
|
// ==================== 接口定义 ====================
|
||||||
|
|
||||||
|
export interface GetMaterialsParams {
|
||||||
|
subjectId?: string;
|
||||||
|
fileType?: MaterialFileType;
|
||||||
|
keyword?: string;
|
||||||
|
sortBy?: 'createdAt' | 'downloadCount' | 'title';
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaterialsResult {
|
||||||
|
materials: MaterialFile[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== API 响应类型 ====================
|
||||||
|
|
||||||
|
interface SubjectResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string;
|
||||||
|
color: string;
|
||||||
|
description: string;
|
||||||
|
sort_order: number;
|
||||||
|
is_active: boolean;
|
||||||
|
material_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MaterialFileResponse {
|
||||||
|
id: string;
|
||||||
|
subject_id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
file_type: string;
|
||||||
|
file_size: number;
|
||||||
|
file_url: string;
|
||||||
|
file_name: string;
|
||||||
|
download_count: number;
|
||||||
|
author_id: string;
|
||||||
|
tags: string[];
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
subject?: SubjectResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 转换函数 ====================
|
||||||
|
|
||||||
|
function toMaterialSubject(res: SubjectResponse): MaterialSubject {
|
||||||
|
return {
|
||||||
|
id: res.id,
|
||||||
|
name: res.name,
|
||||||
|
icon: res.icon,
|
||||||
|
color: res.color,
|
||||||
|
description: res.description,
|
||||||
|
sort_order: res.sort_order,
|
||||||
|
is_active: res.is_active,
|
||||||
|
material_count: res.material_count,
|
||||||
|
created_at: res.created_at,
|
||||||
|
updated_at: res.updated_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMaterialFile(res: MaterialFileResponse): MaterialFile {
|
||||||
|
return {
|
||||||
|
id: res.id,
|
||||||
|
subject_id: res.subject_id,
|
||||||
|
title: res.title,
|
||||||
|
description: res.description,
|
||||||
|
file_type: res.file_type as MaterialFileType,
|
||||||
|
file_size: res.file_size,
|
||||||
|
file_url: res.file_url,
|
||||||
|
file_name: res.file_name,
|
||||||
|
download_count: res.download_count,
|
||||||
|
author_id: res.author_id,
|
||||||
|
tags: res.tags || [],
|
||||||
|
status: res.status as 'active' | 'inactive',
|
||||||
|
created_at: res.created_at,
|
||||||
|
updated_at: res.updated_at,
|
||||||
|
subject: res.subject ? toMaterialSubject(res.subject) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Repository 实现 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 学习资料数据仓库
|
||||||
|
* 使用真实 API
|
||||||
|
*/
|
||||||
|
export class MaterialRepository {
|
||||||
|
private static instance: MaterialRepository;
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static getInstance(): MaterialRepository {
|
||||||
|
if (!MaterialRepository.instance) {
|
||||||
|
MaterialRepository.instance = new MaterialRepository();
|
||||||
|
}
|
||||||
|
return MaterialRepository.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取学科分类列表
|
||||||
|
*/
|
||||||
|
async getSubjects(): Promise<MaterialSubject[]> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<{ list: SubjectResponse[] }>('/materials/subjects');
|
||||||
|
return response.data.list.map(toMaterialSubject);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取学科列表失败:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID获取学科详情
|
||||||
|
*/
|
||||||
|
async getSubjectById(id: string): Promise<MaterialSubject | null> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<SubjectResponse>(`/materials/subjects/${id}`);
|
||||||
|
return toMaterialSubject(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取学科详情失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取资料列表
|
||||||
|
*/
|
||||||
|
async getMaterials(params: GetMaterialsParams): Promise<MaterialsResult> {
|
||||||
|
const {
|
||||||
|
subjectId,
|
||||||
|
fileType,
|
||||||
|
keyword,
|
||||||
|
sortBy = 'createdAt',
|
||||||
|
sortOrder = 'desc',
|
||||||
|
page = 1,
|
||||||
|
pageSize = 20,
|
||||||
|
} = params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const queryParams: Record<string, any> = {
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (subjectId) {
|
||||||
|
queryParams.subject_id = subjectId;
|
||||||
|
}
|
||||||
|
if (fileType) {
|
||||||
|
queryParams.file_type = fileType;
|
||||||
|
}
|
||||||
|
if (keyword) {
|
||||||
|
queryParams.keyword = keyword;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await api.get<{
|
||||||
|
list: MaterialFileResponse[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
has_more: boolean;
|
||||||
|
}>('/materials', queryParams);
|
||||||
|
|
||||||
|
return {
|
||||||
|
materials: response.data.list.map(toMaterialFile),
|
||||||
|
total: response.data.total,
|
||||||
|
page: response.data.page,
|
||||||
|
pageSize: response.data.page_size,
|
||||||
|
hasMore: response.data.has_more,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取资料列表失败:', error);
|
||||||
|
return {
|
||||||
|
materials: [],
|
||||||
|
total: 0,
|
||||||
|
page: 1,
|
||||||
|
pageSize,
|
||||||
|
hasMore: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据ID获取资料详情
|
||||||
|
*/
|
||||||
|
async getMaterialById(id: string): Promise<MaterialFile | null> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<MaterialFileResponse>(`/materials/${id}`);
|
||||||
|
return toMaterialFile(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取资料详情失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索资料
|
||||||
|
*/
|
||||||
|
async searchMaterials(keyword: string, params?: { fileType?: MaterialFileType }): Promise<MaterialsResult> {
|
||||||
|
return this.getMaterials({
|
||||||
|
keyword,
|
||||||
|
fileType: params?.fileType,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取热门资料
|
||||||
|
*/
|
||||||
|
async getHotMaterials(limit: number = 10): Promise<MaterialFile[]> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<{
|
||||||
|
list: MaterialFileResponse[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
has_more: boolean;
|
||||||
|
}>('/materials', {
|
||||||
|
page: 1,
|
||||||
|
page_size: limit,
|
||||||
|
sort_by: 'download_count',
|
||||||
|
sort_order: 'desc',
|
||||||
|
});
|
||||||
|
return response.data.list.map(toMaterialFile);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取热门资料失败:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最新资料
|
||||||
|
*/
|
||||||
|
async getLatestMaterials(limit: number = 10): Promise<MaterialFile[]> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<{
|
||||||
|
list: MaterialFileResponse[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
has_more: boolean;
|
||||||
|
}>('/materials', {
|
||||||
|
page: 1,
|
||||||
|
page_size: limit,
|
||||||
|
sort_by: 'created_at',
|
||||||
|
sort_order: 'desc',
|
||||||
|
});
|
||||||
|
return response.data.list.map(toMaterialFile);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取最新资料失败:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取资料的下载链接(增加下载次数)
|
||||||
|
*/
|
||||||
|
async downloadMaterial(id: string): Promise<{ file_url: string; file_name: string } | null> {
|
||||||
|
try {
|
||||||
|
const response = await api.get<{ file_url: string; file_name: string }>(`/materials/${id}/download`);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取下载链接失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例
|
||||||
|
export const materialRepository = MaterialRepository.getInstance();
|
||||||
@@ -14,6 +14,7 @@ import { postManager } from '../stores/postManager';
|
|||||||
import { groupManager } from '../stores/groupManager';
|
import { groupManager } from '../stores/groupManager';
|
||||||
import { userManager } from '../stores/userManager';
|
import { userManager } from '../stores/userManager';
|
||||||
import { messageManager } from '../stores/messageManager';
|
import { messageManager } from '../stores/messageManager';
|
||||||
|
import type { PostListTab } from '../stores/postListSources';
|
||||||
|
|
||||||
// ==================== 预取配置 ====================
|
// ==================== 预取配置 ====================
|
||||||
|
|
||||||
@@ -134,7 +135,7 @@ const prefetchService = new PrefetchService();
|
|||||||
* 预取帖子数据
|
* 预取帖子数据
|
||||||
* @param types 帖子类型数组
|
* @param types 帖子类型数组
|
||||||
*/
|
*/
|
||||||
function prefetchPosts(types: string[] = ['latest', 'hot']): void {
|
function prefetchPosts(types: PostListTab[] = ['latest', 'hot']): void {
|
||||||
types.forEach((type, index) => {
|
types.forEach((type, index) => {
|
||||||
prefetchService.schedule({
|
prefetchService.schedule({
|
||||||
key: `posts:${type}:1`,
|
key: `posts:${type}:1`,
|
||||||
@@ -279,7 +280,7 @@ export function usePrefetch() {
|
|||||||
const hasInitialPrefetched = useRef(false);
|
const hasInitialPrefetched = useRef(false);
|
||||||
|
|
||||||
/** 预取帖子 */
|
/** 预取帖子 */
|
||||||
const prefetchPostsData = useCallback((types?: string[]) => {
|
const prefetchPostsData = useCallback((types?: PostListTab[]) => {
|
||||||
prefetchPosts(types);
|
prefetchPosts(types);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -155,3 +155,17 @@ export function hrefAuthRegister(): string {
|
|||||||
export function hrefAuthForgot(): string {
|
export function hrefAuthForgot(): string {
|
||||||
return '/forgot-password';
|
return '/forgot-password';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Materials (学习资料) ====================
|
||||||
|
|
||||||
|
export function hrefMaterials(): string {
|
||||||
|
return '/apps/materials';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hrefMaterialSubject(subjectId: string): string {
|
||||||
|
return `/apps/materials/subject?subjectId=${encodeURIComponent(subjectId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hrefMaterialDetail(materialId: string): string {
|
||||||
|
return `/apps/materials/detail?materialId=${encodeURIComponent(materialId)}`;
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
type AppColors,
|
type AppColors,
|
||||||
} from '../../theme';
|
} from '../../theme';
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
import { Text, ResponsiveContainer } from '../../components/common';
|
import { Text } from '../../components/common';
|
||||||
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
|
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
|
||||||
|
|
||||||
type AppItem = {
|
type AppItem = {
|
||||||
@@ -37,8 +37,18 @@ const APP_ENTRIES: AppItem[] = [
|
|||||||
icon: 'calendar-week',
|
icon: 'calendar-week',
|
||||||
href: hrefs.hrefSchedule(),
|
href: hrefs.hrefSchedule(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'materials',
|
||||||
|
title: '学习资料',
|
||||||
|
subtitle: '按学科分类的学习资源网盘',
|
||||||
|
icon: 'folder-multiple',
|
||||||
|
href: hrefs.hrefMaterials(),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 应用卡片最大宽度设置
|
||||||
|
const APP_CARD_MAX_WIDTH = 720;
|
||||||
|
|
||||||
export const AppsScreen: React.FC = () => {
|
export const AppsScreen: React.FC = () => {
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const resolvedScheme = useResolvedColorScheme();
|
const resolvedScheme = useResolvedColorScheme();
|
||||||
@@ -56,6 +66,9 @@ export const AppsScreen: React.FC = () => {
|
|||||||
shadowOpacity: 0.06,
|
shadowOpacity: 0.06,
|
||||||
shadowRadius: 8,
|
shadowRadius: 8,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
|
maxWidth: APP_CARD_MAX_WIDTH,
|
||||||
|
alignSelf: 'center' as const,
|
||||||
|
width: '100%' as const,
|
||||||
}),
|
}),
|
||||||
[colors]
|
[colors]
|
||||||
);
|
);
|
||||||
@@ -129,28 +142,16 @@ export const AppsScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isWideScreen ? (
|
<ScrollView
|
||||||
<ResponsiveContainer maxWidth={720}>
|
style={styles.scroll}
|
||||||
<ScrollView
|
contentContainerStyle={[
|
||||||
style={styles.scroll}
|
styles.scrollContent,
|
||||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}
|
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
|
||||||
showsVerticalScrollIndicator={false}
|
]}
|
||||||
>
|
showsVerticalScrollIndicator={false}
|
||||||
{renderTiles()}
|
>
|
||||||
</ScrollView>
|
{renderTiles()}
|
||||||
</ResponsiveContainer>
|
</ScrollView>
|
||||||
) : (
|
|
||||||
<ScrollView
|
|
||||||
style={styles.scroll}
|
|
||||||
contentContainerStyle={[
|
|
||||||
styles.scrollContent,
|
|
||||||
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
|
|
||||||
]}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{renderTiles()}
|
|
||||||
</ScrollView>
|
|
||||||
)}
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|||||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||||
import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme';
|
import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme';
|
||||||
import { Post } from '../../types';
|
import { Post } from '../../types';
|
||||||
import { useUserStore, useHomeTabBarVisibilityStore } from '../../stores';
|
import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores';
|
||||||
import { useCurrentUser } from '../../stores/authStore';
|
import { useCurrentUser } from '../../stores/authStore';
|
||||||
import { channelService, postService } from '../../services';
|
import { channelService, postService } from '../../services';
|
||||||
import { PostCard, TabBar, SearchBar } from '../../components/business';
|
import { PostCard, TabBar, SearchBar } from '../../components/business';
|
||||||
@@ -212,9 +212,14 @@ export const HomeScreen: React.FC = () => {
|
|||||||
|
|
||||||
const setBottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.setBottomTabBarHiddenByScroll);
|
const setBottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.setBottomTabBarHiddenByScroll);
|
||||||
const bottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
|
const bottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
|
||||||
|
const homeTabPressCount = useHomeTabPressStore((s) => s.pressCount);
|
||||||
const homeListScrollYRef = useRef(0);
|
const homeListScrollYRef = useRef(0);
|
||||||
const tabBarIdleRestoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const tabBarIdleRestoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// FlatList 和 ScrollView 的 ref,用于滚动到顶部
|
||||||
|
const flatListRef = useRef<FlatList<Post> | null>(null);
|
||||||
|
const scrollViewRef = useRef<ScrollView | null>(null);
|
||||||
|
|
||||||
const clearTabBarIdleRestoreTimer = useCallback(() => {
|
const clearTabBarIdleRestoreTimer = useCallback(() => {
|
||||||
if (tabBarIdleRestoreTimerRef.current != null) {
|
if (tabBarIdleRestoreTimerRef.current != null) {
|
||||||
clearTimeout(tabBarIdleRestoreTimerRef.current);
|
clearTimeout(tabBarIdleRestoreTimerRef.current);
|
||||||
@@ -269,6 +274,19 @@ export const HomeScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [showSearch, clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll]);
|
}, [showSearch, clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll]);
|
||||||
|
|
||||||
|
// 监听首页 Tab 点击事件,滚动到顶部
|
||||||
|
useEffect(() => {
|
||||||
|
if (homeTabPressCount > 0) {
|
||||||
|
// 滚动 FlatList 到顶部
|
||||||
|
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||||
|
// 滚动 ScrollView 到顶部(网格模式)
|
||||||
|
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
|
||||||
|
// 重置底部 Tab 栏状态
|
||||||
|
setBottomTabBarHiddenByScroll(false);
|
||||||
|
homeListScrollYRef.current = 0;
|
||||||
|
}
|
||||||
|
}, [homeTabPressCount, setBottomTabBarHiddenByScroll]);
|
||||||
|
|
||||||
/** 横向胶囊条滚动位置:切换频道刷新列表时不重置 */
|
/** 横向胶囊条滚动位置:切换频道刷新列表时不重置 */
|
||||||
const capsuleHScrollRef = useRef<ScrollView | null>(null);
|
const capsuleHScrollRef = useRef<ScrollView | null>(null);
|
||||||
const capsuleScrollXRef = useRef(0);
|
const capsuleScrollXRef = useRef(0);
|
||||||
@@ -819,6 +837,7 @@ export const HomeScreen: React.FC = () => {
|
|||||||
// 移动端使用瀑布流布局(2列)
|
// 移动端使用瀑布流布局(2列)
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
ref={scrollViewRef}
|
||||||
style={styles.waterfallScroll}
|
style={styles.waterfallScroll}
|
||||||
contentContainerStyle={[
|
contentContainerStyle={[
|
||||||
styles.waterfallContainer,
|
styles.waterfallContainer,
|
||||||
@@ -899,6 +918,7 @@ export const HomeScreen: React.FC = () => {
|
|||||||
// 移动端和宽屏都使用单列 FlatList,宽屏下居中显示
|
// 移动端和宽屏都使用单列 FlatList,宽屏下居中显示
|
||||||
return (
|
return (
|
||||||
<FlatList
|
<FlatList
|
||||||
|
ref={flatListRef}
|
||||||
data={displayPosts}
|
data={displayPosts}
|
||||||
renderItem={renderPostList}
|
renderItem={renderPostList}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
|
|||||||
@@ -895,61 +895,48 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 点赞评论(包括回复)
|
// 点赞评论(包括回复)- 优化版
|
||||||
const handleLikeComment = async (comment: Comment) => {
|
const handleLikeComment = async (comment: Comment) => {
|
||||||
// 先保存旧状态用于回滚
|
const { id, is_liked, likes_count } = comment;
|
||||||
const oldIsLiked = comment.is_liked;
|
|
||||||
const oldLikesCount = comment.likes_count;
|
|
||||||
|
|
||||||
// 乐观更新本地状态 - 辅助函数用于更新评论或回复
|
// 乐观更新:切换点赞状态
|
||||||
const updateCommentLike = (c: Comment): Comment => {
|
const newIsLiked = !is_liked;
|
||||||
// 如果是目标评论/回复
|
const newLikesCount = newIsLiked ? likes_count + 1 : Math.max(0, likes_count - 1);
|
||||||
if (c.id === comment.id) {
|
|
||||||
return {
|
// 递归更新评论树中的点赞状态
|
||||||
...c,
|
const updateLikeInTree = (c: Comment): Comment => {
|
||||||
is_liked: !c.is_liked,
|
if (c.id === id) {
|
||||||
likes_count: c.is_liked ? c.likes_count - 1 : c.likes_count + 1
|
return { ...c, is_liked: newIsLiked, likes_count: newLikesCount };
|
||||||
};
|
|
||||||
}
|
}
|
||||||
// 如果有回复,递归查找
|
if (c.replies?.length) {
|
||||||
if (c.replies && c.replies.length > 0) {
|
return { ...c, replies: c.replies.map(r => updateLikeInTree(r)) };
|
||||||
return {
|
|
||||||
...c,
|
|
||||||
replies: c.replies.map(r => updateCommentLike(r))
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return c;
|
return c;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新本地状态
|
setComments(prev => prev.map(c => updateLikeInTree(c)));
|
||||||
setComments(prev => prev.map(c => updateCommentLike(c)));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (oldIsLiked) {
|
const success = newIsLiked
|
||||||
await commentService.unlikeComment(comment.id);
|
? await commentService.likeComment(id)
|
||||||
} else {
|
: await commentService.unlikeComment(id);
|
||||||
await commentService.likeComment(comment.id);
|
|
||||||
|
if (!success) {
|
||||||
|
throw new Error('点赞操作失败');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('评论点赞操作失败:', error);
|
console.error('评论点赞操作失败:', error);
|
||||||
// 失败时回滚状态
|
// 回滚状态
|
||||||
const rollbackCommentLike = (c: Comment): Comment => {
|
const rollbackLikeInTree = (c: Comment): Comment => {
|
||||||
if (c.id === comment.id) {
|
if (c.id === id) {
|
||||||
return {
|
return { ...c, is_liked, likes_count };
|
||||||
...c,
|
|
||||||
is_liked: oldIsLiked,
|
|
||||||
likes_count: oldLikesCount
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (c.replies && c.replies.length > 0) {
|
if (c.replies?.length) {
|
||||||
return {
|
return { ...c, replies: c.replies.map(r => rollbackLikeInTree(r)) };
|
||||||
...c,
|
|
||||||
replies: c.replies.map(r => rollbackCommentLike(r))
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return c;
|
return c;
|
||||||
};
|
};
|
||||||
setComments(prev => prev.map(c => rollbackCommentLike(c)));
|
setComments(prev => prev.map(c => rollbackLikeInTree(c)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1373,7 +1360,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<CommentItem
|
<CommentItem
|
||||||
comment={item}
|
comment={item}
|
||||||
onLike={() => handleLikeComment(item)}
|
onLike={handleLikeComment}
|
||||||
onReply={() => handleReply(item)}
|
onReply={() => handleReply(item)}
|
||||||
onUserPress={() => authorId && handleUserPress(authorId)}
|
onUserPress={() => authorId && handleUserPress(authorId)}
|
||||||
floorNumber={index + 1}
|
floorNumber={index + 1}
|
||||||
|
|||||||
500
src/screens/material/MaterialDetailScreen.tsx
Normal file
500
src/screens/material/MaterialDetailScreen.tsx
Normal file
@@ -0,0 +1,500 @@
|
|||||||
|
/**
|
||||||
|
* 资料详情页:展示资料详细信息
|
||||||
|
*/
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
StatusBar,
|
||||||
|
Alert,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
import {
|
||||||
|
spacing,
|
||||||
|
fontSizes,
|
||||||
|
borderRadius,
|
||||||
|
shadows,
|
||||||
|
useAppColors,
|
||||||
|
useResolvedColorScheme,
|
||||||
|
type AppColors,
|
||||||
|
} from '../../theme';
|
||||||
|
import { Text, ResponsiveContainer, Loading } from '../../components/common';
|
||||||
|
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
|
||||||
|
import type { MaterialFile } from '../../types/material';
|
||||||
|
import { materialRepository } from '../../data/repositories/MaterialRepository';
|
||||||
|
import {
|
||||||
|
MATERIAL_FILE_TYPE_ICONS,
|
||||||
|
MATERIAL_FILE_TYPE_COLORS,
|
||||||
|
MATERIAL_FILE_TYPE_NAMES,
|
||||||
|
formatFileSize,
|
||||||
|
} from '../../types/material';
|
||||||
|
|
||||||
|
export const MaterialDetailScreen: React.FC = () => {
|
||||||
|
const colors = useAppColors();
|
||||||
|
const resolvedScheme = useResolvedColorScheme();
|
||||||
|
const statusBarStyle = resolvedScheme === 'dark' ? 'light-content' : 'dark-content';
|
||||||
|
const styles = useMemo(() => createMaterialDetailStyles(colors), [colors]);
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useLocalSearchParams<{ materialId: string }>();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { isMobile } = useResponsive();
|
||||||
|
const isWideScreen = useBreakpointGTE('lg');
|
||||||
|
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
|
||||||
|
|
||||||
|
const [material, setMaterial] = useState<MaterialFile | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const scrollBottomInset = isMobile ? 88 + insets.bottom + spacing.md : spacing['3xl'];
|
||||||
|
|
||||||
|
const loadMaterial = useCallback(async () => {
|
||||||
|
if (!params.materialId) {
|
||||||
|
setError('缺少资料ID');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const data = await materialRepository.getMaterialById(params.materialId);
|
||||||
|
setMaterial(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError('加载资料详情失败');
|
||||||
|
console.error('Failed to load material:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}, [params.materialId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMaterial();
|
||||||
|
}, [loadMaterial]);
|
||||||
|
|
||||||
|
const handleDownload = useCallback(() => {
|
||||||
|
if (!material) return;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
'下载资料',
|
||||||
|
`确定要下载「${material.title}」吗?`,
|
||||||
|
[
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '下载',
|
||||||
|
onPress: () => {
|
||||||
|
// Mock下载逻辑
|
||||||
|
Alert.alert('提示', '下载功能开发中,请稍后再试');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}, [material]);
|
||||||
|
|
||||||
|
const handlePreview = useCallback(() => {
|
||||||
|
if (!material?.file_url) return;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
'在线预览',
|
||||||
|
'在线预览功能开发中,请稍后再试',
|
||||||
|
[{ text: '确定', style: 'default' }]
|
||||||
|
);
|
||||||
|
}, [material]);
|
||||||
|
|
||||||
|
const formatDate = useCallback((dateStr: string) => {
|
||||||
|
const date = new Date(dateStr);
|
||||||
|
return date.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const cardStyle = useMemo(
|
||||||
|
() => ({
|
||||||
|
backgroundColor: colors.background.paper,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden' as const,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 2,
|
||||||
|
}),
|
||||||
|
[colors]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centerContainer}>
|
||||||
|
<Loading size="lg" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !material) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centerContainer}>
|
||||||
|
<MaterialCommunityIcons name="alert-circle-outline" size={48} color={colors.text.hint} />
|
||||||
|
<Text variant="body" color={colors.text.secondary} style={styles.errorText}>
|
||||||
|
{error || '资料不存在'}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} style={styles.retryButton}>
|
||||||
|
<Text variant="body" color={colors.primary.main}>
|
||||||
|
返回
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileColor = MATERIAL_FILE_TYPE_COLORS[material.file_type];
|
||||||
|
const iconName = MATERIAL_FILE_TYPE_ICONS[material.file_type];
|
||||||
|
const fileTypeName = MATERIAL_FILE_TYPE_NAMES[material.file_type];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 文件信息卡片 */}
|
||||||
|
<View style={[styles.fileCard, cardStyle]}>
|
||||||
|
<View style={[styles.fileTypeBanner, { backgroundColor: fileColor }]}>
|
||||||
|
<MaterialCommunityIcons name={iconName as any} size={48} color="#FFFFFF" />
|
||||||
|
<Text variant="body" style={styles.fileTypeName}>
|
||||||
|
{fileTypeName}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.fileInfo}>
|
||||||
|
<Text variant="h3" style={styles.fileName}>
|
||||||
|
{material.title}
|
||||||
|
</Text>
|
||||||
|
{material.description && (
|
||||||
|
<Text variant="body" color={colors.text.secondary} style={styles.fileDescription}>
|
||||||
|
{material.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<View style={styles.fileMetaRow}>
|
||||||
|
<View style={styles.metaItem}>
|
||||||
|
<MaterialCommunityIcons name="file-outline" size={16} color={colors.text.hint} />
|
||||||
|
<Text variant="caption" color={colors.text.hint}>
|
||||||
|
{formatFileSize(material.file_size)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.metaItem}>
|
||||||
|
<MaterialCommunityIcons name="download" size={16} color={colors.text.hint} />
|
||||||
|
<Text variant="caption" color={colors.text.hint}>
|
||||||
|
{material.download_count} 次下载
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 详细信息 */}
|
||||||
|
<View style={[styles.detailCard, cardStyle]}>
|
||||||
|
<Text variant="body" style={styles.sectionTitle}>
|
||||||
|
详细信息
|
||||||
|
</Text>
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text variant="caption" color={colors.text.hint} style={styles.detailLabel}>
|
||||||
|
文件名
|
||||||
|
</Text>
|
||||||
|
<Text variant="body" color={colors.text.primary} style={styles.detailValue}>
|
||||||
|
{material.file_name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text variant="caption" color={colors.text.hint} style={styles.detailLabel}>
|
||||||
|
上传时间
|
||||||
|
</Text>
|
||||||
|
<Text variant="body" color={colors.text.primary} style={styles.detailValue}>
|
||||||
|
{formatDate(material.created_at)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{material.author_name && (
|
||||||
|
<View style={styles.detailRow}>
|
||||||
|
<Text variant="caption" color={colors.text.hint} style={styles.detailLabel}>
|
||||||
|
上传者
|
||||||
|
</Text>
|
||||||
|
<Text variant="body" color={colors.text.primary} style={styles.detailValue}>
|
||||||
|
{material.author_name}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{material.tags && material.tags.length > 0 && (
|
||||||
|
<View style={styles.tagContainer}>
|
||||||
|
<Text variant="caption" color={colors.text.hint} style={styles.detailLabel}>
|
||||||
|
标签
|
||||||
|
</Text>
|
||||||
|
<View style={styles.tags}>
|
||||||
|
{material.tags.map((tag, index) => (
|
||||||
|
<View key={index} style={styles.tag}>
|
||||||
|
<Text variant="caption" color={colors.primary.main}>
|
||||||
|
{tag}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<View style={styles.actionButtons}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.previewButton, { backgroundColor: colors.background.paper }]}
|
||||||
|
onPress={handlePreview}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name="eye-outline" size={20} color={colors.text.primary} />
|
||||||
|
<Text variant="body" color={colors.text.primary} style={styles.actionButtonText}>
|
||||||
|
在线预览
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.downloadButton, { backgroundColor: colors.primary.main }]}
|
||||||
|
onPress={handleDownload}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name="download" size={20} color={colors.primary.contrast} />
|
||||||
|
<Text variant="body" color={colors.primary.contrast} style={styles.actionButtonText}>
|
||||||
|
下载资料
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
|
<StatusBar barStyle={statusBarStyle} backgroundColor={colors.background.default} />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<View style={[styles.header, isWideScreen && styles.headerWide]}>
|
||||||
|
<View style={styles.headerLeft}>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||||
|
<MaterialCommunityIcons name="arrow-left" size={24} color={colors.text.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View style={styles.headerCenter}>
|
||||||
|
<Text style={isWideScreen ? [styles.headerTitle, styles.headerTitleWide] : styles.headerTitle}>
|
||||||
|
资料详情
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.headerRight}>
|
||||||
|
<TouchableOpacity style={styles.shareButton}>
|
||||||
|
<MaterialCommunityIcons name="share-variant" size={22} color={colors.text.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
{isWideScreen ? (
|
||||||
|
<ResponsiveContainer maxWidth={800}>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scroll}
|
||||||
|
contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{renderContent()}
|
||||||
|
</ScrollView>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scroll}
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.scrollContent,
|
||||||
|
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
|
||||||
|
]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{renderContent()}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MaterialDetailScreen;
|
||||||
|
|
||||||
|
function createMaterialDetailStyles(colors: AppColors) {
|
||||||
|
const headerBg = colors.background.default;
|
||||||
|
return StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.background.default,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
backgroundColor: headerBg,
|
||||||
|
...shadows.sm,
|
||||||
|
},
|
||||||
|
headerWide: {
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.lg,
|
||||||
|
},
|
||||||
|
headerLeft: {
|
||||||
|
width: 44,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
padding: spacing.xs,
|
||||||
|
},
|
||||||
|
headerCenter: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: colors.text.primary,
|
||||||
|
},
|
||||||
|
headerTitleWide: {
|
||||||
|
fontSize: 22,
|
||||||
|
},
|
||||||
|
headerRight: {
|
||||||
|
width: 44,
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
shareButton: {
|
||||||
|
padding: spacing.xs,
|
||||||
|
},
|
||||||
|
scroll: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: headerBg,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingTop: spacing.md,
|
||||||
|
flexGrow: 1,
|
||||||
|
backgroundColor: headerBg,
|
||||||
|
},
|
||||||
|
centerContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: spacing['3xl'],
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
marginTop: spacing.md,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
marginTop: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
|
fileCard: {
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
fileTypeBanner: {
|
||||||
|
height: 100,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
fileTypeName: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: fontSizes.lg,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
},
|
||||||
|
fileInfo: {
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
fileName: {
|
||||||
|
color: colors.text.primary,
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: fontSizes.lg,
|
||||||
|
},
|
||||||
|
fileDescription: {
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
lineHeight: fontSizes.md * 1.5,
|
||||||
|
},
|
||||||
|
fileMetaRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
marginTop: spacing.md,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
metaItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
detailCard: {
|
||||||
|
padding: spacing.lg,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
color: colors.text.primary,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
detailRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.divider,
|
||||||
|
},
|
||||||
|
detailLabel: {
|
||||||
|
width: 80,
|
||||||
|
},
|
||||||
|
detailValue: {
|
||||||
|
flex: 1,
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
tagContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: spacing.xs,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
tag: {
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
backgroundColor: colors.background.paper,
|
||||||
|
borderRadius: borderRadius.sm,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.primary.main,
|
||||||
|
},
|
||||||
|
actionButtons: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: spacing.md,
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
},
|
||||||
|
previewButton: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
downloadButton: {
|
||||||
|
flex: 1,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
actionButtonText: {
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
318
src/screens/material/MaterialsScreen.tsx
Normal file
318
src/screens/material/MaterialsScreen.tsx
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
/**
|
||||||
|
* 学习资料主页:学科分类网格
|
||||||
|
*/
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
StatusBar,
|
||||||
|
RefreshControl,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
import {
|
||||||
|
spacing,
|
||||||
|
fontSizes,
|
||||||
|
borderRadius,
|
||||||
|
shadows,
|
||||||
|
useAppColors,
|
||||||
|
useResolvedColorScheme,
|
||||||
|
type AppColors,
|
||||||
|
} from '../../theme';
|
||||||
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
|
import { Text, Loading } from '../../components/common';
|
||||||
|
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
|
||||||
|
|
||||||
|
// 学科卡片最大宽度设置
|
||||||
|
const SUBJECT_CARD_MAX_WIDTH = 720;
|
||||||
|
import type { MaterialSubject } from '../../types/material';
|
||||||
|
import { materialRepository } from '../../data/repositories/MaterialRepository';
|
||||||
|
|
||||||
|
export const MaterialsScreen: React.FC = () => {
|
||||||
|
const colors = useAppColors();
|
||||||
|
const resolvedScheme = useResolvedColorScheme();
|
||||||
|
const statusBarStyle = resolvedScheme === 'dark' ? 'light-content' : 'dark-content';
|
||||||
|
const styles = useMemo(() => createMaterialsStyles(colors), [colors]);
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { isMobile } = useResponsive();
|
||||||
|
const isWideScreen = useBreakpointGTE('lg');
|
||||||
|
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
|
||||||
|
|
||||||
|
const [subjects, setSubjects] = useState<MaterialSubject[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const scrollBottomInset = isMobile ? 88 + insets.bottom + spacing.md : spacing['3xl'];
|
||||||
|
|
||||||
|
const loadSubjects = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const data = await materialRepository.getSubjects();
|
||||||
|
setSubjects(data);
|
||||||
|
} catch (err) {
|
||||||
|
setError('加载学科列表失败');
|
||||||
|
console.error('Failed to load subjects:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadSubjects();
|
||||||
|
}, [loadSubjects]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
loadSubjects();
|
||||||
|
}, [loadSubjects]);
|
||||||
|
|
||||||
|
const onOpenSubject = useCallback(
|
||||||
|
(subjectId: string) => {
|
||||||
|
router.push(hrefs.hrefMaterialSubject(subjectId));
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const cardStyle = useMemo(
|
||||||
|
() => ({
|
||||||
|
backgroundColor: colors.background.paper,
|
||||||
|
borderRadius: 16,
|
||||||
|
overflow: 'hidden' as const,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.06,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 2,
|
||||||
|
maxWidth: SUBJECT_CARD_MAX_WIDTH,
|
||||||
|
alignSelf: 'center' as const,
|
||||||
|
width: '100%' as const,
|
||||||
|
}),
|
||||||
|
[colors]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderSubjectCard = (subject: MaterialSubject) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={subject.id}
|
||||||
|
style={[styles.subjectCard, cardStyle]}
|
||||||
|
onPress={() => onOpenSubject(subject.id)}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
<View style={[styles.subjectIconCircle, { backgroundColor: subject.color }]}>
|
||||||
|
<MaterialCommunityIcons name={subject.icon as any} size={28} color="#FFFFFF" />
|
||||||
|
</View>
|
||||||
|
<View style={styles.subjectContent}>
|
||||||
|
<Text variant="body" style={styles.subjectName}>
|
||||||
|
{subject.name}
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption" color={colors.text.secondary} style={styles.subjectDesc}>
|
||||||
|
{subject.description || `${subject.material_count} 份资料`}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.subjectMeta}>
|
||||||
|
<Text variant="caption" color={colors.text.hint}>
|
||||||
|
{subject.material_count}
|
||||||
|
</Text>
|
||||||
|
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centerContainer}>
|
||||||
|
<Loading size="lg" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centerContainer}>
|
||||||
|
<MaterialCommunityIcons name="alert-circle-outline" size={48} color={colors.text.hint} />
|
||||||
|
<Text variant="body" color={colors.text.secondary} style={styles.errorText}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={loadSubjects} style={styles.retryButton}>
|
||||||
|
<Text variant="body" color={colors.primary.main}>
|
||||||
|
重试
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Text variant="caption" color={colors.text.secondary} style={styles.pageSubtitle}>
|
||||||
|
按学科分类浏览学习资料
|
||||||
|
</Text>
|
||||||
|
<View style={styles.subjectsGrid}>{subjects.map(renderSubjectCard)}</View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
|
<StatusBar barStyle={statusBarStyle} backgroundColor={colors.background.default} />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<View style={[styles.header, isWideScreen && styles.headerWide]}>
|
||||||
|
<View style={styles.headerLeft}>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||||
|
<MaterialCommunityIcons name="arrow-left" size={24} color={colors.text.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View style={styles.headerCenter}>
|
||||||
|
<Text style={isWideScreen ? [styles.headerTitle, styles.headerTitleWide] : styles.headerTitle}>
|
||||||
|
学习资料
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.headerRight}>
|
||||||
|
<TouchableOpacity style={styles.searchButton}>
|
||||||
|
<MaterialCommunityIcons name="magnify" size={24} color={colors.text.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scroll}
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.scrollContent,
|
||||||
|
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
|
||||||
|
]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isRefreshing}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
tintColor={colors.primary.main}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{renderContent()}
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default MaterialsScreen;
|
||||||
|
|
||||||
|
function createMaterialsStyles(colors: AppColors) {
|
||||||
|
const headerBg = colors.background.default;
|
||||||
|
return StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.background.default,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
backgroundColor: headerBg,
|
||||||
|
...shadows.sm,
|
||||||
|
},
|
||||||
|
headerWide: {
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.lg,
|
||||||
|
},
|
||||||
|
headerLeft: {
|
||||||
|
width: 44,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
padding: spacing.xs,
|
||||||
|
},
|
||||||
|
headerCenter: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: colors.text.primary,
|
||||||
|
},
|
||||||
|
headerTitleWide: {
|
||||||
|
fontSize: 22,
|
||||||
|
},
|
||||||
|
headerRight: {
|
||||||
|
width: 44,
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
searchButton: {
|
||||||
|
padding: spacing.xs,
|
||||||
|
},
|
||||||
|
scroll: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: headerBg,
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
paddingTop: spacing.md,
|
||||||
|
flexGrow: 1,
|
||||||
|
backgroundColor: headerBg,
|
||||||
|
},
|
||||||
|
pageSubtitle: {
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
lineHeight: fontSizes.sm * 1.45,
|
||||||
|
},
|
||||||
|
subjectsGrid: {
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
subjectCard: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: spacing.lg,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
|
subjectIconCircle: {
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: borderRadius.full,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
subjectContent: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: spacing.md,
|
||||||
|
marginRight: spacing.sm,
|
||||||
|
},
|
||||||
|
subjectName: {
|
||||||
|
fontWeight: '700',
|
||||||
|
fontSize: fontSizes.md + 1,
|
||||||
|
color: colors.text.primary,
|
||||||
|
},
|
||||||
|
subjectDesc: {
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
subjectMeta: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
centerContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: spacing['3xl'],
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
marginTop: spacing.md,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
marginTop: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
402
src/screens/material/SubjectMaterialsScreen.tsx
Normal file
402
src/screens/material/SubjectMaterialsScreen.tsx
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
/**
|
||||||
|
* 学科资料列表页:展示某个学科下的所有资料
|
||||||
|
*/
|
||||||
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
TouchableOpacity,
|
||||||
|
StatusBar,
|
||||||
|
RefreshControl,
|
||||||
|
FlatList,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { useRouter, useLocalSearchParams } from 'expo-router';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
|
||||||
|
import {
|
||||||
|
spacing,
|
||||||
|
fontSizes,
|
||||||
|
borderRadius,
|
||||||
|
shadows,
|
||||||
|
useAppColors,
|
||||||
|
useResolvedColorScheme,
|
||||||
|
type AppColors,
|
||||||
|
} from '../../theme';
|
||||||
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
|
import { Text, Loading } from '../../components/common';
|
||||||
|
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
|
||||||
|
|
||||||
|
// 资料卡片最大宽度
|
||||||
|
const MATERIAL_CARD_MAX_WIDTH = 720;
|
||||||
|
import type { MaterialSubject, MaterialFile, MaterialFileType } from '../../types/material';
|
||||||
|
import {
|
||||||
|
materialRepository,
|
||||||
|
} from '../../data/repositories/MaterialRepository';
|
||||||
|
import {
|
||||||
|
MATERIAL_FILE_TYPE_ICONS,
|
||||||
|
MATERIAL_FILE_TYPE_COLORS,
|
||||||
|
formatFileSize,
|
||||||
|
} from '../../types/material';
|
||||||
|
|
||||||
|
export const SubjectMaterialsScreen: React.FC = () => {
|
||||||
|
const colors = useAppColors();
|
||||||
|
const resolvedScheme = useResolvedColorScheme();
|
||||||
|
const statusBarStyle = resolvedScheme === 'dark' ? 'light-content' : 'dark-content';
|
||||||
|
const styles = useMemo(() => createSubjectMaterialsStyles(colors), [colors]);
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useLocalSearchParams<{ subjectId: string }>();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const { isMobile } = useResponsive();
|
||||||
|
const isWideScreen = useBreakpointGTE('lg');
|
||||||
|
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
|
||||||
|
|
||||||
|
const [subject, setSubject] = useState<MaterialSubject | null>(null);
|
||||||
|
const [materials, setMaterials] = useState<MaterialFile[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selectedFileType, setSelectedFileType] = useState<MaterialFileType | null>(null);
|
||||||
|
|
||||||
|
const scrollBottomInset = isMobile ? 88 + insets.bottom + spacing.md : spacing['3xl'];
|
||||||
|
|
||||||
|
const loadData = useCallback(async () => {
|
||||||
|
if (!params.subjectId) {
|
||||||
|
setError('缺少学科ID');
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
const [subjectData, materialsData] = await Promise.all([
|
||||||
|
materialRepository.getSubjectById(params.subjectId),
|
||||||
|
materialRepository.getMaterials({ subjectId: params.subjectId }),
|
||||||
|
]);
|
||||||
|
setSubject(subjectData);
|
||||||
|
setMaterials(materialsData.materials);
|
||||||
|
} catch (err) {
|
||||||
|
setError('加载资料列表失败');
|
||||||
|
console.error('Failed to load materials:', err);
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
}, [params.subjectId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
loadData();
|
||||||
|
}, [loadData]);
|
||||||
|
|
||||||
|
const onOpenMaterial = useCallback(
|
||||||
|
(materialId: string) => {
|
||||||
|
router.push(hrefs.hrefMaterialDetail(materialId));
|
||||||
|
},
|
||||||
|
[router]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredMaterials = useMemo(() => {
|
||||||
|
if (!selectedFileType) return materials;
|
||||||
|
return materials.filter((m) => m.file_type === selectedFileType);
|
||||||
|
}, [materials, selectedFileType]);
|
||||||
|
|
||||||
|
const fileTypes: { type: MaterialFileType | null; label: string }[] = [
|
||||||
|
{ type: null, label: '全部' },
|
||||||
|
{ type: 'pdf', label: 'PDF' },
|
||||||
|
{ type: 'word', label: 'Word' },
|
||||||
|
{ type: 'ppt', label: 'PPT' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const cardStyle = useMemo(
|
||||||
|
() => ({
|
||||||
|
backgroundColor: colors.background.paper,
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden' as const,
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 1 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 4,
|
||||||
|
elevation: 1,
|
||||||
|
maxWidth: MATERIAL_CARD_MAX_WIDTH,
|
||||||
|
alignSelf: 'center' as const,
|
||||||
|
width: '100%' as const,
|
||||||
|
}),
|
||||||
|
[colors]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderMaterialItem = ({ item }: { item: MaterialFile }) => {
|
||||||
|
const fileColor = MATERIAL_FILE_TYPE_COLORS[item.file_type];
|
||||||
|
const iconName = MATERIAL_FILE_TYPE_ICONS[item.file_type];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.materialCard, cardStyle]}
|
||||||
|
onPress={() => onOpenMaterial(item.id)}
|
||||||
|
activeOpacity={0.85}
|
||||||
|
>
|
||||||
|
<View style={[styles.fileTypeIcon, { backgroundColor: fileColor }]}>
|
||||||
|
<MaterialCommunityIcons name={iconName as any} size={24} color="#FFFFFF" />
|
||||||
|
</View>
|
||||||
|
<View style={styles.materialContent}>
|
||||||
|
<Text variant="body" style={styles.materialTitle} numberOfLines={2}>
|
||||||
|
{item.title}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.materialMeta}>
|
||||||
|
<Text variant="caption" color={colors.text.hint}>
|
||||||
|
{formatFileSize(item.file_size)}
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption" color={colors.text.hint}>
|
||||||
|
{' · '}
|
||||||
|
</Text>
|
||||||
|
<Text variant="caption" color={colors.text.hint}>
|
||||||
|
{item.download_count} 次下载
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
{item.description && (
|
||||||
|
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
|
||||||
|
{item.description}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderFilterTabs = () => (
|
||||||
|
<View style={styles.filterContainer}>
|
||||||
|
{fileTypes.map(({ type, label }) => (
|
||||||
|
<TouchableOpacity
|
||||||
|
key={label}
|
||||||
|
style={[
|
||||||
|
styles.filterTab,
|
||||||
|
selectedFileType === type && styles.filterTabActive,
|
||||||
|
selectedFileType === type && { backgroundColor: colors.primary.main },
|
||||||
|
]}
|
||||||
|
onPress={() => setSelectedFileType(type)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
variant="caption"
|
||||||
|
color={selectedFileType === type ? colors.primary.contrast : colors.text.secondary}
|
||||||
|
style={styles.filterTabText}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderEmptyComponent = () => (
|
||||||
|
<View style={styles.centerContainer}>
|
||||||
|
<MaterialCommunityIcons name="file-document-outline" size={64} color={colors.text.hint} />
|
||||||
|
<Text variant="body" color={colors.text.secondary} style={styles.emptyText}>
|
||||||
|
{selectedFileType ? '该类型暂无资料' : '暂无资料'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centerContainer}>
|
||||||
|
<Loading size="lg" />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<View style={styles.centerContainer}>
|
||||||
|
<MaterialCommunityIcons name="alert-circle-outline" size={48} color={colors.text.hint} />
|
||||||
|
<Text variant="body" color={colors.text.secondary} style={styles.errorText}>
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity onPress={loadData} style={styles.retryButton}>
|
||||||
|
<Text variant="body" color={colors.primary.main}>
|
||||||
|
重试
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlatList
|
||||||
|
data={filteredMaterials}
|
||||||
|
keyExtractor={(item) => item.id}
|
||||||
|
renderItem={renderMaterialItem}
|
||||||
|
ListHeaderComponent={renderFilterTabs}
|
||||||
|
ListEmptyComponent={renderEmptyComponent}
|
||||||
|
contentContainerStyle={styles.listContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
refreshControl={
|
||||||
|
<RefreshControl
|
||||||
|
refreshing={isRefreshing}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
tintColor={colors.primary.main}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||||
|
<StatusBar barStyle={statusBarStyle} backgroundColor={colors.background.default} />
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<View style={[styles.header, isWideScreen && styles.headerWide]}>
|
||||||
|
<View style={styles.headerLeft}>
|
||||||
|
<TouchableOpacity onPress={() => router.back()} style={styles.backButton}>
|
||||||
|
<MaterialCommunityIcons name="arrow-left" size={24} color={colors.text.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
<View style={styles.headerCenter}>
|
||||||
|
<Text style={isWideScreen ? [styles.headerTitle, styles.headerTitleWide] : styles.headerTitle}>
|
||||||
|
{subject?.name || '资料列表'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.headerRight}>
|
||||||
|
<TouchableOpacity style={styles.searchButton}>
|
||||||
|
<MaterialCommunityIcons name="magnify" size={24} color={colors.text.primary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<View style={[styles.contentWrapper, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
|
||||||
|
{renderContent()}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SubjectMaterialsScreen;
|
||||||
|
|
||||||
|
function createSubjectMaterialsStyles(colors: AppColors) {
|
||||||
|
const headerBg = colors.background.default;
|
||||||
|
return StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.background.default,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
backgroundColor: headerBg,
|
||||||
|
...shadows.sm,
|
||||||
|
},
|
||||||
|
headerWide: {
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
paddingVertical: spacing.lg,
|
||||||
|
},
|
||||||
|
headerLeft: {
|
||||||
|
width: 44,
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
padding: spacing.xs,
|
||||||
|
},
|
||||||
|
headerCenter: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 19,
|
||||||
|
fontWeight: '700',
|
||||||
|
color: colors.text.primary,
|
||||||
|
},
|
||||||
|
headerTitleWide: {
|
||||||
|
fontSize: 22,
|
||||||
|
},
|
||||||
|
headerRight: {
|
||||||
|
width: 44,
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
searchButton: {
|
||||||
|
padding: spacing.xs,
|
||||||
|
},
|
||||||
|
contentWrapper: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
listContent: {
|
||||||
|
paddingTop: spacing.md,
|
||||||
|
},
|
||||||
|
filterContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
filterTab: {
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
borderRadius: borderRadius.full,
|
||||||
|
backgroundColor: colors.background.paper,
|
||||||
|
},
|
||||||
|
filterTabActive: {
|
||||||
|
// backgroundColor set inline
|
||||||
|
},
|
||||||
|
filterTabText: {
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
materialCard: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
fileTypeIcon: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
materialContent: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: spacing.md,
|
||||||
|
marginRight: spacing.sm,
|
||||||
|
},
|
||||||
|
materialTitle: {
|
||||||
|
fontWeight: '600',
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
color: colors.text.primary,
|
||||||
|
},
|
||||||
|
materialMeta: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
centerContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: spacing['3xl'],
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
marginTop: spacing.md,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
marginTop: spacing.md,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
marginTop: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
6
src/screens/material/index.ts
Normal file
6
src/screens/material/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/**
|
||||||
|
* 学习资料屏幕导出
|
||||||
|
*/
|
||||||
|
export { MaterialsScreen } from './MaterialsScreen';
|
||||||
|
export { SubjectMaterialsScreen } from './SubjectMaterialsScreen';
|
||||||
|
export { MaterialDetailScreen } from './MaterialDetailScreen';
|
||||||
@@ -35,7 +35,7 @@ import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
|||||||
import { Text, ImageGallery, ImageGridItem } from '../../components/common';
|
import { Text, ImageGallery, ImageGridItem } from '../../components/common';
|
||||||
import { useAppColors } from '../../theme';
|
import { useAppColors } from '../../theme';
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
import { messageManager } from '../../stores';
|
import { messageManager, callStore } from '../../stores';
|
||||||
import { useBreakpointGTE } from '../../hooks/useResponsive';
|
import { useBreakpointGTE } from '../../hooks/useResponsive';
|
||||||
import {
|
import {
|
||||||
useChatScreen,
|
useChatScreen,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { View, TouchableOpacity, ActivityIndicator, Modal, Alert, Dimensions, StyleSheet, FlatList, ListRenderItem } from 'react-native';
|
import { View, TouchableOpacity, ActivityIndicator, Modal, Alert, Dimensions, StyleSheet, FlatList, ListRenderItem, ScrollView } from 'react-native';
|
||||||
import { Image as ExpoImage } from 'expo-image';
|
import { Image as ExpoImage } from 'expo-image';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
@@ -18,11 +18,11 @@ import { useAppColors, spacing } from '../../../../theme';
|
|||||||
|
|
||||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
|
||||||
// 表情尺寸配置
|
// 表情尺寸配置 - 固定宽度 40px,使用 flex 布局
|
||||||
const EMOJI_SIZES = {
|
const EMOJI_SIZES = {
|
||||||
mobile: { size: 28, itemHeight: 52 },
|
mobile: { size: 24, itemWidth: 40, itemHeight: 40 },
|
||||||
tablet: { size: 32, itemHeight: 60 },
|
tablet: { size: 26, itemWidth: 40, itemHeight: 40 },
|
||||||
desktop: { size: 36, itemHeight: 68 },
|
desktop: { size: 28, itemWidth: 40, itemHeight: 40 },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Tab 类型
|
// Tab 类型
|
||||||
@@ -62,29 +62,30 @@ export const EmojiPanel: React.FC<EmojiPanelProps> = ({
|
|||||||
return EMOJI_SIZES.mobile;
|
return EMOJI_SIZES.mobile;
|
||||||
}, [isWideScreen, isTablet]);
|
}, [isWideScreen, isTablet]);
|
||||||
|
|
||||||
// 表情网格列数
|
|
||||||
const emojiColumns = useMemo(() => {
|
|
||||||
if (isWideScreen) return 10;
|
|
||||||
if (isTablet) return 9;
|
|
||||||
return 8;
|
|
||||||
}, [isWideScreen, isTablet]);
|
|
||||||
|
|
||||||
// 合并样式
|
// 合并样式 - 使用 flex 布局,每个表情固定 40px 宽度
|
||||||
const styles = useMemo(() => {
|
const styles = useMemo(() => {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
...baseStyles,
|
...baseStyles,
|
||||||
|
emojiContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
padding: spacing.sm,
|
||||||
|
},
|
||||||
emojiItem: {
|
emojiItem: {
|
||||||
...baseStyles.emojiItem,
|
width: 40,
|
||||||
width: `${100 / emojiColumns}%`,
|
height: 40,
|
||||||
height: emojiSize.itemHeight,
|
margin: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
emojiText: {
|
emojiText: {
|
||||||
...baseStyles.emojiText,
|
|
||||||
fontSize: emojiSize.size,
|
fontSize: emojiSize.size,
|
||||||
lineHeight: emojiSize.size + 6,
|
lineHeight: emojiSize.size + 4,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}, [emojiSize, emojiColumns, baseStyles]);
|
}, [emojiSize, baseStyles]);
|
||||||
|
|
||||||
// 加载自定义表情
|
// 加载自定义表情
|
||||||
const loadStickers = useCallback(async () => {
|
const loadStickers = useCallback(async () => {
|
||||||
@@ -106,37 +107,26 @@ export const EmojiPanel: React.FC<EmojiPanelProps> = ({
|
|||||||
}
|
}
|
||||||
}, [activeTab, loadStickers]);
|
}, [activeTab, loadStickers]);
|
||||||
|
|
||||||
const renderEmojiItem: ListRenderItem<string> = useCallback(({ item }) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.emojiItem}
|
|
||||||
onPress={() => onInsertEmoji(item)}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<Text style={styles.emojiText}>{item}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
), [styles.emojiItem, styles.emojiText, onInsertEmoji]);
|
|
||||||
|
|
||||||
// 渲染 Emoji 面板(使用 FlatList 虚拟化,降低首次打开卡顿)
|
|
||||||
|
// 渲染 Emoji 面板(使用 flex 布局,每个表情固定 40px 宽度)
|
||||||
const renderEmojiPanel = () => (
|
const renderEmojiPanel = () => (
|
||||||
<FlatList
|
<ScrollView
|
||||||
data={EMOJIS}
|
contentContainerStyle={styles.emojiContainer}
|
||||||
key={emojiColumns}
|
showsVerticalScrollIndicator={true}
|
||||||
numColumns={emojiColumns}
|
|
||||||
keyExtractor={(item, index) => `${item}-${index}`}
|
|
||||||
renderItem={renderEmojiItem}
|
|
||||||
contentContainerStyle={styles.emojiGrid}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
initialNumToRender={emojiColumns * 4}
|
>
|
||||||
maxToRenderPerBatch={emojiColumns * 4}
|
{EMOJIS.map((emoji, index) => (
|
||||||
windowSize={7}
|
<TouchableOpacity
|
||||||
removeClippedSubviews={true}
|
key={`${emoji}-${index}`}
|
||||||
getItemLayout={(_, index) => ({
|
style={styles.emojiItem}
|
||||||
length: emojiSize.itemHeight,
|
onPress={() => onInsertEmoji(emoji)}
|
||||||
offset: emojiSize.itemHeight * Math.floor(index / emojiColumns),
|
activeOpacity={0.7}
|
||||||
index,
|
>
|
||||||
})}
|
<Text style={styles.emojiText}>{emoji}</Text>
|
||||||
/>
|
</TouchableOpacity>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
);
|
);
|
||||||
|
|
||||||
// 添加表情(从相册选择)
|
// 添加表情(从相册选择)
|
||||||
|
|||||||
602
src/screens/message/components/ChatScreen/GroupInfoPanel.web.tsx
Normal file
602
src/screens/message/components/ChatScreen/GroupInfoPanel.web.tsx
Normal file
@@ -0,0 +1,602 @@
|
|||||||
|
/**
|
||||||
|
* GroupInfoPanel.web.tsx
|
||||||
|
* Web端群信息侧边栏组件
|
||||||
|
* 大屏幕模式下从右侧滑入显示
|
||||||
|
* 实现手机端群信息界面的核心功能
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
ScrollView,
|
||||||
|
Dimensions,
|
||||||
|
Animated,
|
||||||
|
Alert,
|
||||||
|
} from 'react-native';
|
||||||
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
|
import { Avatar, Text } from '../../../../components/common';
|
||||||
|
import { useAppColors, spacing, fontSizes, shadows, type AppColors } from '../../../../theme';
|
||||||
|
import { GroupMemberResponse, GroupResponse, GroupAnnouncementResponse } from '../../../../types/dto';
|
||||||
|
import { useBreakpointGTE } from '../../../../hooks/useResponsive';
|
||||||
|
import { groupManager } from '../../../../stores/groupManager';
|
||||||
|
import { groupService } from '../../../../services/groupService';
|
||||||
|
|
||||||
|
const { width: screenWidth } = Dimensions.get('window');
|
||||||
|
const PANEL_WIDTH = 360;
|
||||||
|
|
||||||
|
interface GroupInfoPanelProps {
|
||||||
|
visible: boolean;
|
||||||
|
groupId?: string;
|
||||||
|
groupInfo: GroupResponse | null;
|
||||||
|
conversationId?: string;
|
||||||
|
isPinned?: boolean;
|
||||||
|
currentUserId?: string;
|
||||||
|
onClose: () => void;
|
||||||
|
onTogglePin?: (pinned: boolean) => void;
|
||||||
|
onLeaveGroup?: () => void;
|
||||||
|
onInviteMembers?: () => void;
|
||||||
|
onViewAllMembers?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GroupInfoPanel: React.FC<GroupInfoPanelProps> = ({
|
||||||
|
visible,
|
||||||
|
groupId,
|
||||||
|
groupInfo,
|
||||||
|
conversationId,
|
||||||
|
isPinned,
|
||||||
|
currentUserId,
|
||||||
|
onClose,
|
||||||
|
onTogglePin,
|
||||||
|
onLeaveGroup,
|
||||||
|
onInviteMembers,
|
||||||
|
onViewAllMembers,
|
||||||
|
}) => {
|
||||||
|
const colors = useAppColors();
|
||||||
|
const styles = useMemo(() => createGroupInfoPanelStyles(colors), [colors]);
|
||||||
|
const isWideScreen = useBreakpointGTE('lg');
|
||||||
|
// Web 端不使用 useNativeDriver
|
||||||
|
const slideAnim = React.useRef(new Animated.Value(PANEL_WIDTH)).current;
|
||||||
|
const fadeAnim = React.useRef(new Animated.Value(0)).current;
|
||||||
|
const [members, setMembers] = useState<GroupMemberResponse[]>([]);
|
||||||
|
const [announcements, setAnnouncements] = useState<GroupAnnouncementResponse[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [showAllMembers, setShowAllMembers] = useState(false);
|
||||||
|
const [allMembers, setAllMembers] = useState<GroupMemberResponse[]>([]);
|
||||||
|
const [loadingMoreMembers, setLoadingMoreMembers] = useState(false);
|
||||||
|
|
||||||
|
// 加载群成员(初始只加载10个)
|
||||||
|
const loadMembers = useCallback(async () => {
|
||||||
|
if (!groupId) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await groupManager.getMembers(groupId, 1, 10);
|
||||||
|
setMembers(response.list || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GroupInfoPanel] 加载成员失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [groupId]);
|
||||||
|
|
||||||
|
// 加载全部群成员
|
||||||
|
const loadAllMembers = useCallback(async () => {
|
||||||
|
if (!groupId) return;
|
||||||
|
try {
|
||||||
|
setLoadingMoreMembers(true);
|
||||||
|
const response = await groupManager.getMembers(groupId, 1, 100);
|
||||||
|
setAllMembers(response.list || []);
|
||||||
|
setShowAllMembers(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GroupInfoPanel] 加载全部成员失败:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingMoreMembers(false);
|
||||||
|
}
|
||||||
|
}, [groupId]);
|
||||||
|
|
||||||
|
// 加载群公告
|
||||||
|
const loadAnnouncements = useCallback(async () => {
|
||||||
|
if (!groupId) return;
|
||||||
|
try {
|
||||||
|
const response = await groupService.getAnnouncements(groupId, 1, 10);
|
||||||
|
setAnnouncements(response.list || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[GroupInfoPanel] 加载公告失败:', error);
|
||||||
|
}
|
||||||
|
}, [groupId]);
|
||||||
|
|
||||||
|
// 每次打开面板时重置状态
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setShowAllMembers(false);
|
||||||
|
setAllMembers([]);
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && groupId) {
|
||||||
|
loadMembers();
|
||||||
|
loadAnnouncements();
|
||||||
|
}
|
||||||
|
}, [visible, groupId, loadMembers, loadAnnouncements]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(slideAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
// Web 端不使用 useNativeDriver
|
||||||
|
useNativeDriver: false,
|
||||||
|
}),
|
||||||
|
Animated.timing(fadeAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 300,
|
||||||
|
// Web 端不使用 useNativeDriver
|
||||||
|
useNativeDriver: false,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
} else {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(slideAnim, {
|
||||||
|
toValue: PANEL_WIDTH,
|
||||||
|
duration: 300,
|
||||||
|
// Web 端不使用 useNativeDriver
|
||||||
|
useNativeDriver: false,
|
||||||
|
}),
|
||||||
|
Animated.timing(fadeAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
// Web 端不使用 useNativeDriver
|
||||||
|
useNativeDriver: false,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
}
|
||||||
|
}, [visible, slideAnim, fadeAnim]);
|
||||||
|
|
||||||
|
if (!isWideScreen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取加群方式文本
|
||||||
|
const getJoinTypeText = (joinType?: number): string => {
|
||||||
|
switch (joinType) {
|
||||||
|
case 0:
|
||||||
|
return '允许任何人加入';
|
||||||
|
case 1:
|
||||||
|
return '需要管理员审批';
|
||||||
|
case 2:
|
||||||
|
return '不允许任何人加入';
|
||||||
|
default:
|
||||||
|
return '未知';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* 遮罩层 */}
|
||||||
|
{visible && (
|
||||||
|
<Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.overlayTouchable}
|
||||||
|
onPress={onClose}
|
||||||
|
activeOpacity={1}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 侧边栏面板 */}
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.panel,
|
||||||
|
{
|
||||||
|
transform: [{ translateX: slideAnim }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* 头部 */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.headerTitle}>群信息</Text>
|
||||||
|
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||||
|
<MaterialCommunityIcons name="close" size={24} color="#333" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
||||||
|
{/* 群头像和名称 */}
|
||||||
|
<View style={styles.groupInfoSection}>
|
||||||
|
<Avatar
|
||||||
|
source={groupInfo?.avatar}
|
||||||
|
size={80}
|
||||||
|
name={groupInfo?.name || '群聊'}
|
||||||
|
/>
|
||||||
|
<Text style={styles.groupName}>{groupInfo?.name || '群聊'}</Text>
|
||||||
|
<Text style={styles.memberCount}>
|
||||||
|
{groupInfo?.member_count || members.length || 0} 位成员
|
||||||
|
</Text>
|
||||||
|
{groupInfo?.join_type !== undefined && (
|
||||||
|
<Text style={styles.joinType}>
|
||||||
|
{getJoinTypeText(groupInfo.join_type)}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
{/* 邀请新成员按钮 */}
|
||||||
|
{onInviteMembers && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.inviteButton}
|
||||||
|
onPress={onInviteMembers}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name="account-plus" size={18} color={colors.primary.main} />
|
||||||
|
<Text style={styles.inviteButtonText}>邀请新成员</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 分割线 */}
|
||||||
|
<View style={styles.divider} />
|
||||||
|
|
||||||
|
{/* 群公告 */}
|
||||||
|
{announcements.length > 0 && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.sectionHeader}>
|
||||||
|
<MaterialCommunityIcons name="bullhorn" size={18} color={colors.warning.main} />
|
||||||
|
<Text style={styles.sectionTitle}>群公告</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.noticeBox}>
|
||||||
|
<Text style={styles.noticeText}>{announcements[0].content}</Text>
|
||||||
|
<Text style={styles.noticeTime}>
|
||||||
|
{new Date(announcements[0].created_at).toLocaleDateString()}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 群简介 */}
|
||||||
|
{!!groupInfo?.description && (
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>群简介</Text>
|
||||||
|
<View style={styles.descriptionBox}>
|
||||||
|
<Text style={styles.description}>{groupInfo.description}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 群成员列表 */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<View style={styles.memberListHeader}>
|
||||||
|
<Text style={styles.sectionTitle}>成员列表 ({groupInfo?.member_count || members.length})</Text>
|
||||||
|
{onViewAllMembers && !showAllMembers && members.length >= 10 && (
|
||||||
|
<TouchableOpacity onPress={loadAllMembers} style={styles.viewAllButton} disabled={loadingMoreMembers}>
|
||||||
|
<Text style={styles.viewAllText}>
|
||||||
|
{loadingMoreMembers ? '加载中...' : '查看全部'}
|
||||||
|
</Text>
|
||||||
|
<MaterialCommunityIcons name="chevron-right" size={16} color={colors.primary.main} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
{onViewAllMembers && showAllMembers && (
|
||||||
|
<TouchableOpacity onPress={() => setShowAllMembers(false)} style={styles.viewAllButton}>
|
||||||
|
<Text style={styles.viewAllText}>收起</Text>
|
||||||
|
<MaterialCommunityIcons name="chevron-up" size={16} color={colors.primary.main} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View style={styles.memberList}>
|
||||||
|
{(showAllMembers ? allMembers : members).map((member) => (
|
||||||
|
<View key={member.id || member.user_id} style={styles.memberItem}>
|
||||||
|
<Avatar
|
||||||
|
source={member.user?.avatar}
|
||||||
|
size={44}
|
||||||
|
name={member.nickname || member.user?.nickname || '用户'}
|
||||||
|
/>
|
||||||
|
<View style={styles.memberInfo}>
|
||||||
|
<Text style={styles.memberName} numberOfLines={1}>
|
||||||
|
{member.nickname || member.user?.nickname || '用户'}
|
||||||
|
</Text>
|
||||||
|
{member.role === 'owner' && (
|
||||||
|
<Text style={styles.ownerBadge}>群主</Text>
|
||||||
|
)}
|
||||||
|
{member.role === 'admin' && (
|
||||||
|
<Text style={styles.adminBadge}>管理员</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 群信息 */}
|
||||||
|
<View style={styles.section}>
|
||||||
|
<Text style={styles.sectionTitle}>群信息</Text>
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Text style={styles.infoLabel}>群号</Text>
|
||||||
|
<Text style={styles.infoValue}>{groupInfo?.id || '-'}</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Text style={styles.infoLabel}>创建时间</Text>
|
||||||
|
<Text style={styles.infoValue}>
|
||||||
|
{groupInfo?.created_at
|
||||||
|
? new Date(groupInfo.created_at).toLocaleDateString('zh-CN')
|
||||||
|
: '-'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.infoRow}>
|
||||||
|
<Text style={styles.infoLabel}>全员禁言</Text>
|
||||||
|
<Text style={styles.infoValue}>
|
||||||
|
{groupInfo?.mute_all ? '已开启' : '已关闭'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 操作按钮 */}
|
||||||
|
<View style={styles.actionSection}>
|
||||||
|
{conversationId && onTogglePin && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={() => onTogglePin(!isPinned)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={isPinned ? "pin-off" : "pin"}
|
||||||
|
size={22}
|
||||||
|
color={colors.primary.main}
|
||||||
|
/>
|
||||||
|
<Text style={styles.actionButtonText}>
|
||||||
|
{isPinned ? '取消置顶' : '置顶群聊'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onLeaveGroup && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.actionButton, styles.leaveButton]}
|
||||||
|
onPress={onLeaveGroup}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="logout"
|
||||||
|
size={22}
|
||||||
|
color={colors.error.main}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.actionButtonText, styles.leaveButtonText]}>
|
||||||
|
退出群聊
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</Animated.View>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function createGroupInfoPanelStyles(colors: AppColors) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
overlay: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||||
|
zIndex: 100,
|
||||||
|
},
|
||||||
|
overlayTouchable: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: PANEL_WIDTH,
|
||||||
|
backgroundColor: colors.background.paper,
|
||||||
|
zIndex: 101,
|
||||||
|
...shadows.lg,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.divider,
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: fontSizes.xl,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text.primary,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: spacing.xs,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
groupInfoSection: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: spacing.xl,
|
||||||
|
},
|
||||||
|
groupName: {
|
||||||
|
fontSize: fontSizes.xl,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text.primary,
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
memberCount: {
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
},
|
||||||
|
joinType: {
|
||||||
|
fontSize: fontSizes.sm,
|
||||||
|
color: colors.primary.main,
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
},
|
||||||
|
inviteButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
backgroundColor: colors.primary.light + '15',
|
||||||
|
borderRadius: 20,
|
||||||
|
marginTop: spacing.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.primary.light,
|
||||||
|
},
|
||||||
|
inviteButtonText: {
|
||||||
|
fontSize: fontSizes.sm,
|
||||||
|
color: colors.primary.main,
|
||||||
|
marginLeft: spacing.xs,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: colors.divider,
|
||||||
|
marginHorizontal: spacing.md,
|
||||||
|
},
|
||||||
|
section: {
|
||||||
|
padding: spacing.md,
|
||||||
|
},
|
||||||
|
sectionHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text.primary,
|
||||||
|
marginLeft: spacing.xs,
|
||||||
|
},
|
||||||
|
noticeBox: {
|
||||||
|
backgroundColor: colors.chat.tipBg,
|
||||||
|
padding: spacing.md,
|
||||||
|
borderRadius: 8,
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: colors.warning.main,
|
||||||
|
},
|
||||||
|
noticeText: {
|
||||||
|
fontSize: fontSizes.sm,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
noticeTime: {
|
||||||
|
fontSize: fontSizes.xs,
|
||||||
|
color: colors.text.hint,
|
||||||
|
marginTop: spacing.xs,
|
||||||
|
textAlign: 'right',
|
||||||
|
},
|
||||||
|
descriptionBox: {
|
||||||
|
backgroundColor: colors.chat.surfaceMuted,
|
||||||
|
padding: spacing.md,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
fontSize: fontSizes.sm,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
memberList: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
memberListHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
viewAllButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
viewAllText: {
|
||||||
|
fontSize: fontSizes.sm,
|
||||||
|
color: colors.primary.main,
|
||||||
|
},
|
||||||
|
memberItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
},
|
||||||
|
memberInfo: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: spacing.sm,
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
},
|
||||||
|
memberName: {
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
color: colors.text.primary,
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
ownerBadge: {
|
||||||
|
fontSize: fontSizes.xs,
|
||||||
|
color: colors.warning.main,
|
||||||
|
marginLeft: spacing.xs,
|
||||||
|
backgroundColor: colors.warning.light + '30',
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
adminBadge: {
|
||||||
|
fontSize: fontSizes.xs,
|
||||||
|
color: colors.primary.main,
|
||||||
|
marginLeft: spacing.xs,
|
||||||
|
backgroundColor: colors.primary.light + '30',
|
||||||
|
paddingHorizontal: 6,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: 4,
|
||||||
|
},
|
||||||
|
moreMembers: {
|
||||||
|
fontSize: fontSizes.sm,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
infoRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.chat.borderHairline,
|
||||||
|
},
|
||||||
|
infoLabel: {
|
||||||
|
fontSize: fontSizes.sm,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
},
|
||||||
|
infoValue: {
|
||||||
|
fontSize: fontSizes.sm,
|
||||||
|
color: colors.text.primary,
|
||||||
|
},
|
||||||
|
actionSection: {
|
||||||
|
padding: spacing.md,
|
||||||
|
paddingBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
backgroundColor: colors.primary.light + '15',
|
||||||
|
borderRadius: 12,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.primary.light,
|
||||||
|
},
|
||||||
|
actionButtonText: {
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.primary.main,
|
||||||
|
marginLeft: spacing.sm,
|
||||||
|
},
|
||||||
|
leaveButton: {
|
||||||
|
backgroundColor: colors.error.light + '15',
|
||||||
|
borderColor: colors.error.light,
|
||||||
|
},
|
||||||
|
leaveButtonText: {
|
||||||
|
color: colors.error.main,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default GroupInfoPanel;
|
||||||
@@ -404,7 +404,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
source={senderInfo.avatar}
|
source={senderInfo.avatar}
|
||||||
size={36}
|
size={40}
|
||||||
name={senderInfo.nickname}
|
name={senderInfo.nickname}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -418,7 +418,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
>
|
>
|
||||||
<Avatar
|
<Avatar
|
||||||
source={otherUser?.avatar || null}
|
source={otherUser?.avatar || null}
|
||||||
size={36}
|
size={40}
|
||||||
name={otherUser?.nickname || ''}
|
name={otherUser?.nickname || ''}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -452,7 +452,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|||||||
<View style={styles.avatarWrapper}>
|
<View style={styles.avatarWrapper}>
|
||||||
<Avatar
|
<Avatar
|
||||||
source={currentUser?.avatar || null}
|
source={currentUser?.avatar || null}
|
||||||
size={36}
|
size={40}
|
||||||
name={currentUser?.nickname || ''}
|
name={currentUser?.nickname || ''}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -847,11 +847,11 @@ function createSegmentStyles(colors: AppColors) {
|
|||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 文本 - Telegram风格:统一深色字体
|
// 文本 - 适配暗色模式
|
||||||
textContent: {
|
textContent: {
|
||||||
fontSize: 16,
|
fontSize: 17.5,
|
||||||
lineHeight: 23,
|
lineHeight: 25,
|
||||||
letterSpacing: 0.2,
|
letterSpacing: 0.1,
|
||||||
fontWeight: '400',
|
fontWeight: '400',
|
||||||
},
|
},
|
||||||
textMe: {
|
textMe: {
|
||||||
@@ -861,27 +861,22 @@ function createSegmentStyles(colors: AppColors) {
|
|||||||
color: colors.chat.textPrimary,
|
color: colors.chat.textPrimary,
|
||||||
},
|
},
|
||||||
|
|
||||||
// @提及 - Telegram风格:蓝色高亮
|
// @提及 - 微信/QQ 风格:更精致的高亮
|
||||||
atText: {
|
atText: {
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
lineHeight: 23,
|
lineHeight: 22,
|
||||||
fontWeight: '600',
|
fontWeight: '500',
|
||||||
paddingHorizontal: 2,
|
|
||||||
},
|
},
|
||||||
atTextMe: {
|
atTextMe: {
|
||||||
color: '#4A88C7',
|
color: '#1A5F9E', // 深蓝色,在绿色背景上更清晰
|
||||||
backgroundColor: 'rgba(74, 136, 199, 0.12)',
|
|
||||||
borderRadius: 4,
|
|
||||||
},
|
},
|
||||||
atTextOther: {
|
atTextOther: {
|
||||||
color: colors.chat.link,
|
color: colors.chat.link,
|
||||||
backgroundColor: 'rgba(74, 136, 199, 0.12)',
|
|
||||||
borderRadius: 4,
|
|
||||||
},
|
},
|
||||||
atHighlight: {
|
atHighlight: {
|
||||||
backgroundColor: 'rgba(74, 136, 199, 0.2)',
|
backgroundColor: 'rgba(74, 136, 199, 0.15)',
|
||||||
borderRadius: 4,
|
borderRadius: 4,
|
||||||
paddingHorizontal: 4,
|
paddingHorizontal: 3,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 图片 - QQ风格:保持原始宽高比
|
// 图片 - QQ风格:保持原始宽高比
|
||||||
|
|||||||
@@ -143,6 +143,8 @@ export const EMOJIS = [
|
|||||||
|
|
||||||
// 更多功能项
|
// 更多功能项
|
||||||
export const MORE_ACTIONS: MoreAction[] = [
|
export const MORE_ACTIONS: MoreAction[] = [
|
||||||
|
{ id: 'voice_call', icon: 'phone', name: '语音通话', color: '#4CAF50' },
|
||||||
|
{ id: 'video_call', icon: 'video', name: '视频通话', color: '#2196F3' },
|
||||||
{ id: 'image', icon: 'image', name: '图片', color: '#FF6B6B' },
|
{ id: 'image', icon: 'image', name: '图片', color: '#FF6B6B' },
|
||||||
{ id: 'camera', icon: 'camera', name: '拍摄', color: '#4ECDC4' },
|
{ id: 'camera', icon: 'camera', name: '拍摄', color: '#4ECDC4' },
|
||||||
{ id: 'file', icon: 'file-document', name: '文件', color: '#45B7D1' },
|
{ id: 'file', icon: 'file-document', name: '文件', color: '#45B7D1' },
|
||||||
|
|||||||
@@ -114,11 +114,22 @@ export function createChatScreenStyles(colors: AppColors) {
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
borderRadius: 19,
|
borderRadius: 19,
|
||||||
backgroundColor: colors.chat.surfaceRaised,
|
backgroundColor: 'transparent',
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.chat.borderLight,
|
|
||||||
},
|
},
|
||||||
|
headerActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
callButton: {
|
||||||
|
width: 38,
|
||||||
|
height: 38,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 19,
|
||||||
|
backgroundColor: `${colors.primary.main}12`,
|
||||||
|
},
|
||||||
|
|
||||||
// 消息列表
|
// 消息列表
|
||||||
messageListContainer: {
|
messageListContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -201,7 +212,7 @@ export function createChatScreenStyles(colors: AppColors) {
|
|||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 头像
|
// 头像 - 更大一点
|
||||||
avatarWrapper: {
|
avatarWrapper: {
|
||||||
marginHorizontal: 2,
|
marginHorizontal: 2,
|
||||||
shadowColor: colors.chat.shadow,
|
shadowColor: colors.chat.shadow,
|
||||||
@@ -226,43 +237,43 @@ export function createChatScreenStyles(colors: AppColors) {
|
|||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
},
|
},
|
||||||
senderName: {
|
senderName: {
|
||||||
fontSize: 13,
|
fontSize: 14,
|
||||||
color: colors.chat.link,
|
color: colors.chat.textSecondary, // 使用主题次要文字颜色
|
||||||
marginBottom: 4,
|
marginBottom: 4,
|
||||||
marginLeft: 4,
|
marginLeft: 4,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 消息气泡 - 外层极轻阴影(纯文字气泡更接近扁平 IM)
|
// 消息气泡 - 微信风格:更小的圆角 + 更紧凑的内边距
|
||||||
messageBubbleOuter: {
|
messageBubbleOuter: {
|
||||||
borderRadius: 16,
|
borderRadius: 12,
|
||||||
minWidth: 60,
|
minWidth: 44,
|
||||||
shadowColor: colors.chat.shadow,
|
shadowColor: colors.chat.shadow,
|
||||||
shadowOffset: { width: 0, height: 0.5 },
|
shadowOffset: { width: 0, height: 1 },
|
||||||
shadowOpacity: 0.04,
|
shadowOpacity: 0.06,
|
||||||
shadowRadius: 1,
|
shadowRadius: 2,
|
||||||
elevation: 1,
|
elevation: 1,
|
||||||
},
|
},
|
||||||
myBubbleOuter: {
|
myBubbleOuter: {
|
||||||
borderBottomRightRadius: 4,
|
borderBottomRightRadius: 4, // 微信的小尾巴更尖
|
||||||
},
|
},
|
||||||
theirBubbleOuter: {
|
theirBubbleOuter: {
|
||||||
borderTopLeftRadius: 4,
|
borderTopLeftRadius: 4,
|
||||||
},
|
},
|
||||||
// 内层:背景 + 裁剪,防止回复条/@ 高亮在圆角外露出颜色
|
// 内层:更紧凑的微信风格内边距
|
||||||
messageBubbleInner: {
|
messageBubbleInner: {
|
||||||
borderRadius: 16,
|
borderRadius: 12,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: 10, // 微信:更窄的左右边距
|
||||||
paddingVertical: spacing.sm + 4,
|
paddingVertical: 7, // 微信:更紧的上下边距
|
||||||
minWidth: 60,
|
minWidth: 44,
|
||||||
},
|
},
|
||||||
myBubbleInner: {
|
myBubbleInner: {
|
||||||
backgroundColor: colors.chat.replyTint,
|
backgroundColor: colors.chat.bubbleOutgoing,
|
||||||
borderBottomRightRadius: 4,
|
borderBottomRightRadius: 4,
|
||||||
},
|
},
|
||||||
theirBubbleInner: {
|
theirBubbleInner: {
|
||||||
backgroundColor: colors.chat.card,
|
backgroundColor: colors.chat.bubbleIncoming,
|
||||||
borderTopLeftRadius: 4,
|
borderTopLeftRadius: 4,
|
||||||
},
|
},
|
||||||
recalledBubble: {
|
recalledBubble: {
|
||||||
@@ -272,17 +283,17 @@ export function createChatScreenStyles(colors: AppColors) {
|
|||||||
borderStyle: 'dashed',
|
borderStyle: 'dashed',
|
||||||
},
|
},
|
||||||
myBubbleText: {
|
myBubbleText: {
|
||||||
color: colors.chat.textPrimary, // 主文案
|
color: colors.chat.textPrimary, // 使用主题文字颜色
|
||||||
fontSize: 16,
|
fontSize: 18,
|
||||||
lineHeight: 23,
|
lineHeight: 25,
|
||||||
letterSpacing: 0.2,
|
letterSpacing: 0.1,
|
||||||
fontWeight: '400',
|
fontWeight: '400',
|
||||||
},
|
},
|
||||||
theirBubbleText: {
|
theirBubbleText: {
|
||||||
color: colors.chat.textPrimary, // 主文案
|
color: colors.chat.textPrimary, // 使用主题文字颜色
|
||||||
fontSize: 16,
|
fontSize: 18,
|
||||||
lineHeight: 23,
|
lineHeight: 25,
|
||||||
letterSpacing: 0.2,
|
letterSpacing: 0.1,
|
||||||
fontWeight: '400',
|
fontWeight: '400',
|
||||||
},
|
},
|
||||||
// 选中状态
|
// 选中状态
|
||||||
@@ -312,21 +323,21 @@ export function createChatScreenStyles(colors: AppColors) {
|
|||||||
fontStyle: 'italic',
|
fontStyle: 'italic',
|
||||||
},
|
},
|
||||||
|
|
||||||
// 图片消息 - QQ风格:更大的图片,更明显的圆角
|
// 图片消息 - QQ/微信风格:更大的圆角,更柔和的阴影
|
||||||
imageBubble: {
|
imageBubble: {
|
||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
shadowColor: colors.chat.shadow,
|
shadowColor: colors.chat.shadow,
|
||||||
shadowOffset: { width: 0, height: 3 },
|
shadowOffset: { width: 0, height: 3 },
|
||||||
shadowOpacity: 0.15,
|
shadowOpacity: 0.12,
|
||||||
shadowRadius: 6,
|
shadowRadius: 6,
|
||||||
elevation: 5,
|
elevation: 4,
|
||||||
},
|
},
|
||||||
myImageBubble: {
|
myImageBubble: {
|
||||||
borderBottomRightRadius: 6, // 右下角尖,箭头在右下
|
borderBottomRightRadius: 8,
|
||||||
},
|
},
|
||||||
theirImageBubble: {
|
theirImageBubble: {
|
||||||
borderTopLeftRadius: 6, // 左上角尖,箭头在左上
|
borderTopLeftRadius: 8,
|
||||||
},
|
},
|
||||||
messageImage: {
|
messageImage: {
|
||||||
width: 220,
|
width: 220,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { messageService } from '../../../../services/messageService';
|
|||||||
import { uploadService } from '../../../../services/uploadService';
|
import { uploadService } from '../../../../services/uploadService';
|
||||||
import { ApiError } from '../../../../services/api';
|
import { ApiError } from '../../../../services/api';
|
||||||
// 【新架构】使用 MessageManager
|
// 【新架构】使用 MessageManager
|
||||||
import { useChat, useGroupTyping, useGroupMuted, messageManager } from '../../../../stores';
|
import { useChat, useGroupTyping, useGroupMuted, messageManager, callStore } from '../../../../stores';
|
||||||
import { groupService } from '../../../../services/groupService';
|
import { groupService } from '../../../../services/groupService';
|
||||||
import { userManager } from '../../../../stores/userManager';
|
import { userManager } from '../../../../stores/userManager';
|
||||||
import { groupManager } from '../../../../stores/groupManager';
|
import { groupManager } from '../../../../stores/groupManager';
|
||||||
@@ -1060,6 +1060,24 @@ export const useChatScreen = () => {
|
|||||||
// 处理更多功能
|
// 处理更多功能
|
||||||
const handleMoreAction = useCallback((actionId: string) => {
|
const handleMoreAction = useCallback((actionId: string) => {
|
||||||
switch (actionId) {
|
switch (actionId) {
|
||||||
|
case 'voice_call':
|
||||||
|
if (!isGroupChat && otherUser?.id && conversationId) {
|
||||||
|
callStore.getState().startCall(conversationId, otherUser.id, {
|
||||||
|
nickname: otherUser.nickname,
|
||||||
|
avatar: otherUser.avatar,
|
||||||
|
}, 'voice');
|
||||||
|
}
|
||||||
|
setActivePanel('none');
|
||||||
|
break;
|
||||||
|
case 'video_call':
|
||||||
|
if (!isGroupChat && otherUser?.id && conversationId) {
|
||||||
|
callStore.getState().startCall(conversationId, otherUser.id, {
|
||||||
|
nickname: otherUser.nickname,
|
||||||
|
avatar: otherUser.avatar,
|
||||||
|
}, 'video');
|
||||||
|
}
|
||||||
|
setActivePanel('none');
|
||||||
|
break;
|
||||||
case 'image':
|
case 'image':
|
||||||
handlePickImage();
|
handlePickImage();
|
||||||
break;
|
break;
|
||||||
@@ -1077,7 +1095,7 @@ export const useChatScreen = () => {
|
|||||||
default:
|
default:
|
||||||
setActivePanel('none');
|
setActivePanel('none');
|
||||||
}
|
}
|
||||||
}, [handlePickImage, handleTakePhoto]);
|
}, [handlePickImage, handleTakePhoto, isGroupChat, otherUser, conversationId]);
|
||||||
|
|
||||||
// 插入表情
|
// 插入表情
|
||||||
const handleInsertEmoji = useCallback((emoji: string) => {
|
const handleInsertEmoji = useCallback((emoji: string) => {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import { spacing, fontSizes, shadows, useAppColors, type AppColors } from '../..
|
|||||||
import { ConversationResponse, MessageResponse, MessageSegment, GroupMemberResponse, ImageSegmentData } from '../../../types/dto';
|
import { ConversationResponse, MessageResponse, MessageSegment, GroupMemberResponse, ImageSegmentData } from '../../../types/dto';
|
||||||
import { Avatar, Text, ImageGallery, AppBackButton } from '../../../components/common';
|
import { Avatar, Text, ImageGallery, AppBackButton } from '../../../components/common';
|
||||||
import { useAuthStore, messageManager, MessageEvent, MessageSubscriber } from '../../../stores';
|
import { useAuthStore, messageManager, MessageEvent, MessageSubscriber } from '../../../stores';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
import { zhCN } from 'date-fns/locale';
|
||||||
import { extractTextFromSegments } from '../../../types/dto';
|
import { extractTextFromSegments } from '../../../types/dto';
|
||||||
import { useBreakpointGTE } from '../../../hooks/useResponsive';
|
import { useBreakpointGTE } from '../../../hooks/useResponsive';
|
||||||
import { useMarkAsRead } from '../../../stores/messageManagerHooks';
|
import { useMarkAsRead } from '../../../stores/messageManagerHooks';
|
||||||
@@ -30,15 +32,20 @@ import { messageService } from '../../../services';
|
|||||||
import * as hrefs from '../../../navigation/hrefs';
|
import * as hrefs from '../../../navigation/hrefs';
|
||||||
import { GroupInfoPanel } from './ChatScreen/GroupInfoPanel';
|
import { GroupInfoPanel } from './ChatScreen/GroupInfoPanel';
|
||||||
import { LongPressMenu, MenuPosition, GroupMessage } from './ChatScreen';
|
import { LongPressMenu, MenuPosition, GroupMessage } from './ChatScreen';
|
||||||
|
import { EmojiPanel } from './ChatScreen/EmojiPanel';
|
||||||
|
import { MorePanel } from './ChatScreen/MorePanel';
|
||||||
|
|
||||||
const { width: screenWidth } = Dimensions.get('window');
|
const { width: screenWidth } = Dimensions.get('window');
|
||||||
const PANEL_WIDTH = 360;
|
const PANEL_WIDTH = 360;
|
||||||
|
|
||||||
|
// 时间分隔间隔(毫秒)- 5分钟
|
||||||
|
const TIME_SEPARATOR_INTERVAL = 5 * 60 * 1000;
|
||||||
|
|
||||||
function createEmbeddedChatStyles(colors: AppColors) {
|
function createEmbeddedChatStyles(colors: AppColors) {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.chat.screen,
|
backgroundColor: colors.background.default,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -78,7 +85,7 @@ function createEmbeddedChatStyles(colors: AppColors) {
|
|||||||
},
|
},
|
||||||
messageList: {
|
messageList: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.chat.screen,
|
backgroundColor: colors.background.default,
|
||||||
},
|
},
|
||||||
centerContainer: {
|
centerContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -102,7 +109,7 @@ function createEmbeddedChatStyles(colors: AppColors) {
|
|||||||
},
|
},
|
||||||
messageRow: {
|
messageRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-end',
|
alignItems: 'flex-start',
|
||||||
marginBottom: spacing.md,
|
marginBottom: spacing.md,
|
||||||
maxWidth: screenWidth * 0.7,
|
maxWidth: screenWidth * 0.7,
|
||||||
},
|
},
|
||||||
@@ -112,6 +119,15 @@ function createEmbeddedChatStyles(colors: AppColors) {
|
|||||||
messageRowRight: {
|
messageRowRight: {
|
||||||
alignSelf: 'flex-end',
|
alignSelf: 'flex-end',
|
||||||
justifyContent: 'flex-end',
|
justifyContent: 'flex-end',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
avatarWrapper: {
|
||||||
|
marginHorizontal: 2,
|
||||||
|
},
|
||||||
|
messageContent: {
|
||||||
|
maxWidth: screenWidth * 0.5,
|
||||||
|
marginHorizontal: spacing.xs,
|
||||||
|
alignItems: 'flex-start',
|
||||||
},
|
},
|
||||||
messageBubble: {
|
messageBubble: {
|
||||||
maxWidth: screenWidth * 0.5,
|
maxWidth: screenWidth * 0.5,
|
||||||
@@ -122,11 +138,11 @@ function createEmbeddedChatStyles(colors: AppColors) {
|
|||||||
},
|
},
|
||||||
messageBubbleMe: {
|
messageBubbleMe: {
|
||||||
backgroundColor: colors.chat.replyTint,
|
backgroundColor: colors.chat.replyTint,
|
||||||
borderBottomRightRadius: 4,
|
borderTopRightRadius: 4,
|
||||||
},
|
},
|
||||||
messageBubbleOther: {
|
messageBubbleOther: {
|
||||||
backgroundColor: colors.chat.bubbleIncoming,
|
backgroundColor: colors.chat.bubbleIncoming,
|
||||||
borderBottomLeftRadius: 4,
|
borderTopLeftRadius: 4,
|
||||||
shadowColor: colors.chat.shadow,
|
shadowColor: colors.chat.shadow,
|
||||||
shadowOffset: { width: 0, height: 0.5 },
|
shadowOffset: { width: 0, height: 0.5 },
|
||||||
shadowOpacity: 0.04,
|
shadowOpacity: 0.04,
|
||||||
@@ -134,9 +150,11 @@ function createEmbeddedChatStyles(colors: AppColors) {
|
|||||||
elevation: 1,
|
elevation: 1,
|
||||||
},
|
},
|
||||||
senderName: {
|
senderName: {
|
||||||
fontSize: 12,
|
fontSize: 14,
|
||||||
color: colors.text.secondary,
|
color: colors.text.secondary,
|
||||||
marginBottom: 2,
|
marginBottom: 4,
|
||||||
|
marginLeft: 4,
|
||||||
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
messageText: {
|
messageText: {
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
@@ -149,7 +167,7 @@ function createEmbeddedChatStyles(colors: AppColors) {
|
|||||||
color: colors.chat.textPrimary,
|
color: colors.chat.textPrimary,
|
||||||
},
|
},
|
||||||
inputArea: {
|
inputArea: {
|
||||||
backgroundColor: colors.chat.screen,
|
backgroundColor: colors.background.paper,
|
||||||
borderTopWidth: StyleSheet.hairlineWidth,
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
borderTopColor: colors.divider,
|
borderTopColor: colors.divider,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: spacing.md,
|
||||||
@@ -227,6 +245,43 @@ function createEmbeddedChatStyles(colors: AppColors) {
|
|||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
marginTop: 4,
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
|
// 时间分隔样式
|
||||||
|
timeContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginVertical: spacing.md,
|
||||||
|
},
|
||||||
|
timeText: {
|
||||||
|
color: 'rgba(142, 142, 147, 0.95)',
|
||||||
|
fontSize: 11,
|
||||||
|
backgroundColor: 'rgba(142, 142, 147, 0.1)',
|
||||||
|
paddingHorizontal: spacing.sm + 4,
|
||||||
|
paddingVertical: 5,
|
||||||
|
borderRadius: 12,
|
||||||
|
fontWeight: '500',
|
||||||
|
overflow: 'hidden',
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
},
|
||||||
|
// 系统通知样式(如"某某加入了群聊")
|
||||||
|
systemNoticeContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginVertical: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
},
|
||||||
|
systemNoticeText: {
|
||||||
|
fontSize: 12,
|
||||||
|
color: colors.chat.textSecondary,
|
||||||
|
backgroundColor: 'rgba(142, 142, 147, 0.12)',
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
// 面板容器样式
|
||||||
|
panelContainer: {
|
||||||
|
backgroundColor: colors.background.paper,
|
||||||
|
borderTopWidth: StyleSheet.hairlineWidth,
|
||||||
|
borderTopColor: colors.divider,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -268,6 +323,81 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
|||||||
// 回复状态
|
// 回复状态
|
||||||
const [replyingToMessage, setReplyingToMessage] = useState<GroupMessage | null>(null);
|
const [replyingToMessage, setReplyingToMessage] = useState<GroupMessage | null>(null);
|
||||||
|
|
||||||
|
// 面板显示状态
|
||||||
|
const [showEmojiPanel, setShowEmojiPanel] = useState(false);
|
||||||
|
const [showMorePanel, setShowMorePanel] = useState(false);
|
||||||
|
|
||||||
|
// 切换表情面板
|
||||||
|
const toggleEmojiPanel = useCallback(() => {
|
||||||
|
setShowEmojiPanel(prev => !prev);
|
||||||
|
setShowMorePanel(false);
|
||||||
|
if (!showEmojiPanel) {
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
}, [showEmojiPanel]);
|
||||||
|
|
||||||
|
// 切换更多功能面板
|
||||||
|
const toggleMorePanel = useCallback(() => {
|
||||||
|
setShowMorePanel(prev => !prev);
|
||||||
|
setShowEmojiPanel(false);
|
||||||
|
if (!showMorePanel) {
|
||||||
|
inputRef.current?.blur();
|
||||||
|
}
|
||||||
|
}, [showMorePanel]);
|
||||||
|
|
||||||
|
// 关闭所有面板
|
||||||
|
const closePanels = useCallback(() => {
|
||||||
|
setShowEmojiPanel(false);
|
||||||
|
setShowMorePanel(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 插入表情
|
||||||
|
const handleInsertEmoji = useCallback((emoji: string) => {
|
||||||
|
setInputText(prev => prev + emoji);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 发送自定义表情
|
||||||
|
const handleSendSticker = useCallback(async (stickerUrl: string) => {
|
||||||
|
if (!conversation.id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSending(true);
|
||||||
|
const segments: MessageSegment[] = [{
|
||||||
|
type: 'image',
|
||||||
|
data: { url: stickerUrl, thumbnail_url: stickerUrl }
|
||||||
|
}];
|
||||||
|
await messageManager.sendMessage(String(conversation.id), segments);
|
||||||
|
closePanels();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[EmbeddedChat] 发送表情失败:', error);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
}, [conversation.id, closePanels]);
|
||||||
|
|
||||||
|
// 处理更多功能
|
||||||
|
const handleMoreAction = useCallback((actionId: string) => {
|
||||||
|
switch (actionId) {
|
||||||
|
case 'image':
|
||||||
|
// TODO: 实现选择图片功能
|
||||||
|
console.log('选择图片');
|
||||||
|
break;
|
||||||
|
case 'camera':
|
||||||
|
// TODO: 实现相机功能
|
||||||
|
console.log('相机');
|
||||||
|
break;
|
||||||
|
case 'file':
|
||||||
|
// TODO: 实现文件功能
|
||||||
|
console.log('文件');
|
||||||
|
break;
|
||||||
|
case 'location':
|
||||||
|
// TODO: 实现位置功能
|
||||||
|
console.log('位置');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
closePanels();
|
||||||
|
}, [closePanels]);
|
||||||
|
|
||||||
// 群信息面板动画
|
// 群信息面板动画
|
||||||
const slideAnim = useRef(new Animated.Value(PANEL_WIDTH)).current;
|
const slideAnim = useRef(new Animated.Value(PANEL_WIDTH)).current;
|
||||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||||
@@ -276,6 +406,42 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
|||||||
// 群成员列表
|
// 群成员列表
|
||||||
const [groupMembers, setGroupMembers] = useState<GroupMemberResponse[]>([]);
|
const [groupMembers, setGroupMembers] = useState<GroupMemberResponse[]>([]);
|
||||||
|
|
||||||
|
// 格式化时间
|
||||||
|
const formatTime = useCallback((dateString: string): string => {
|
||||||
|
try {
|
||||||
|
const date = new Date(dateString);
|
||||||
|
const now = new Date();
|
||||||
|
const diffInHours = (now.getTime() - date.getTime()) / (1000 * 60 * 60);
|
||||||
|
|
||||||
|
if (diffInHours < 24 && date.getDate() === now.getDate()) {
|
||||||
|
// 当天消息显示 "上午/下午 HH:mm" 格式
|
||||||
|
const hours = date.getHours();
|
||||||
|
const period = hours < 12 ? '上午' : '下午';
|
||||||
|
const displayHours = hours % 12 || 12;
|
||||||
|
const minutes = date.getMinutes().toString().padStart(2, '0');
|
||||||
|
return `${period} ${displayHours}:${minutes}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDistanceToNow(date, {
|
||||||
|
addSuffix: true,
|
||||||
|
locale: zhCN,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 检查是否需要显示时间分隔
|
||||||
|
const shouldShowTime = useCallback((index: number): boolean => {
|
||||||
|
if (index === 0) return true;
|
||||||
|
const currentMsg = messages[index];
|
||||||
|
const prevMsg = messages[index - 1];
|
||||||
|
if (!currentMsg || !prevMsg) return false;
|
||||||
|
const currentTime = new Date(currentMsg.created_at).getTime();
|
||||||
|
const prevTime = new Date(prevMsg.created_at).getTime();
|
||||||
|
return (currentTime - prevTime) > TIME_SEPARATOR_INTERVAL;
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
// 打开/关闭群信息面板
|
// 打开/关闭群信息面板
|
||||||
const toggleGroupInfoPanel = useCallback(() => {
|
const toggleGroupInfoPanel = useCallback(() => {
|
||||||
if (showGroupInfoPanel) {
|
if (showGroupInfoPanel) {
|
||||||
@@ -325,8 +491,21 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
await messageManager.fetchMessages(String(conversation.id));
|
await messageManager.fetchMessages(String(conversation.id));
|
||||||
setMessages(messageManager.getMessages(String(conversation.id)));
|
setMessages(messageManager.getMessages(String(conversation.id)));
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('[EmbeddedChat] 加载消息失败:', error);
|
// 检查是否是权限错误或会话不存在错误
|
||||||
|
const errorMessage = error?.message || String(error);
|
||||||
|
if (
|
||||||
|
errorMessage.includes('not found') ||
|
||||||
|
errorMessage.includes('no permission') ||
|
||||||
|
error?.code === 'NOT_FOUND' ||
|
||||||
|
error?.code === 'FORBIDDEN'
|
||||||
|
) {
|
||||||
|
console.warn('[EmbeddedChat] 会话不存在或无权限访问:', conversation.id);
|
||||||
|
// 设置空消息列表,让 UI 显示"暂无消息"而不是崩溃
|
||||||
|
setMessages([]);
|
||||||
|
} else {
|
||||||
|
console.error('[EmbeddedChat] 加载消息失败:', error);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -558,9 +737,36 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
|||||||
};
|
};
|
||||||
|
|
||||||
// 渲染消息
|
// 渲染消息
|
||||||
const renderMessage = ({ item }: { item: MessageResponse }) => {
|
const renderMessage = ({ item, index }: { item: MessageResponse; index: number }) => {
|
||||||
const isMe = String(item.sender_id) === String(currentUser?.id);
|
const isMe = String(item.sender_id) === String(currentUser?.id);
|
||||||
|
const showTime = shouldShowTime(index);
|
||||||
|
|
||||||
|
// 系统通知消息特殊处理
|
||||||
|
// 1. is_system_notice 字段(WebSocket 推送时设置)
|
||||||
|
// 2. category === 'notification'(从数据库加载时使用)
|
||||||
|
// 3. sender_id === '10000'(系统发送者兜底)
|
||||||
|
const isSystemNotice = (item as any).is_system_notice || item.category === 'notification' || item.sender_id === '10000';
|
||||||
|
|
||||||
|
// 系统通知消息渲染(如"某某加入了群聊")
|
||||||
|
if (isSystemNotice) {
|
||||||
|
return (
|
||||||
|
<View>
|
||||||
|
{showTime && (
|
||||||
|
<View style={styles.timeContainer}>
|
||||||
|
<Text style={styles.timeText}>
|
||||||
|
{formatTime(item.created_at)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
<View style={styles.systemNoticeContainer}>
|
||||||
|
<Text style={styles.systemNoticeText}>
|
||||||
|
{(item as any).notice_content || extractTextFromSegments(item.segments)}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 处理指针事件(鼠标右键)- 使用 onPointerDown 检测鼠标右键
|
// 处理指针事件(鼠标右键)- 使用 onPointerDown 检测鼠标右键
|
||||||
const handlePointerDown = (e: any) => {
|
const handlePointerDown = (e: any) => {
|
||||||
// e.nativeEvent.button: 0=左键, 1=中键, 2=右键
|
// e.nativeEvent.button: 0=左键, 1=中键, 2=右键
|
||||||
@@ -572,33 +778,55 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
|||||||
handleMessageRightPress(item, e);
|
handleMessageRightPress(item, e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<View>
|
||||||
style={[styles.messageRow, isMe ? styles.messageRowRight : styles.messageRowLeft]}
|
{/* 时间分隔 */}
|
||||||
// @ts-ignore - React Native for Web 支持 pointer events
|
{showTime && (
|
||||||
onPointerDown={handlePointerDown}
|
<View style={styles.timeContainer}>
|
||||||
>
|
<Text style={styles.timeText}>
|
||||||
{!isMe && (
|
{formatTime(item.created_at)}
|
||||||
<Avatar
|
</Text>
|
||||||
source={item.sender?.avatar}
|
</View>
|
||||||
size={36}
|
|
||||||
name={item.sender?.nickname || item.sender?.username}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<View style={[styles.messageBubble, isMe ? styles.messageBubbleMe : styles.messageBubbleOther]}>
|
<View
|
||||||
{isGroupChat && !isMe && (
|
style={[styles.messageRow, isMe ? styles.messageRowRight : styles.messageRowLeft]}
|
||||||
<Text style={styles.senderName}>{item.sender?.nickname || item.sender?.username}</Text>
|
// @ts-ignore - React Native for Web 支持 pointer events
|
||||||
|
onPointerDown={handlePointerDown}
|
||||||
|
>
|
||||||
|
{/* 对方头像(左侧) */}
|
||||||
|
{!isMe && (
|
||||||
|
<View style={styles.avatarWrapper}>
|
||||||
|
<Avatar
|
||||||
|
source={item.sender?.avatar}
|
||||||
|
size={36}
|
||||||
|
name={item.sender?.nickname || item.sender?.username}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 消息内容区域 */}
|
||||||
|
<View style={styles.messageContent}>
|
||||||
|
{/* 群聊模式:显示发送者昵称(在气泡上方,与头像顶部对齐) */}
|
||||||
|
{isGroupChat && !isMe && (
|
||||||
|
<Text style={styles.senderName}>{item.sender?.nickname || item.sender?.username}</Text>
|
||||||
|
)}
|
||||||
|
<View style={[styles.messageBubble, isMe ? styles.messageBubbleMe : styles.messageBubbleOther]}>
|
||||||
|
{renderMessageContent(item)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 自己的头像(右侧) */}
|
||||||
|
{isMe && (
|
||||||
|
<View style={styles.avatarWrapper}>
|
||||||
|
<Avatar
|
||||||
|
source={currentUser?.avatar}
|
||||||
|
size={36}
|
||||||
|
name={currentUser?.nickname || currentUser?.username}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
)}
|
)}
|
||||||
{renderMessageContent(item)}
|
|
||||||
</View>
|
</View>
|
||||||
{isMe && (
|
|
||||||
<Avatar
|
|
||||||
source={currentUser?.avatar}
|
|
||||||
size={36}
|
|
||||||
name={currentUser?.nickname || currentUser?.username}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -669,8 +897,12 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
|||||||
>
|
>
|
||||||
<View style={styles.inputArea}>
|
<View style={styles.inputArea}>
|
||||||
<View style={styles.inputRow}>
|
<View style={styles.inputRow}>
|
||||||
<TouchableOpacity style={styles.iconButton}>
|
<TouchableOpacity style={styles.iconButton} onPress={toggleMorePanel}>
|
||||||
<MaterialCommunityIcons name="plus-circle-outline" size={28} color={colors.text.secondary} />
|
<MaterialCommunityIcons
|
||||||
|
name={showMorePanel ? "close-circle-outline" : "plus-circle-outline"}
|
||||||
|
size={28}
|
||||||
|
color={showMorePanel ? colors.primary.main : colors.text.secondary}
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<View style={styles.inputContainer}>
|
<View style={styles.inputContainer}>
|
||||||
@@ -691,8 +923,12 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
|||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<TouchableOpacity style={styles.iconButton}>
|
<TouchableOpacity style={styles.iconButton} onPress={toggleEmojiPanel}>
|
||||||
<MaterialCommunityIcons name="emoticon-outline" size={28} color={colors.text.secondary} />
|
<MaterialCommunityIcons
|
||||||
|
name={showEmojiPanel ? "emoticon-happy" : "emoticon-outline"}
|
||||||
|
size={28}
|
||||||
|
color={showEmojiPanel ? colors.primary.main : colors.text.secondary}
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -708,6 +944,26 @@ export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 表情面板 */}
|
||||||
|
{showEmojiPanel && (
|
||||||
|
<View style={{ height: 350, backgroundColor: colors.background.paper }}>
|
||||||
|
<EmojiPanel
|
||||||
|
onInsertEmoji={handleInsertEmoji}
|
||||||
|
onInsertSticker={handleSendSticker}
|
||||||
|
onClose={closePanels}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 更多功能面板 */}
|
||||||
|
{showMorePanel && (
|
||||||
|
<View style={{ height: 350, backgroundColor: colors.background.paper }}>
|
||||||
|
<MorePanel
|
||||||
|
onAction={handleMoreAction}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
|
||||||
{/* 群信息侧边栏面板 - 仅大屏幕显示 */}
|
{/* 群信息侧边栏面板 - 仅大屏幕显示 */}
|
||||||
|
|||||||
@@ -10,8 +10,11 @@ import {
|
|||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||||
import { Text, ResponsiveContainer } from '../../components/common';
|
import { Text } from '../../components/common';
|
||||||
import { useResponsive } from '../../hooks';
|
import { useResponsive, useResponsiveSpacing } from '../../hooks';
|
||||||
|
|
||||||
|
// 内容最大宽度
|
||||||
|
const CONTENT_MAX_WIDTH = 720;
|
||||||
import { authService, resolveAuthApiError } from '../../services/authService';
|
import { authService, resolveAuthApiError } from '../../services/authService';
|
||||||
import { showPrompt } from '../../services/promptService';
|
import { showPrompt } from '../../services/promptService';
|
||||||
import { useAuthStore } from '../../stores';
|
import { useAuthStore } from '../../stores';
|
||||||
@@ -340,15 +343,13 @@ export const AccountSecurityScreen: React.FC = () => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||||
{isWideScreen ? (
|
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
|
||||||
<ResponsiveContainer maxWidth={800}>
|
{content}
|
||||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>{content}</ScrollView>
|
</ScrollView>
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>{content}</ScrollView>
|
|
||||||
)}
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -380,6 +381,9 @@ function createAccountSecurityStyles(colors: AppColors) {
|
|||||||
marginHorizontal: spacing.lg,
|
marginHorizontal: spacing.lg,
|
||||||
borderRadius: borderRadius.lg,
|
borderRadius: borderRadius.lg,
|
||||||
padding: spacing.md,
|
padding: spacing.md,
|
||||||
|
maxWidth: CONTENT_MAX_WIDTH,
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: '100%',
|
||||||
},
|
},
|
||||||
statusRow: {
|
statusRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@@ -31,9 +31,12 @@ import {
|
|||||||
type AppColors,
|
type AppColors,
|
||||||
} from '../../theme';
|
} from '../../theme';
|
||||||
import { useAuthStore } from '../../stores';
|
import { useAuthStore } from '../../stores';
|
||||||
import { Avatar, Button, Text, ResponsiveContainer } from '../../components/common';
|
import { Avatar, Button, Text } from '../../components/common';
|
||||||
import { authService, uploadService } from '../../services';
|
import { authService, uploadService } from '../../services';
|
||||||
import { useResponsive } from '../../hooks';
|
import { useResponsive, useResponsiveSpacing } from '../../hooks';
|
||||||
|
|
||||||
|
// 内容最大宽度
|
||||||
|
const CONTENT_MAX_WIDTH = 720;
|
||||||
|
|
||||||
function createEditProfileStyles(colors: AppColors) {
|
function createEditProfileStyles(colors: AppColors) {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
@@ -210,6 +213,9 @@ function createEditProfileStyles(colors: AppColors) {
|
|||||||
shadowOpacity: 0.05,
|
shadowOpacity: 0.05,
|
||||||
shadowRadius: 8,
|
shadowRadius: 8,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
|
maxWidth: CONTENT_MAX_WIDTH,
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: '100%',
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
marginBottom: spacing.lg,
|
marginBottom: spacing.lg,
|
||||||
@@ -357,6 +363,7 @@ export const EditProfileScreen: React.FC = () => {
|
|||||||
const navigation = useNavigation();
|
const navigation = useNavigation();
|
||||||
const { currentUser, updateUser } = useAuthStore();
|
const { currentUser, updateUser } = useAuthStore();
|
||||||
const { isWideScreen, isMobile, width } = useResponsive();
|
const { isWideScreen, isMobile, width } = useResponsive();
|
||||||
|
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
const [nickname, setNickname] = useState(currentUser?.nickname || '');
|
const [nickname, setNickname] = useState(currentUser?.nickname || '');
|
||||||
@@ -793,23 +800,12 @@ export const EditProfileScreen: React.FC = () => {
|
|||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
style={styles.keyboardView}
|
style={styles.keyboardView}
|
||||||
>
|
>
|
||||||
{isWideScreen ? (
|
<ScrollView
|
||||||
<ResponsiveContainer maxWidth={800}>
|
contentContainerStyle={[styles.scrollContent, { paddingHorizontal: responsivePadding }]}
|
||||||
<ScrollView
|
showsVerticalScrollIndicator={false}
|
||||||
contentContainerStyle={styles.scrollContent}
|
>
|
||||||
showsVerticalScrollIndicator={false}
|
{renderFormContent()}
|
||||||
>
|
</ScrollView>
|
||||||
{renderFormContent()}
|
|
||||||
</ScrollView>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={styles.scrollContent}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{renderFormContent()}
|
|
||||||
</ScrollView>
|
|
||||||
)}
|
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,14 +14,17 @@ import {
|
|||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { spacing, borderRadius, useAppColors, type AppColors } from '../../theme';
|
import { spacing, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||||
import { Text, ResponsiveContainer } from '../../components/common';
|
import { Text } from '../../components/common';
|
||||||
import {
|
import {
|
||||||
loadNotificationPreferences,
|
loadNotificationPreferences,
|
||||||
setPushNotificationsPreference,
|
setPushNotificationsPreference,
|
||||||
setSoundPreference,
|
setSoundPreference,
|
||||||
setVibrationPreference,
|
setVibrationPreference,
|
||||||
} from '../../services/notificationPreferences';
|
} from '../../services/notificationPreferences';
|
||||||
import { useResponsive } from '../../hooks';
|
import { useResponsive, useResponsiveSpacing } from '../../hooks';
|
||||||
|
|
||||||
|
// 内容最大宽度
|
||||||
|
const CONTENT_MAX_WIDTH = 720;
|
||||||
|
|
||||||
interface NotificationSettingItem {
|
interface NotificationSettingItem {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -168,19 +171,13 @@ export const NotificationSettingsScreen: React.FC = () => {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||||
{isWideScreen ? (
|
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
|
||||||
<ResponsiveContainer maxWidth={800}>
|
{renderContent()}
|
||||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
|
</ScrollView>
|
||||||
{renderContent()}
|
|
||||||
</ScrollView>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
|
|
||||||
{renderContent()}
|
|
||||||
</ScrollView>
|
|
||||||
)}
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -212,6 +209,9 @@ function createNotificationSettingsStyles(colors: AppColors) {
|
|||||||
marginHorizontal: spacing.lg,
|
marginHorizontal: spacing.lg,
|
||||||
borderRadius: borderRadius.lg,
|
borderRadius: borderRadius.lg,
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
|
maxWidth: CONTENT_MAX_WIDTH,
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: '100%',
|
||||||
},
|
},
|
||||||
settingItem: {
|
settingItem: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@@ -23,13 +23,17 @@ import {
|
|||||||
borderRadius,
|
borderRadius,
|
||||||
useThemePreference,
|
useThemePreference,
|
||||||
useSetThemePreference,
|
useSetThemePreference,
|
||||||
|
useThemeStore,
|
||||||
type AppColors,
|
type AppColors,
|
||||||
} from '../../theme';
|
} from '../../theme';
|
||||||
import { useAuthStore } from '../../stores';
|
import { useAuthStore } from '../../stores';
|
||||||
import { Text, ResponsiveContainer } from '../../components/common';
|
import { Text } from '../../components/common';
|
||||||
import { useResponsive } from '../../hooks';
|
import { useResponsive, useResponsiveSpacing } from '../../hooks';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
// 设置卡片最大宽度
|
||||||
|
const SETTINGS_CARD_MAX_WIDTH = 720;
|
||||||
|
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
|
|
||||||
interface SettingsItem {
|
interface SettingsItem {
|
||||||
@@ -83,6 +87,9 @@ function createSettingsStyles(colors: AppColors) {
|
|||||||
},
|
},
|
||||||
groupContainer: {
|
groupContainer: {
|
||||||
marginBottom: spacing.lg,
|
marginBottom: spacing.lg,
|
||||||
|
maxWidth: SETTINGS_CARD_MAX_WIDTH,
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: '100%',
|
||||||
},
|
},
|
||||||
groupHeader: {
|
groupHeader: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -153,6 +160,9 @@ function createSettingsStyles(colors: AppColors) {
|
|||||||
paddingVertical: spacing.md,
|
paddingVertical: spacing.md,
|
||||||
borderRadius: borderRadius.lg,
|
borderRadius: borderRadius.lg,
|
||||||
gap: spacing.sm,
|
gap: spacing.sm,
|
||||||
|
maxWidth: SETTINGS_CARD_MAX_WIDTH,
|
||||||
|
alignSelf: 'center',
|
||||||
|
width: '100%',
|
||||||
},
|
},
|
||||||
logoutText: {
|
logoutText: {
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
@@ -171,9 +181,9 @@ function createSettingsStyles(colors: AppColors) {
|
|||||||
export const SettingsScreen: React.FC = () => {
|
export const SettingsScreen: React.FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { logout } = useAuthStore();
|
const { logout } = useAuthStore();
|
||||||
const { isWideScreen } = useResponsive();
|
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const { isMobile } = useResponsive();
|
const { isMobile } = useResponsive();
|
||||||
|
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const styles = useMemo(() => createSettingsStyles(colors), [colors]);
|
const styles = useMemo(() => createSettingsStyles(colors), [colors]);
|
||||||
const themePreference = useThemePreference();
|
const themePreference = useThemePreference();
|
||||||
@@ -204,29 +214,41 @@ export const SettingsScreen: React.FC = () => {
|
|||||||
|
|
||||||
const handleItemPress = (key: string) => {
|
const handleItemPress = (key: string) => {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'theme':
|
case 'theme': {
|
||||||
Alert.alert('主题模式', '选择应用界面明暗', [
|
// 获取调试信息
|
||||||
{ text: '取消', style: 'cancel' },
|
const { Appearance } = require('react-native');
|
||||||
{
|
const systemScheme = Appearance?.getColorScheme?.() || 'undefined';
|
||||||
text: '跟随系统',
|
const resolvedScheme = useThemeStore.getState().resolvedScheme;
|
||||||
onPress: () => {
|
const storedPref = useThemeStore.getState().preference;
|
||||||
void setThemePreference('system');
|
const storedSystem = useThemeStore.getState().systemScheme;
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
'主题模式',
|
||||||
|
`选择应用界面明暗\n\n[调试信息]\n系统主题: ${systemScheme}\n存储偏好: ${storedPref}\n存储系统: ${storedSystem}\n实际应用: ${resolvedScheme}`,
|
||||||
|
[
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '跟随系统',
|
||||||
|
onPress: () => {
|
||||||
|
void setThemePreference('system');
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
text: '浅色',
|
||||||
text: '浅色',
|
onPress: () => {
|
||||||
onPress: () => {
|
void setThemePreference('light');
|
||||||
void setThemePreference('light');
|
},
|
||||||
},
|
},
|
||||||
},
|
{
|
||||||
{
|
text: '深色',
|
||||||
text: '深色',
|
onPress: () => {
|
||||||
onPress: () => {
|
void setThemePreference('dark');
|
||||||
void setThemePreference('dark');
|
},
|
||||||
},
|
},
|
||||||
},
|
]
|
||||||
]);
|
);
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case 'edit_profile':
|
case 'edit_profile':
|
||||||
router.push(hrefs.hrefProfileEdit());
|
router.push(hrefs.hrefProfileEdit());
|
||||||
break;
|
break;
|
||||||
@@ -342,17 +364,9 @@ export const SettingsScreen: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||||
{isWideScreen ? (
|
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding }]}>
|
||||||
<ResponsiveContainer maxWidth={800}>
|
{renderContent()}
|
||||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
|
</ScrollView>
|
||||||
{renderContent()}
|
|
||||||
</ScrollView>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<ScrollView contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}>
|
|
||||||
{renderContent()}
|
|
||||||
</ScrollView>
|
|
||||||
)}
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const getBaseUrl = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const BASE_URL = getBaseUrl();
|
const BASE_URL = getBaseUrl();
|
||||||
const SSE_URL = `${BASE_URL.replace(/\/+$/, '')}/realtime/sse`;
|
const WS_URL = `${BASE_URL.replace(/^https?:\/\//, 'wss://').replace(/\/api\/v1$/, '/api/v1')}/realtime/ws`;
|
||||||
|
|
||||||
// Token 存储键
|
// Token 存储键
|
||||||
const TOKEN_KEY = 'auth_token';
|
const TOKEN_KEY = 'auth_token';
|
||||||
@@ -441,4 +441,4 @@ class ApiClient {
|
|||||||
// 导出 API 客户端实例
|
// 导出 API 客户端实例
|
||||||
export const api = new ApiClient(BASE_URL);
|
export const api = new ApiClient(BASE_URL);
|
||||||
|
|
||||||
export { SSE_URL, TOKEN_KEY, REFRESH_TOKEN_KEY };
|
export { WS_URL, TOKEN_KEY, REFRESH_TOKEN_KEY };
|
||||||
|
|||||||
290
src/services/apkUpdateService.ts
Normal file
290
src/services/apkUpdateService.ts
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
/**
|
||||||
|
* APK 更新检查服务
|
||||||
|
* 用于检查最新版本并提示用户下载更新
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Platform, Linking, Alert } from 'react-native';
|
||||||
|
import Constants from 'expo-constants';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { showConfirm } from './dialogService';
|
||||||
|
|
||||||
|
// 条件导入原生模块(仅在 Android 上可用)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const FileSystem = Platform.OS === 'android' ? require('expo-file-system/legacy') : null;
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||||
|
const IntentLauncher = Platform.OS === 'android' ? require('expo-intent-launcher') : null;
|
||||||
|
|
||||||
|
// 更新服务器地址
|
||||||
|
const UPDATES_SERVER_URL = Constants.expoConfig?.extra?.updatesUrl
|
||||||
|
? Constants.expoConfig.extra.updatesUrl.replace('/api/manifest', '')
|
||||||
|
: 'https://updates.littlelan.cn';
|
||||||
|
|
||||||
|
// 检查间隔(毫秒):24小时
|
||||||
|
const CHECK_INTERVAL = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
// 最后检查时间存储键
|
||||||
|
const LAST_CHECK_KEY = 'apk_update_last_check';
|
||||||
|
|
||||||
|
// APK 版本信息
|
||||||
|
export interface APKVersionInfo {
|
||||||
|
runtimeVersion: string;
|
||||||
|
versionName: string;
|
||||||
|
size: number;
|
||||||
|
downloadUrl: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 版本比较结果
|
||||||
|
export interface VersionCheckResult {
|
||||||
|
hasUpdate: boolean;
|
||||||
|
currentVersion: string;
|
||||||
|
latestVersion: string;
|
||||||
|
downloadUrl: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 比较版本号
|
||||||
|
* 返回: 1 表示 v1 > v2, -1 表示 v1 < v2, 0 表示相等
|
||||||
|
*/
|
||||||
|
function compareVersions(v1: string, v2: string): number {
|
||||||
|
const parts1 = v1.split('.').map(Number);
|
||||||
|
const parts2 = v2.split('.').map(Number);
|
||||||
|
|
||||||
|
for (let i = 0; i < Math.max(parts1.length, parts2.length); i++) {
|
||||||
|
const p1 = parts1[i] || 0;
|
||||||
|
const p2 = parts2[i] || 0;
|
||||||
|
if (p1 > p2) return 1;
|
||||||
|
if (p1 < p2) return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取最新 APK 版本信息
|
||||||
|
*/
|
||||||
|
async function fetchLatestAPKVersion(): Promise<APKVersionInfo | null> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${UPDATES_SERVER_URL}/api/apk/latest`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn('Failed to fetch latest APK version:', response.status);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: APKVersionInfo = await response.json();
|
||||||
|
return data;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching latest APK version:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前应用版本
|
||||||
|
*/
|
||||||
|
function getCurrentVersion(): string {
|
||||||
|
return Constants.expoConfig?.version || '1.0.0';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化文件大小
|
||||||
|
*/
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查是否应该执行检查(避免频繁检查)
|
||||||
|
*/
|
||||||
|
async function shouldCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const lastCheck = await AsyncStorage.getItem(LAST_CHECK_KEY);
|
||||||
|
if (!lastCheck) return true;
|
||||||
|
|
||||||
|
const lastCheckTime = parseInt(lastCheck, 10);
|
||||||
|
return Date.now() - lastCheckTime > CHECK_INTERVAL;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 记录检查时间
|
||||||
|
*/
|
||||||
|
async function recordCheckTime(): Promise<void> {
|
||||||
|
try {
|
||||||
|
await AsyncStorage.setItem(LAST_CHECK_KEY, Date.now().toString());
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to record check time:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下载并安装 APK
|
||||||
|
*/
|
||||||
|
async function downloadAndInstallAPK(downloadUrl: string, version: string): Promise<void> {
|
||||||
|
if (Platform.OS !== 'android') {
|
||||||
|
Alert.alert('提示', '此功能仅支持 Android 设备');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!FileSystem) {
|
||||||
|
Alert.alert('提示', '当前环境不支持 APK 更新,请在浏览器中下载');
|
||||||
|
Linking.openURL(downloadUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const downloadPath = `${FileSystem.documentDirectory}carrot-bbs-${version}.apk`;
|
||||||
|
|
||||||
|
// 显示下载进度
|
||||||
|
showConfirm({
|
||||||
|
title: '正在下载',
|
||||||
|
message: `正在下载新版本 ${version},请稍候...`,
|
||||||
|
confirmText: '后台下载',
|
||||||
|
cancelText: '取消',
|
||||||
|
onConfirm: () => {
|
||||||
|
// 用户选择后台下载,继续
|
||||||
|
},
|
||||||
|
onCancel: () => {
|
||||||
|
// 用户取消
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 下载 APK
|
||||||
|
const downloadResult = await FileSystem.downloadAsync(downloadUrl, downloadPath);
|
||||||
|
|
||||||
|
if (!downloadResult.uri) {
|
||||||
|
Alert.alert('下载失败', '无法下载安装包,请稍后重试');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 安装 APK
|
||||||
|
await installAPK(downloadResult.uri);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Download/Install error:', error);
|
||||||
|
Alert.alert('更新失败', '下载或安装过程中出错,请稍后重试');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安装 APK
|
||||||
|
*/
|
||||||
|
async function installAPK(apkUri: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (!FileSystem || !IntentLauncher) {
|
||||||
|
throw new Error('Native modules not available');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取文件的 content URI
|
||||||
|
const contentUri = await FileSystem.getContentUriAsync(apkUri);
|
||||||
|
|
||||||
|
// 使用 Intent 安装 APK
|
||||||
|
await IntentLauncher.startActivityAsync('android.intent.action.INSTALL_PACKAGE', {
|
||||||
|
data: contentUri,
|
||||||
|
flags: 1, // FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Install APK error:', error);
|
||||||
|
// 尝试使用浏览器下载
|
||||||
|
Linking.openURL(apkUri.replace('file://', ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 显示更新提示对话框
|
||||||
|
*/
|
||||||
|
function showUpdateDialog(result: VersionCheckResult): void {
|
||||||
|
const sizeText = formatFileSize(result.size);
|
||||||
|
|
||||||
|
showConfirm({
|
||||||
|
title: '发现新版本',
|
||||||
|
message: `当前版本: ${result.currentVersion}\n最新版本: ${result.latestVersion}\n安装包大小: ${sizeText}\n\n是否立即下载更新?`,
|
||||||
|
confirmText: '立即更新',
|
||||||
|
cancelText: '稍后提醒',
|
||||||
|
onConfirm: () => {
|
||||||
|
downloadAndInstallAPK(result.downloadUrl, result.latestVersion);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查 APK 更新(主入口)
|
||||||
|
* @param force 是否强制检查(忽略时间间隔)
|
||||||
|
*/
|
||||||
|
export async function checkForAPKUpdate(force: boolean = false): Promise<VersionCheckResult | null> {
|
||||||
|
// 仅在 Android 平台检查
|
||||||
|
if (Platform.OS !== 'android') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否应该执行检查
|
||||||
|
if (!force) {
|
||||||
|
const should = await shouldCheck();
|
||||||
|
if (!should) {
|
||||||
|
console.log('APK update check skipped (too frequent)');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录检查时间
|
||||||
|
await recordCheckTime();
|
||||||
|
|
||||||
|
// 获取最新版本信息
|
||||||
|
const latestAPK = await fetchLatestAPKVersion();
|
||||||
|
if (!latestAPK) {
|
||||||
|
console.log('No APK version info available');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentVersion = getCurrentVersion();
|
||||||
|
const latestVersion = latestAPK.versionName || latestAPK.runtimeVersion;
|
||||||
|
|
||||||
|
// 比较版本
|
||||||
|
const comparison = compareVersions(currentVersion, latestVersion);
|
||||||
|
|
||||||
|
const result: VersionCheckResult = {
|
||||||
|
hasUpdate: comparison < 0,
|
||||||
|
currentVersion,
|
||||||
|
latestVersion,
|
||||||
|
downloadUrl: latestAPK.downloadUrl,
|
||||||
|
size: latestAPK.size,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果有更新,显示对话框
|
||||||
|
if (result.hasUpdate) {
|
||||||
|
showUpdateDialog(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手动检查更新(用户主动触发)
|
||||||
|
*/
|
||||||
|
export async function manualCheckForUpdate(): Promise<void> {
|
||||||
|
const result = await checkForAPKUpdate(true);
|
||||||
|
|
||||||
|
if (!result) {
|
||||||
|
Alert.alert('检查失败', '无法获取版本信息,请检查网络连接');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.hasUpdate) {
|
||||||
|
Alert.alert('已是最新', `当前版本 ${result.currentVersion} 已是最新版本`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const apkUpdateService = {
|
||||||
|
checkForAPKUpdate,
|
||||||
|
manualCheckForUpdate,
|
||||||
|
};
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
import { AppState, AppStateStatus, Platform } from 'react-native';
|
import { AppState, AppStateStatus, Platform } from 'react-native';
|
||||||
import * as BackgroundFetch from 'expo-background-fetch';
|
import * as BackgroundFetch from 'expo-background-fetch';
|
||||||
import * as TaskManager from 'expo-task-manager';
|
import * as TaskManager from 'expo-task-manager';
|
||||||
import { sseService } from './sseService';
|
import { wsService } from './wsService';
|
||||||
import {
|
import {
|
||||||
triggerVibration,
|
triggerVibration,
|
||||||
vibrateOnMessage,
|
vibrateOnMessage,
|
||||||
@@ -36,8 +36,8 @@ let appStateSubscription: any = null;
|
|||||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||||
try {
|
try {
|
||||||
// 检查 SSE 连接状态
|
// 检查 SSE 连接状态
|
||||||
if (!sseService.isConnected()) {
|
if (!wsService.isConnected()) {
|
||||||
await sseService.connect();
|
await wsService.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 返回收到新数据
|
// 返回收到新数据
|
||||||
@@ -51,8 +51,8 @@ TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
|||||||
// SSE 保活任务
|
// SSE 保活任务
|
||||||
TaskManager.defineTask(REALTIME_KEEPALIVE_TASK, async () => {
|
TaskManager.defineTask(REALTIME_KEEPALIVE_TASK, async () => {
|
||||||
try {
|
try {
|
||||||
if (!sseService.isConnected()) {
|
if (!wsService.isConnected()) {
|
||||||
await sseService.connect();
|
await wsService.connect();
|
||||||
}
|
}
|
||||||
return BackgroundFetch.BackgroundFetchResult.NewData;
|
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -120,8 +120,8 @@ function setupAppStateListener(): void {
|
|||||||
void nextAppState;
|
void nextAppState;
|
||||||
if (nextAppState === 'active') {
|
if (nextAppState === 'active') {
|
||||||
// App 回到前台,确保连接
|
// App 回到前台,确保连接
|
||||||
if (!sseService.isConnected()) {
|
if (!wsService.isConnected()) {
|
||||||
sseService.connect();
|
wsService.connect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -78,7 +78,13 @@ function normalizeAnnouncementCursor(
|
|||||||
// 群组服务类(纯 API 层)
|
// 群组服务类(纯 API 层)
|
||||||
// SQLite/内存缓存编排由 groupManager 统一处理
|
// SQLite/内存缓存编排由 groupManager 统一处理
|
||||||
class GroupService {
|
class GroupService {
|
||||||
async fetchGroupsFromApi(page = 1, pageSize = 20): Promise<GroupListResponse> {
|
// ==================== 群组管理 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取群组列表
|
||||||
|
* GET /api/v1/groups
|
||||||
|
*/
|
||||||
|
async getGroups(page = 1, pageSize = 20): Promise<GroupListResponse> {
|
||||||
const response = await api.get<GroupListResponse>('/groups', {
|
const response = await api.get<GroupListResponse>('/groups', {
|
||||||
page,
|
page,
|
||||||
page_size: pageSize,
|
page_size: pageSize,
|
||||||
@@ -86,16 +92,20 @@ class GroupService {
|
|||||||
return normalizeGroupListResponse(response.data);
|
return normalizeGroupListResponse(response.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchGroupFromApi(id: string): Promise<GroupResponse> {
|
/**
|
||||||
|
* 获取群组详情
|
||||||
|
* GET /api/v1/groups/:id
|
||||||
|
*/
|
||||||
|
async getGroup(id: string): Promise<GroupResponse> {
|
||||||
const response = await api.get<GroupResponse>(`/groups/${encodeURIComponent(id)}`);
|
const response = await api.get<GroupResponse>(`/groups/${encodeURIComponent(id)}`);
|
||||||
return normalizeGroup(response.data);
|
return normalizeGroup(response.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async fetchMembersFromApi(
|
/**
|
||||||
groupId: string,
|
* 获取群组成员列表
|
||||||
page = 1,
|
* GET /api/v1/groups/:id/members
|
||||||
pageSize = 50
|
*/
|
||||||
): Promise<GroupMemberListResponse> {
|
async getMembers(groupId: string, page = 1, pageSize = 50): Promise<GroupMemberListResponse> {
|
||||||
const response = await api.get<GroupMemberListResponse>(
|
const response = await api.get<GroupMemberListResponse>(
|
||||||
`/groups/${encodeURIComponent(groupId)}/members`,
|
`/groups/${encodeURIComponent(groupId)}/members`,
|
||||||
{
|
{
|
||||||
@@ -106,8 +116,6 @@ class GroupService {
|
|||||||
return normalizeMemberListResponse(response.data);
|
return normalizeMemberListResponse(response.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 群组管理 ====================
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建群组
|
* 创建群组
|
||||||
* POST /api/v1/groups
|
* POST /api/v1/groups
|
||||||
@@ -117,22 +125,6 @@ class GroupService {
|
|||||||
return normalizeGroup(response.data);
|
return normalizeGroup(response.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取群组列表
|
|
||||||
* GET /api/v1/groups
|
|
||||||
*/
|
|
||||||
async getGroups(page = 1, pageSize = 20): Promise<GroupListResponse> {
|
|
||||||
return this.fetchGroupsFromApi(page, pageSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取群组详情
|
|
||||||
* GET /api/v1/groups/:id
|
|
||||||
*/
|
|
||||||
async getGroup(id: string): Promise<GroupResponse> {
|
|
||||||
return this.fetchGroupFromApi(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前用户在群组中的成员信息
|
* 获取当前用户在群组中的成员信息
|
||||||
* GET /api/v1/groups/:id/me
|
* GET /api/v1/groups/:id/me
|
||||||
@@ -211,14 +203,6 @@ class GroupService {
|
|||||||
await api.post(`/groups/${encodeURIComponent(groupId)}/leave`);
|
await api.post(`/groups/${encodeURIComponent(groupId)}/leave`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取群组成员列表
|
|
||||||
* GET /api/v1/groups/:id/members
|
|
||||||
*/
|
|
||||||
async getMembers(groupId: string, page = 1, pageSize = 50): Promise<GroupMemberListResponse> {
|
|
||||||
return this.fetchMembersFromApi(groupId, page, pageSize);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 移除群成员(踢人)
|
* 移除群成员(踢人)
|
||||||
* POST /api/v1/groups/:id/members/kick
|
* POST /api/v1/groups/:id/members/kick
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// API 客户端
|
// API 客户端
|
||||||
export { api, SSE_URL, TOKEN_KEY, REFRESH_TOKEN_KEY } from './api';
|
export { api, WS_URL, TOKEN_KEY, REFRESH_TOKEN_KEY } from './api';
|
||||||
export type { ApiResponse, PaginatedData, ApiError } from './api';
|
export type { ApiResponse, PaginatedData, ApiError } from './api';
|
||||||
|
|
||||||
// 认证服务
|
// 认证服务
|
||||||
@@ -47,8 +47,8 @@ export { voteService } from './voteService';
|
|||||||
export { channelService } from './channelService';
|
export { channelService } from './channelService';
|
||||||
export type { ChannelItem } from './channelService';
|
export type { ChannelItem } from './channelService';
|
||||||
|
|
||||||
// SSE 实时服务
|
// WebSocket 实时服务
|
||||||
export { sseService } from './sseService';
|
export { wsService } from './wsService';
|
||||||
export type {
|
export type {
|
||||||
WSMessage,
|
WSMessage,
|
||||||
WSMessageType,
|
WSMessageType,
|
||||||
@@ -65,7 +65,7 @@ export type {
|
|||||||
WSGroupMentionMessage,
|
WSGroupMentionMessage,
|
||||||
WSGroupReadMessage,
|
WSGroupReadMessage,
|
||||||
WSGroupRecallMessage
|
WSGroupRecallMessage
|
||||||
} from './sseService';
|
} from './wsService';
|
||||||
|
|
||||||
// 系统通知服务
|
// 系统通知服务
|
||||||
export { systemNotificationService, getNotificationTitle } from './systemNotificationService';
|
export { systemNotificationService, getNotificationTitle } from './systemNotificationService';
|
||||||
@@ -110,3 +110,7 @@ export {
|
|||||||
// 统一提示/弹窗服务
|
// 统一提示/弹窗服务
|
||||||
export { showPrompt } from './promptService';
|
export { showPrompt } from './promptService';
|
||||||
export { showConfirm } from './dialogService';
|
export { showConfirm } from './dialogService';
|
||||||
|
|
||||||
|
// APK 更新检查服务
|
||||||
|
export { apkUpdateService, checkForAPKUpdate, manualCheckForUpdate } from './apkUpdateService';
|
||||||
|
export type { APKVersionInfo, VersionCheckResult } from './apkUpdateService';
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { api } from './api';
|
import { api } from './api';
|
||||||
|
import { wsService } from './wsService';
|
||||||
import {
|
import {
|
||||||
ConversationListResponse,
|
ConversationListResponse,
|
||||||
ConversationResponse,
|
ConversationResponse,
|
||||||
@@ -27,6 +28,7 @@ import {
|
|||||||
saveConversationCache,
|
saveConversationCache,
|
||||||
saveConversationsWithRelatedCache,
|
saveConversationsWithRelatedCache,
|
||||||
updateConversationCacheUnreadCount,
|
updateConversationCacheUnreadCount,
|
||||||
|
saveUsersCache,
|
||||||
} from './database';
|
} from './database';
|
||||||
|
|
||||||
/** 远端会话列表分页(offset / cursor)统一结果:拉取成功后均已执行本地缓存同步 */
|
/** 远端会话列表分页(offset / cursor)统一结果:拉取成功后均已执行本地缓存同步 */
|
||||||
@@ -330,8 +332,22 @@ class MessageService {
|
|||||||
page: data.page || 1,
|
page: data.page || 1,
|
||||||
page_size: data.page_size || 20,
|
page_size: data.page_size || 20,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error: any) {
|
||||||
console.error('获取消息列表失败:', error);
|
// 检查是否是权限错误或会话不存在错误 - 这是正常情况,不需要打印错误日志
|
||||||
|
const errorMessage = error?.message || String(error);
|
||||||
|
if (
|
||||||
|
errorMessage.includes('not found') ||
|
||||||
|
errorMessage.includes('no permission') ||
|
||||||
|
error?.code === 'NOT_FOUND' ||
|
||||||
|
error?.code === 'FORBIDDEN'
|
||||||
|
) {
|
||||||
|
// 会话不存在或无权限访问,返回空消息列表
|
||||||
|
// 这可能是因为会话已被删除、用户被踢出群聊等情况
|
||||||
|
console.warn('[MessageService] 会话不存在或无权限访问:', conversationId);
|
||||||
|
} else {
|
||||||
|
// 其他错误才打印错误日志
|
||||||
|
console.error('[MessageService] 获取消息列表失败:', error);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
messages: [],
|
messages: [],
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -343,6 +359,7 @@ class MessageService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 发送消息(新格式)
|
* 发送消息(新格式)
|
||||||
|
* 优先使用WebSocket发送,失败时降级到HTTP
|
||||||
* POST /api/v1/conversations/:id/messages
|
* POST /api/v1/conversations/:id/messages
|
||||||
* Body: { detail_type, segments }
|
* Body: { detail_type, segments }
|
||||||
*/
|
*/
|
||||||
@@ -356,12 +373,25 @@ class MessageService {
|
|||||||
throw new Error('消息内容不能为空');
|
throw new Error('消息内容不能为空');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.sendMessageByAction(
|
// 优先使用WebSocket发送
|
||||||
detailType,
|
try {
|
||||||
conversationId,
|
const result = await wsService.sendMessage(
|
||||||
data.segments,
|
conversationId,
|
||||||
data.reply_to_id
|
detailType,
|
||||||
);
|
data.segments,
|
||||||
|
data.reply_to_id
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.log('WebSocket发送失败,降级到HTTP:', error);
|
||||||
|
// 降级到HTTP发送
|
||||||
|
return this.sendMessageByAction(
|
||||||
|
detailType,
|
||||||
|
conversationId,
|
||||||
|
data.segments,
|
||||||
|
data.reply_to_id
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -493,13 +523,19 @@ class MessageService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 撤回/删除消息
|
* 撤回/删除消息
|
||||||
|
* 优先使用WebSocket,失败时降级到HTTP
|
||||||
* POST /api/v1/messages/delete_msg
|
* POST /api/v1/messages/delete_msg
|
||||||
* Body: { "message_id": "xxx" }
|
* Body: { "message_id": "xxx" }
|
||||||
*/
|
*/
|
||||||
async recallMessage(messageId: string): Promise<void> {
|
async recallMessage(messageId: string): Promise<void> {
|
||||||
await api.post('/messages/delete_msg', {
|
try {
|
||||||
message_id: messageId,
|
await wsService.recallMessage(messageId);
|
||||||
});
|
} catch (error) {
|
||||||
|
console.log('WebSocket撤回失败,降级到HTTP:', error);
|
||||||
|
await api.post('/messages/delete_msg', {
|
||||||
|
message_id: messageId,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -514,11 +550,16 @@ class MessageService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 标记已读
|
* 标记已读
|
||||||
|
* 优先使用WebSocket,失败时降级到HTTP
|
||||||
* POST /api/v1/conversations/:id/read
|
* POST /api/v1/conversations/:id/read
|
||||||
* @param conversationId 会话ID
|
* @param conversationId 会话ID
|
||||||
* @param seq 已读到的消息序号
|
* @param seq 已读到的消息序号
|
||||||
*/
|
*/
|
||||||
async markAsRead(conversationId: string, seq: number): Promise<void> {
|
async markAsRead(conversationId: string, seq: number): Promise<void> {
|
||||||
|
// 使用WebSocket发送已读标记(不等待响应)
|
||||||
|
wsService.markRead(conversationId, seq).catch(() => {});
|
||||||
|
|
||||||
|
// 同时通过HTTP确保可靠性
|
||||||
await api.post(`/conversations/${encodeURIComponent(conversationId)}/read`, {
|
await api.post(`/conversations/${encodeURIComponent(conversationId)}/read`, {
|
||||||
last_read_seq: Number(seq),
|
last_read_seq: Number(seq),
|
||||||
});
|
});
|
||||||
@@ -530,9 +571,14 @@ class MessageService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 上报输入状态
|
* 上报输入状态
|
||||||
|
* 优先使用WebSocket,失败时降级到HTTP
|
||||||
* POST /api/v1/conversations/:id/typing
|
* POST /api/v1/conversations/:id/typing
|
||||||
*/
|
*/
|
||||||
async sendTyping(conversationId: string): Promise<void> {
|
async sendTyping(conversationId: string): Promise<void> {
|
||||||
|
// 使用WebSocket发送输入状态
|
||||||
|
wsService.sendTyping(conversationId, true);
|
||||||
|
|
||||||
|
// 同时通过HTTP确保可靠性
|
||||||
await api.post(`/conversations/${encodeURIComponent(conversationId)}/typing`);
|
await api.post(`/conversations/${encodeURIComponent(conversationId)}/typing`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,517 +0,0 @@
|
|||||||
import { AppState, AppStateStatus } from 'react-native';
|
|
||||||
import EventSource from 'react-native-sse';
|
|
||||||
|
|
||||||
import { api, SSE_URL } from './api';
|
|
||||||
import { MessageCategory, SystemMessageType, SystemMessageExtraData, MessageSegment } from '../types/dto';
|
|
||||||
import { systemNotificationService } from './systemNotificationService';
|
|
||||||
|
|
||||||
export type WSMessageType =
|
|
||||||
| 'chat'
|
|
||||||
| 'message'
|
|
||||||
| 'read'
|
|
||||||
| 'typing'
|
|
||||||
| 'recall'
|
|
||||||
| 'notification'
|
|
||||||
| 'announcement'
|
|
||||||
| 'group_message'
|
|
||||||
| 'group_typing'
|
|
||||||
| 'group_notice'
|
|
||||||
| 'group_mention'
|
|
||||||
| 'group_read'
|
|
||||||
| 'group_recall'
|
|
||||||
| 'notice'
|
|
||||||
| 'request'
|
|
||||||
| 'meta'
|
|
||||||
| 'private'
|
|
||||||
| 'group'
|
|
||||||
| 'follow'
|
|
||||||
| 'like'
|
|
||||||
| 'comment'
|
|
||||||
| 'heartbeat';
|
|
||||||
|
|
||||||
export interface WSChatMessage {
|
|
||||||
type: 'chat';
|
|
||||||
conversation_id: string;
|
|
||||||
id: string;
|
|
||||||
sender_id: string;
|
|
||||||
seq: number;
|
|
||||||
segments?: MessageSegment[];
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WSReadMessage {
|
|
||||||
type: 'read';
|
|
||||||
conversation_id: string;
|
|
||||||
user_id: string;
|
|
||||||
seq: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WSTypingMessage {
|
|
||||||
type: 'typing';
|
|
||||||
conversation_id: string;
|
|
||||||
user_id: string;
|
|
||||||
is_typing: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WSRecallMessage {
|
|
||||||
type: 'recall';
|
|
||||||
conversation_id: string;
|
|
||||||
message_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WSNotificationMessage {
|
|
||||||
type: 'notification';
|
|
||||||
id: string;
|
|
||||||
sender_id?: string;
|
|
||||||
receiver_id?: string;
|
|
||||||
content: string;
|
|
||||||
category?: MessageCategory;
|
|
||||||
system_type?: SystemMessageType;
|
|
||||||
extra_data?: SystemMessageExtraData;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WSAnnouncementMessage {
|
|
||||||
type: 'announcement';
|
|
||||||
id: string;
|
|
||||||
sender_id?: string;
|
|
||||||
receiver_id?: string;
|
|
||||||
content: string;
|
|
||||||
category?: MessageCategory;
|
|
||||||
system_type?: SystemMessageType;
|
|
||||||
extra_data?: SystemMessageExtraData;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WSGroupMentionMessage {
|
|
||||||
type: 'group_mention';
|
|
||||||
group_id: string;
|
|
||||||
conversation_id: string;
|
|
||||||
message_id: string;
|
|
||||||
from_user_id: string;
|
|
||||||
content: string;
|
|
||||||
mention_all: boolean;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WSGroupChatMessage {
|
|
||||||
type: 'group_message';
|
|
||||||
conversation_id: string;
|
|
||||||
group_id: string;
|
|
||||||
id: string;
|
|
||||||
sender_id: string;
|
|
||||||
seq: number;
|
|
||||||
segments?: MessageSegment[];
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WSGroupTypingMessage {
|
|
||||||
type: 'group_typing';
|
|
||||||
group_id: string;
|
|
||||||
user_id: string;
|
|
||||||
is_typing: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type GroupNoticeType = 'member_join' | 'member_leave' | 'member_removed' | 'role_changed' | 'muted' | 'unmuted';
|
|
||||||
|
|
||||||
export interface WSGroupNoticeMessage {
|
|
||||||
type: 'group_notice';
|
|
||||||
notice_type: GroupNoticeType;
|
|
||||||
group_id: string;
|
|
||||||
data: {
|
|
||||||
user_id?: string;
|
|
||||||
operator_id?: string;
|
|
||||||
role?: string;
|
|
||||||
[key: string]: any;
|
|
||||||
};
|
|
||||||
timestamp: number;
|
|
||||||
message_id?: string;
|
|
||||||
seq?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WSGroupReadMessage {
|
|
||||||
type: 'group_read';
|
|
||||||
group_id: string;
|
|
||||||
conversation_id: string;
|
|
||||||
user_id: string;
|
|
||||||
seq: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WSGroupRecallMessage {
|
|
||||||
type: 'group_recall';
|
|
||||||
group_id: string;
|
|
||||||
conversation_id: string;
|
|
||||||
message_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type WSMessage =
|
|
||||||
| WSChatMessage
|
|
||||||
| WSReadMessage
|
|
||||||
| WSTypingMessage
|
|
||||||
| WSRecallMessage
|
|
||||||
| WSNotificationMessage
|
|
||||||
| WSAnnouncementMessage
|
|
||||||
| WSGroupChatMessage
|
|
||||||
| WSGroupTypingMessage
|
|
||||||
| WSGroupNoticeMessage
|
|
||||||
| WSGroupMentionMessage
|
|
||||||
| WSGroupReadMessage
|
|
||||||
| WSGroupRecallMessage;
|
|
||||||
|
|
||||||
type MessageHandler<T extends WSMessage = WSMessage> = (message: T) => void;
|
|
||||||
type ConnectionHandler = () => void;
|
|
||||||
|
|
||||||
interface SSEEnvelope {
|
|
||||||
event_id?: number;
|
|
||||||
event?: string;
|
|
||||||
ts?: number;
|
|
||||||
payload?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
class SSEService {
|
|
||||||
private source: EventSource | null = null;
|
|
||||||
private isConnecting = false;
|
|
||||||
private connected = false;
|
|
||||||
private reconnectAttempts = 0;
|
|
||||||
private maxReconnectAttempts = 20;
|
|
||||||
private reconnectDelay = 3000;
|
|
||||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
||||||
private connectionWatchdogTimer: NodeJS.Timeout | null = null;
|
|
||||||
private readonly CONNECTION_IDLE_TIMEOUT_MS = 70000;
|
|
||||||
private messageHandlers: Map<WSMessageType, MessageHandler[]> = new Map();
|
|
||||||
private connectionHandlers: ConnectionHandler[] = [];
|
|
||||||
private disconnectionHandlers: ConnectionHandler[] = [];
|
|
||||||
private appStateSubscription: any = null;
|
|
||||||
private lastAppState: AppStateStatus = 'active';
|
|
||||||
private lastEventId = '';
|
|
||||||
private lastActivityAt = 0;
|
|
||||||
private shouldRun = false;
|
|
||||||
|
|
||||||
private toSSEUrl(): string {
|
|
||||||
return `${SSE_URL}?last_event_id=${encodeURIComponent(this.lastEventId)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async connect(): Promise<boolean> {
|
|
||||||
this.shouldRun = true;
|
|
||||||
if (this.isConnecting || this.isConnected()) return true;
|
|
||||||
this.isConnecting = true;
|
|
||||||
try {
|
|
||||||
const token = await api.getToken();
|
|
||||||
if (!token) {
|
|
||||||
this.isConnecting = false;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const url = this.toSSEUrl();
|
|
||||||
this.source = new EventSource(url, {
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
},
|
|
||||||
// Use one reconnect strategy only (manual), avoid library auto-reconnect fighting with us.
|
|
||||||
pollingInterval: 0,
|
|
||||||
// Be explicit to prevent platform-level short idle timeout behavior.
|
|
||||||
timeout: this.CONNECTION_IDLE_TIMEOUT_MS,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.source.addEventListener('open', () => {
|
|
||||||
this.isConnecting = false;
|
|
||||||
this.connected = true;
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
this.markActivity();
|
|
||||||
this.startConnectionWatchdog();
|
|
||||||
this.connectionHandlers.forEach(h => h());
|
|
||||||
});
|
|
||||||
|
|
||||||
this.source.addEventListener('error', () => {
|
|
||||||
this.handleDisconnected();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.source.addEventListener('done' as any, () => {
|
|
||||||
this.handleDisconnected();
|
|
||||||
});
|
|
||||||
|
|
||||||
this.source.addEventListener('close' as any, () => {
|
|
||||||
this.handleDisconnected();
|
|
||||||
});
|
|
||||||
|
|
||||||
const events = ['chat_message', 'message_read', 'typing', 'system_notification', 'group_notice', 'message_recall', 'heartbeat'];
|
|
||||||
events.forEach(eventName => {
|
|
||||||
this.source?.addEventListener(eventName as any, (evt: any) => this.handleIncoming(eventName, evt));
|
|
||||||
});
|
|
||||||
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
this.isConnecting = false;
|
|
||||||
this.scheduleReconnect();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleIncoming(eventName: string, evt: any): void {
|
|
||||||
this.markActivity();
|
|
||||||
const rawData = typeof evt?.data === 'string' ? evt.data : '{}';
|
|
||||||
const lastEventId = evt?.lastEventId;
|
|
||||||
if (lastEventId) {
|
|
||||||
this.lastEventId = String(lastEventId);
|
|
||||||
}
|
|
||||||
let payload: any = {};
|
|
||||||
try {
|
|
||||||
payload = JSON.parse(rawData);
|
|
||||||
} catch {
|
|
||||||
payload = {};
|
|
||||||
}
|
|
||||||
this.dispatchEvent(eventName, payload);
|
|
||||||
}
|
|
||||||
|
|
||||||
private dispatchEvent(eventName: string, payload: any): void {
|
|
||||||
if (eventName === 'chat_message') {
|
|
||||||
const detailType = payload?.detail_type || 'private';
|
|
||||||
const m = payload?.message || payload;
|
|
||||||
if (detailType === 'group') {
|
|
||||||
const gm: WSGroupChatMessage = {
|
|
||||||
type: 'group_message',
|
|
||||||
conversation_id: m.conversation_id,
|
|
||||||
group_id: String(m.group_id ?? ''),
|
|
||||||
id: m.id,
|
|
||||||
sender_id: m.sender_id,
|
|
||||||
seq: Number(m.seq || 0),
|
|
||||||
segments: m.segments || [],
|
|
||||||
created_at: m.created_at || new Date().toISOString(),
|
|
||||||
};
|
|
||||||
this.emit('group_message', gm);
|
|
||||||
} else {
|
|
||||||
const cm: WSChatMessage = {
|
|
||||||
type: 'chat',
|
|
||||||
conversation_id: m.conversation_id,
|
|
||||||
id: m.id,
|
|
||||||
sender_id: m.sender_id,
|
|
||||||
seq: Number(m.seq || 0),
|
|
||||||
segments: m.segments || [],
|
|
||||||
created_at: m.created_at || new Date().toISOString(),
|
|
||||||
};
|
|
||||||
this.emit('chat', cm);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventName === 'message_read') {
|
|
||||||
const detailType = payload?.detail_type || 'private';
|
|
||||||
if (detailType === 'group') {
|
|
||||||
const m: WSGroupReadMessage = {
|
|
||||||
type: 'group_read',
|
|
||||||
group_id: String(payload.group_id ?? ''),
|
|
||||||
conversation_id: payload.conversation_id,
|
|
||||||
user_id: payload.user_id,
|
|
||||||
seq: Number(payload.seq || 0),
|
|
||||||
};
|
|
||||||
this.emit('group_read', m);
|
|
||||||
} else {
|
|
||||||
const m: WSReadMessage = {
|
|
||||||
type: 'read',
|
|
||||||
conversation_id: payload.conversation_id,
|
|
||||||
user_id: payload.user_id,
|
|
||||||
seq: Number(payload.seq || 0),
|
|
||||||
};
|
|
||||||
this.emit('read', m);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventName === 'typing') {
|
|
||||||
const detailType = payload?.detail_type || 'private';
|
|
||||||
if (detailType === 'group') {
|
|
||||||
const m: WSGroupTypingMessage = {
|
|
||||||
type: 'group_typing',
|
|
||||||
group_id: String(payload.group_id ?? ''),
|
|
||||||
user_id: payload.user_id,
|
|
||||||
is_typing: payload.is_typing !== false,
|
|
||||||
};
|
|
||||||
this.emit('group_typing', m);
|
|
||||||
} else {
|
|
||||||
const m: WSTypingMessage = {
|
|
||||||
type: 'typing',
|
|
||||||
conversation_id: payload.conversation_id,
|
|
||||||
user_id: payload.user_id,
|
|
||||||
is_typing: payload.is_typing !== false,
|
|
||||||
};
|
|
||||||
this.emit('typing', m);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventName === 'message_recall') {
|
|
||||||
const detailType = payload?.detail_type || 'private';
|
|
||||||
if (detailType === 'group') {
|
|
||||||
const m: WSGroupRecallMessage = {
|
|
||||||
type: 'group_recall',
|
|
||||||
group_id: String(payload.group_id ?? ''),
|
|
||||||
conversation_id: payload.conversation_id,
|
|
||||||
message_id: payload.message_id,
|
|
||||||
};
|
|
||||||
this.emit('group_recall', m);
|
|
||||||
} else {
|
|
||||||
const m: WSRecallMessage = {
|
|
||||||
type: 'recall',
|
|
||||||
conversation_id: payload.conversation_id,
|
|
||||||
message_id: payload.message_id,
|
|
||||||
};
|
|
||||||
this.emit('recall', m);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventName === 'group_notice') {
|
|
||||||
const m: WSGroupNoticeMessage = {
|
|
||||||
type: 'group_notice',
|
|
||||||
notice_type: payload.notice_type,
|
|
||||||
group_id: String(payload.group_id ?? ''),
|
|
||||||
data: payload.data || {},
|
|
||||||
timestamp: payload.timestamp || Date.now(),
|
|
||||||
message_id: payload.message_id,
|
|
||||||
seq: payload.seq,
|
|
||||||
};
|
|
||||||
this.emit('group_notice', m);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventName === 'system_notification') {
|
|
||||||
const m: WSNotificationMessage = {
|
|
||||||
type: 'notification',
|
|
||||||
id: String(payload.id ?? ''),
|
|
||||||
content: payload.content || '',
|
|
||||||
created_at: payload.created_at || new Date().toISOString(),
|
|
||||||
};
|
|
||||||
this.emit('notification', m);
|
|
||||||
systemNotificationService.handleWSMessage(m as any).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private emit<T extends WSMessageType>(type: T, message: Extract<WSMessage, { type: T }>) {
|
|
||||||
const handlers = this.messageHandlers.get(type) || [];
|
|
||||||
handlers.forEach(h => h(message as WSMessage));
|
|
||||||
}
|
|
||||||
|
|
||||||
disconnect(): void {
|
|
||||||
this.shouldRun = false;
|
|
||||||
this.connected = false;
|
|
||||||
this.isConnecting = false;
|
|
||||||
if (this.source) {
|
|
||||||
this.source.close();
|
|
||||||
this.source = null;
|
|
||||||
}
|
|
||||||
this.stopConnectionWatchdog();
|
|
||||||
this.stopReconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
private scheduleReconnect(): void {
|
|
||||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) return;
|
|
||||||
this.stopReconnect();
|
|
||||||
this.reconnectTimer = setTimeout(() => {
|
|
||||||
this.reconnectAttempts += 1;
|
|
||||||
this.connect();
|
|
||||||
}, this.reconnectDelay);
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopReconnect() {
|
|
||||||
if (this.reconnectTimer) {
|
|
||||||
clearTimeout(this.reconnectTimer);
|
|
||||||
this.reconnectTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
isConnected(): boolean {
|
|
||||||
return this.connected;
|
|
||||||
}
|
|
||||||
|
|
||||||
on<T extends WSMessageType>(type: T, handler: MessageHandler<Extract<WSMessage, { type: T }>>): () => void {
|
|
||||||
const list = this.messageHandlers.get(type) || [];
|
|
||||||
list.push(handler as MessageHandler);
|
|
||||||
this.messageHandlers.set(type, list);
|
|
||||||
return () => {
|
|
||||||
const current = this.messageHandlers.get(type) || [];
|
|
||||||
const idx = current.indexOf(handler as MessageHandler);
|
|
||||||
if (idx >= 0) current.splice(idx, 1);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
onConnect(handler: ConnectionHandler): () => void {
|
|
||||||
this.connectionHandlers.push(handler);
|
|
||||||
return () => {
|
|
||||||
const i = this.connectionHandlers.indexOf(handler);
|
|
||||||
if (i >= 0) this.connectionHandlers.splice(i, 1);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
onDisconnect(handler: ConnectionHandler): () => void {
|
|
||||||
this.disconnectionHandlers.push(handler);
|
|
||||||
return () => {
|
|
||||||
const i = this.disconnectionHandlers.indexOf(handler);
|
|
||||||
if (i >= 0) this.disconnectionHandlers.splice(i, 1);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupAppStateListener(): void {
|
|
||||||
if (this.appStateSubscription) return;
|
|
||||||
this.lastAppState = AppState.currentState;
|
|
||||||
this.appStateSubscription = AppState.addEventListener('change', (nextState: AppStateStatus) => {
|
|
||||||
if (this.lastAppState.match(/inactive|background/) && nextState === 'active' && !this.isConnected()) {
|
|
||||||
this.reconnectAttempts = 0;
|
|
||||||
this.connect();
|
|
||||||
}
|
|
||||||
this.lastAppState = nextState;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async start(): Promise<boolean> {
|
|
||||||
this.setupAppStateListener();
|
|
||||||
return this.connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(): void {
|
|
||||||
if (this.appStateSubscription) {
|
|
||||||
this.appStateSubscription.remove();
|
|
||||||
this.appStateSubscription = null;
|
|
||||||
}
|
|
||||||
this.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
private markActivity(): void {
|
|
||||||
this.lastActivityAt = Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
private startConnectionWatchdog(): void {
|
|
||||||
this.stopConnectionWatchdog();
|
|
||||||
this.connectionWatchdogTimer = setInterval(() => {
|
|
||||||
// During active app usage, if no SSE activity for too long, reconnect proactively.
|
|
||||||
if (!this.connected || this.lastActivityAt <= 0) return;
|
|
||||||
if (Date.now() - this.lastActivityAt > this.CONNECTION_IDLE_TIMEOUT_MS) {
|
|
||||||
this.handleDisconnected();
|
|
||||||
}
|
|
||||||
}, 10000);
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopConnectionWatchdog(): void {
|
|
||||||
if (this.connectionWatchdogTimer) {
|
|
||||||
clearInterval(this.connectionWatchdogTimer);
|
|
||||||
this.connectionWatchdogTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleDisconnected(): void {
|
|
||||||
const wasActive = this.connected || this.source != null || this.isConnecting;
|
|
||||||
this.isConnecting = false;
|
|
||||||
this.connected = false;
|
|
||||||
if (this.source) {
|
|
||||||
this.source.close();
|
|
||||||
this.source = null;
|
|
||||||
}
|
|
||||||
this.stopConnectionWatchdog();
|
|
||||||
if (wasActive) {
|
|
||||||
this.disconnectionHandlers.forEach(h => h());
|
|
||||||
}
|
|
||||||
if (this.shouldRun) {
|
|
||||||
this.scheduleReconnect();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const sseService = new SSEService();
|
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
import * as Notifications from 'expo-notifications';
|
import * as Notifications from 'expo-notifications';
|
||||||
import { Platform, AppState, AppStateStatus } from 'react-native';
|
import { Platform, AppState, AppStateStatus } from 'react-native';
|
||||||
import type { WSChatMessage, WSNotificationMessage, WSAnnouncementMessage } from './sseService';
|
import type { WSChatMessage, WSNotificationMessage, WSAnnouncementMessage } from './wsService';
|
||||||
import { extractTextFromSegments } from '../types/dto';
|
import { extractTextFromSegments } from '../types/dto';
|
||||||
import { getNotificationPreferencesSync } from './notificationPreferences';
|
import { getNotificationPreferencesSync } from './notificationPreferences';
|
||||||
import { vibrateOnMessage } from './messageVibrationService';
|
import { vibrateOnMessage } from './messageVibrationService';
|
||||||
|
|||||||
669
src/services/webrtc/WebRTCManager.ts
Normal file
669
src/services/webrtc/WebRTCManager.ts
Normal file
@@ -0,0 +1,669 @@
|
|||||||
|
import {
|
||||||
|
RTCPeerConnection,
|
||||||
|
RTCSessionDescription,
|
||||||
|
RTCIceCandidate,
|
||||||
|
mediaDevices,
|
||||||
|
MediaStream,
|
||||||
|
} from 'react-native-webrtc';
|
||||||
|
|
||||||
|
export interface ICEServer {
|
||||||
|
urls: string[];
|
||||||
|
username?: string;
|
||||||
|
credential?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectionState = 'new' | 'connecting' | 'connected' | 'disconnected' | 'failed' | 'closed';
|
||||||
|
|
||||||
|
export type CallType = 'voice' | 'video';
|
||||||
|
|
||||||
|
export type WebRTCManagerEvent =
|
||||||
|
| { type: 'icecandidate'; candidate: RTCIceCandidate | null }
|
||||||
|
| { type: 'connectionstatechange'; state: ConnectionState }
|
||||||
|
| { type: 'remotestream'; stream: MediaStream }
|
||||||
|
| { type: 'negotiationneeded'; offer: RTCSessionDescriptionInit }
|
||||||
|
| { type: 'error'; error: Error };
|
||||||
|
|
||||||
|
type EventHandler = (event: WebRTCManagerEvent) => void;
|
||||||
|
|
||||||
|
class WebRTCManager {
|
||||||
|
private peerConnection: RTCPeerConnection | null = null;
|
||||||
|
private localStream: MediaStream | null = null;
|
||||||
|
private remoteStream: MediaStream | null = null;
|
||||||
|
private pendingCandidates: RTCIceCandidateInit[] = [];
|
||||||
|
private iceServers: ICEServer[] = [];
|
||||||
|
private eventHandlers: Set<EventHandler> = new Set();
|
||||||
|
private disposed = false;
|
||||||
|
private isInitiator = false;
|
||||||
|
|
||||||
|
// ICE restart 相关状态
|
||||||
|
private reconnectAttempts = 0;
|
||||||
|
private readonly MAX_RECONNECT_ATTEMPTS = 3;
|
||||||
|
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||||
|
private disconnectTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
async initialize(iceServers: ICEServer[] = []): Promise<void> {
|
||||||
|
if (this.peerConnection) {
|
||||||
|
this.dispose();
|
||||||
|
}
|
||||||
|
this.disposed = false;
|
||||||
|
this.iceServers = iceServers.length > 0
|
||||||
|
? iceServers
|
||||||
|
: [{ urls: ['stun:stun.l.google.com:19302'] }];
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildPeerConnectionConfig(): RTCConfiguration {
|
||||||
|
return {
|
||||||
|
iceServers: this.iceServers.map((server) => ({
|
||||||
|
urls: server.urls,
|
||||||
|
...(server.username ? { username: server.username } : {}),
|
||||||
|
...(server.credential ? { credential: server.credential } : {}),
|
||||||
|
})),
|
||||||
|
iceCandidatePoolSize: 10,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private createPeerConnection(): RTCPeerConnection {
|
||||||
|
const config = this.buildPeerConnectionConfig();
|
||||||
|
const pc = new RTCPeerConnection(config);
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
pc.onicecandidate = (event) => {
|
||||||
|
if (event.candidate) {
|
||||||
|
this.emit({ type: 'icecandidate', candidate: event.candidate });
|
||||||
|
} else {
|
||||||
|
this.emit({ type: 'icecandidate', candidate: null });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
pc.oniceconnectionstatechange = () => {
|
||||||
|
const state = pc.iceConnectionState as ConnectionState;
|
||||||
|
this.emit({ type: 'connectionstatechange', state });
|
||||||
|
this.handleIceConnectionStateChange(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
pc.onconnectionstatechange = () => {
|
||||||
|
const state = pc.connectionState as ConnectionState;
|
||||||
|
this.emit({ type: 'connectionstatechange', state });
|
||||||
|
this.handleConnectionStateChange(state);
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
pc.ontrack = (event) => {
|
||||||
|
console.log('[WebRTC] ontrack event, kind:', event.track?.kind, 'streams:', event.streams?.length ?? 0);
|
||||||
|
if (!event.track) {
|
||||||
|
console.log('[WebRTC] ontrack: no track in event');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Official react-native-webrtc approach: use event.streams[0] if available
|
||||||
|
if (event.streams && event.streams[0]) {
|
||||||
|
this.remoteStream = event.streams[0];
|
||||||
|
} else {
|
||||||
|
// Fallback: manually construct stream
|
||||||
|
if (!this.remoteStream) {
|
||||||
|
this.remoteStream = new MediaStream();
|
||||||
|
}
|
||||||
|
// Check if track already exists to avoid duplicates
|
||||||
|
const existingTracks = this.remoteStream.getTracks();
|
||||||
|
const trackExists = existingTracks.some(t => t.id === event.track.id);
|
||||||
|
if (!trackExists) {
|
||||||
|
this.remoteStream.addTrack(event.track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.emit({ type: 'remotestream', stream: this.remoteStream! });
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
pc.onsignalingstatechange = () => {
|
||||||
|
console.log('[WebRTC] Signaling state changed:', pc.signalingState);
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
pc.onnegotiationneeded = async () => {
|
||||||
|
console.log('[WebRTC] Negotiation needed, signalingState:', pc.signalingState);
|
||||||
|
if (!this.peerConnection || this.peerConnection !== pc) {
|
||||||
|
console.log('[WebRTC] PeerConnection changed or disposed, skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pc.signalingState !== 'stable') {
|
||||||
|
console.log('[WebRTC] Skipping negotiation, not in stable state:', pc.signalingState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const offer = await this.createOffer();
|
||||||
|
if (this.peerConnection && !this.disposed) {
|
||||||
|
this.emit({ type: 'negotiationneeded', offer });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[WebRTC] Negotiation needed failed:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return pc;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLocalStream(voiceOnly = true): Promise<MediaStream> {
|
||||||
|
const constraints = voiceOnly
|
||||||
|
? { audio: true, video: false }
|
||||||
|
: { audio: true, video: { facingMode: 'user', frameRate: 30 } };
|
||||||
|
|
||||||
|
try {
|
||||||
|
// @ts-ignore
|
||||||
|
this.localStream = await mediaDevices.getUserMedia(constraints);
|
||||||
|
return this.localStream;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WebRTC] Failed to get local media stream:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a call using official addTrack approach
|
||||||
|
* Follows react-native-webrtc CallGuide.md
|
||||||
|
*/
|
||||||
|
async startCall(isInitiator: boolean, callType: CallType = 'voice'): Promise<RTCSessionDescriptionInit | null> {
|
||||||
|
if (this.disposed) throw new Error('WebRTCManager has been disposed');
|
||||||
|
if (!this.localStream) throw new Error('Local stream not initialized');
|
||||||
|
|
||||||
|
this.isInitiator = isInitiator;
|
||||||
|
this.peerConnection = this.createPeerConnection();
|
||||||
|
|
||||||
|
// Official approach: add all tracks using addTrack
|
||||||
|
// addTrack automatically creates senders with proper directions
|
||||||
|
this.localStream.getTracks().forEach((track) => {
|
||||||
|
console.log('[WebRTC] Adding track:', track.kind, track.enabled);
|
||||||
|
this.peerConnection!.addTrack(track, this.localStream!);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isInitiator) {
|
||||||
|
// For initiator, create offer directly
|
||||||
|
const offer = await this.createOffer();
|
||||||
|
return offer;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOffer(): Promise<RTCSessionDescriptionInit> {
|
||||||
|
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
|
||||||
|
|
||||||
|
console.log('[WebRTC] Creating offer...');
|
||||||
|
|
||||||
|
const offerOptions = {
|
||||||
|
offerToReceiveAudio: true,
|
||||||
|
offerToReceiveVideo: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const offer = await this.peerConnection.createOffer(offerOptions);
|
||||||
|
|
||||||
|
if (!this.peerConnection || this.disposed) {
|
||||||
|
throw new Error('PeerConnection was disposed during offer creation');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.peerConnection.setLocalDescription(offer);
|
||||||
|
return offer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAnswer(): Promise<RTCSessionDescriptionInit> {
|
||||||
|
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
|
||||||
|
|
||||||
|
console.log('[WebRTC] Creating answer...');
|
||||||
|
|
||||||
|
const answerOptions = {
|
||||||
|
offerToReceiveAudio: true,
|
||||||
|
offerToReceiveVideo: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
const answer = await this.peerConnection.createAnswer(answerOptions);
|
||||||
|
|
||||||
|
if (!this.peerConnection || this.disposed) {
|
||||||
|
throw new Error('PeerConnection was disposed during answer creation');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.peerConnection.setLocalDescription(answer);
|
||||||
|
|
||||||
|
// Process pending candidates
|
||||||
|
await this.processPendingCandidates();
|
||||||
|
|
||||||
|
return answer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAnswerFromOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit> {
|
||||||
|
await this.setRemoteDescription(offer);
|
||||||
|
return this.createAnswer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rollback to stable state (for glare handling)
|
||||||
|
* Used when both peers try to negotiate simultaneously
|
||||||
|
*/
|
||||||
|
async rollback(): Promise<void> {
|
||||||
|
if (!this.peerConnection) {
|
||||||
|
throw new Error('PeerConnection not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pc = this.peerConnection;
|
||||||
|
const signalingState = pc.signalingState;
|
||||||
|
|
||||||
|
console.log('[WebRTC] Attempting rollback, current state:', signalingState);
|
||||||
|
|
||||||
|
// Only rollback if we're not in stable state
|
||||||
|
if (signalingState === 'stable') {
|
||||||
|
console.log('[WebRTC] Already in stable state, no rollback needed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// For react-native-webrtc, we may need to recreate the peer connection
|
||||||
|
// as rollback is not fully supported
|
||||||
|
if (signalingState === 'have-local-offer') {
|
||||||
|
// Rollback local offer by setting local description to null/undefined
|
||||||
|
// @ts-ignore - react-native-webrtc specific
|
||||||
|
if (pc.setLocalDescription) {
|
||||||
|
// @ts-ignore
|
||||||
|
await pc.setLocalDescription({ type: 'rollback' });
|
||||||
|
}
|
||||||
|
} else if (signalingState === 'have-remote-offer') {
|
||||||
|
// Rollback remote offer
|
||||||
|
// @ts-ignore
|
||||||
|
if (pc.setRemoteDescription) {
|
||||||
|
// @ts-ignore
|
||||||
|
await pc.setRemoteDescription({ type: 'rollback' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[WebRTC] Rollback successful');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[WebRTC] Rollback failed:', err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
|
||||||
|
if (!this.peerConnection) {
|
||||||
|
console.error('[WebRTC] setRemoteDescription: PeerConnection is null');
|
||||||
|
throw new Error('PeerConnection not initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!description.sdp) {
|
||||||
|
throw new Error('setRemoteDescription: sdp is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[WebRTC] Setting remote description, type:', description.type);
|
||||||
|
|
||||||
|
const desc = new RTCSessionDescription({
|
||||||
|
type: description.type,
|
||||||
|
sdp: description.sdp,
|
||||||
|
});
|
||||||
|
await this.peerConnection.setRemoteDescription(desc);
|
||||||
|
|
||||||
|
console.log('[WebRTC] Remote description set successfully');
|
||||||
|
|
||||||
|
// Process pending candidates after remote description is set
|
||||||
|
await this.processPendingCandidates();
|
||||||
|
}
|
||||||
|
|
||||||
|
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
|
||||||
|
if (!this.peerConnection) {
|
||||||
|
this.pendingCandidates.push(candidate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.peerConnection.remoteDescription) {
|
||||||
|
this.pendingCandidates.push(candidate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const iceCandidate = new RTCIceCandidate(candidate);
|
||||||
|
await this.peerConnection.addIceCandidate(iceCandidate);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WebRTC] Failed to add ICE candidate:', error);
|
||||||
|
this.pendingCandidates.push(candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async processPendingCandidates(): Promise<void> {
|
||||||
|
if (this.pendingCandidates.length === 0) return;
|
||||||
|
if (!this.peerConnection) return;
|
||||||
|
|
||||||
|
const candidates = [...this.pendingCandidates];
|
||||||
|
this.pendingCandidates = [];
|
||||||
|
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
if (!this.peerConnection) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const iceCandidate = new RTCIceCandidate(candidate);
|
||||||
|
await this.peerConnection.addIceCandidate(iceCandidate);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WebRTC] Failed to add pending ICE candidate:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setMuted(muted: boolean): void {
|
||||||
|
if (!this.localStream) return;
|
||||||
|
const audioTracks = this.localStream.getAudioTracks();
|
||||||
|
audioTracks.forEach((track) => {
|
||||||
|
track.enabled = !muted;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
isMuted(): boolean {
|
||||||
|
if (!this.localStream) return false;
|
||||||
|
const audioTracks = this.localStream.getAudioTracks();
|
||||||
|
return audioTracks.some((track) => !track.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable video - official replaceTrack approach
|
||||||
|
*/
|
||||||
|
async enableVideo(): Promise<MediaStream> {
|
||||||
|
if (!this.peerConnection) throw new Error('PeerConnection not initialized');
|
||||||
|
|
||||||
|
console.log('[WebRTC] Enabling video...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get video stream
|
||||||
|
const videoStream = await mediaDevices.getUserMedia({
|
||||||
|
video: { facingMode: 'user', frameRate: 30 },
|
||||||
|
audio: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const videoTrack = videoStream.getVideoTracks()[0];
|
||||||
|
|
||||||
|
// Find the sender for video and replace the track
|
||||||
|
const senders = this.peerConnection.getSenders();
|
||||||
|
const videoSender = senders.find(s => s.track?.kind === 'video');
|
||||||
|
|
||||||
|
if (videoSender) {
|
||||||
|
await videoSender.replaceTrack(videoTrack);
|
||||||
|
console.log('[WebRTC] Video track replaced on existing sender');
|
||||||
|
} else {
|
||||||
|
// No existing video sender, add track
|
||||||
|
this.peerConnection.addTrack(videoTrack, this.localStream!);
|
||||||
|
console.log('[WebRTC] Video track added via addTrack');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local stream
|
||||||
|
const newStream = new MediaStream();
|
||||||
|
|
||||||
|
if (this.localStream) {
|
||||||
|
this.localStream.getAudioTracks().forEach((track) => {
|
||||||
|
newStream.addTrack(track);
|
||||||
|
});
|
||||||
|
// Stop old video tracks
|
||||||
|
this.localStream.getVideoTracks().forEach((track) => {
|
||||||
|
track.stop();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
newStream.addTrack(videoTrack);
|
||||||
|
this.localStream = newStream;
|
||||||
|
|
||||||
|
console.log('[WebRTC] Video enabled successfully');
|
||||||
|
|
||||||
|
return newStream;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WebRTC] Failed to enable video:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable video - official replaceTrack approach
|
||||||
|
*/
|
||||||
|
async disableVideo(): Promise<MediaStream | null> {
|
||||||
|
if (!this.peerConnection) {
|
||||||
|
console.log('[WebRTC] disableVideo: No peer connection');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!this.localStream) {
|
||||||
|
console.log('[WebRTC] disableVideo: No local stream');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[WebRTC] Disabling video...');
|
||||||
|
|
||||||
|
// Find the sender for video and replace with null
|
||||||
|
const senders = this.peerConnection.getSenders();
|
||||||
|
const videoSender = senders.find(s => s.track?.kind === 'video');
|
||||||
|
|
||||||
|
if (videoSender) {
|
||||||
|
await videoSender.replaceTrack(null);
|
||||||
|
console.log('[WebRTC] Video track removed from sender');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop video tracks
|
||||||
|
const videoTracks = this.localStream.getVideoTracks();
|
||||||
|
videoTracks.forEach((track) => {
|
||||||
|
track.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new stream with only audio
|
||||||
|
const newStream = new MediaStream();
|
||||||
|
this.localStream.getAudioTracks().forEach((track) => {
|
||||||
|
newStream.addTrack(track);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.localStream = newStream;
|
||||||
|
console.log('[WebRTC] Video disabled successfully');
|
||||||
|
|
||||||
|
return newStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
isVideoEnabled(): boolean {
|
||||||
|
if (!this.localStream) return false;
|
||||||
|
const videoTracks = this.localStream.getVideoTracks();
|
||||||
|
return videoTracks.length > 0 && videoTracks.some((track) => track.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRemoteStream(): MediaStream | null {
|
||||||
|
return this.remoteStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLocalStream(): MediaStream | null {
|
||||||
|
return this.localStream;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPeerConnection(): RTCPeerConnection | null {
|
||||||
|
return this.peerConnection;
|
||||||
|
}
|
||||||
|
|
||||||
|
getSignalingState(): RTCSignalingState | null {
|
||||||
|
return this.peerConnection?.signalingState || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getIsInitiator(): boolean {
|
||||||
|
return this.isInitiator;
|
||||||
|
}
|
||||||
|
|
||||||
|
onEvent(handler: EventHandler): () => void {
|
||||||
|
this.eventHandlers.add(handler);
|
||||||
|
return () => {
|
||||||
|
this.eventHandlers.delete(handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(event: WebRTCManagerEvent): void {
|
||||||
|
this.eventHandlers.forEach((handler) => {
|
||||||
|
try {
|
||||||
|
handler(event);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WebRTC] Event handler error:', error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== ICE Restart 支持 ==========
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 ICE 连接状态变化
|
||||||
|
* 根据 W3C 规范: disconnected 状态可能间歇性触发并自发解决
|
||||||
|
* failed 状态表示需要 ICE restart
|
||||||
|
*/
|
||||||
|
private handleIceConnectionStateChange(state: ConnectionState): void {
|
||||||
|
console.log('[WebRTC] ICE connection state:', state);
|
||||||
|
|
||||||
|
switch (state) {
|
||||||
|
case 'connected': // 连接成功,重置重连状态
|
||||||
|
this.resetReconnectState();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'disconnected':
|
||||||
|
// 临时断开,等待一段时间看是否自动恢复
|
||||||
|
this.scheduleDisconnectCheck();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'failed':
|
||||||
|
// ICE 失败,尝试 ICE restart
|
||||||
|
this.attemptIceRestart();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'closed':
|
||||||
|
this.clearReconnectTimers();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 PeerConnection 连接状态变化
|
||||||
|
*/
|
||||||
|
private handleConnectionStateChange(state: ConnectionState): void {
|
||||||
|
console.log('[WebRTC] PeerConnection state:', state);
|
||||||
|
|
||||||
|
switch (state) {
|
||||||
|
case 'connected':
|
||||||
|
this.resetReconnectState();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'disconnected':
|
||||||
|
// 等待短暂时间看是否自动恢复
|
||||||
|
this.scheduleDisconnectCheck();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'failed':
|
||||||
|
// 连接完全失败
|
||||||
|
this.emit({ type: 'error', error: new Error('Connection failed') });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安排断开检查
|
||||||
|
* 给 disconnected 状态一个恢复窗口(5秒)
|
||||||
|
*/
|
||||||
|
private scheduleDisconnectCheck(): void {
|
||||||
|
if (this.disconnectTimer) {
|
||||||
|
clearTimeout(this.disconnectTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.disconnectTimer = setTimeout(() => {
|
||||||
|
const pc = this.peerConnection;
|
||||||
|
if (!pc || this.disposed) return;
|
||||||
|
|
||||||
|
// 如果 5 秒后仍然是 disconnected,尝试 ICE restart
|
||||||
|
if (pc.iceConnectionState === 'disconnected' || pc.iceConnectionState === 'failed') {
|
||||||
|
console.log('[WebRTC] Connection still disconnected after 5s, attempting ICE restart');
|
||||||
|
this.attemptIceRestart();
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 尝试 ICE restart
|
||||||
|
* 参考: https://developer.mozilla.org/en-US/docs/Web/API/WebRTC_API/Session_lifetime#ice_restart
|
||||||
|
*/
|
||||||
|
private async attemptIceRestart(): Promise<void> {
|
||||||
|
if (this.reconnectAttempts >= this.MAX_RECONNECT_ATTEMPTS) {
|
||||||
|
console.error('[WebRTC] Max reconnection attempts reached');
|
||||||
|
this.emit({ type: 'error', error: new Error('Max reconnection attempts reached') });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pc = this.peerConnection;
|
||||||
|
if (!pc || this.disposed) {
|
||||||
|
console.log('[WebRTC] Cannot restart ICE: PeerConnection not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查信令状态
|
||||||
|
if (pc.signalingState !== 'stable') {
|
||||||
|
console.log('[WebRTC] Cannot restart ICE: signaling state not stable:', pc.signalingState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.reconnectAttempts++;
|
||||||
|
console.log(`[WebRTC] Attempting ICE restart (${this.reconnectAttempts}/${this.MAX_RECONNECT_ATTEMPTS})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 尝试使用 restartIce() API (现代浏览器支持)
|
||||||
|
// @ts-ignore
|
||||||
|
if (pc.restartIce) {
|
||||||
|
// @ts-ignore
|
||||||
|
pc.restartIce();
|
||||||
|
console.log('[WebRTC] restartIce() called');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新的 offer,触发 ICE restart
|
||||||
|
const offer = await pc.createOffer({ iceRestart: true });
|
||||||
|
await pc.setLocalDescription(offer);
|
||||||
|
|
||||||
|
console.log('[WebRTC] ICE restart offer created');
|
||||||
|
|
||||||
|
// 发送新的 offer 给对方
|
||||||
|
this.emit({ type: 'negotiationneeded', offer });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WebRTC] ICE restart failed:', error);
|
||||||
|
this.emit({ type: 'error', error: error as Error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置重连状态
|
||||||
|
*/
|
||||||
|
private resetReconnectState(): void {
|
||||||
|
this.reconnectAttempts = 0;
|
||||||
|
this.clearReconnectTimers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除重连定时器
|
||||||
|
*/
|
||||||
|
private clearReconnectTimers(): void {
|
||||||
|
if (this.disconnectTimer) {
|
||||||
|
clearTimeout(this.disconnectTimer);
|
||||||
|
this.disconnectTimer = null;
|
||||||
|
}
|
||||||
|
if (this.reconnectTimer) {
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.disposed = true;
|
||||||
|
this.eventHandlers.clear();
|
||||||
|
this.pendingCandidates = [];
|
||||||
|
this.clearReconnectTimers();
|
||||||
|
|
||||||
|
if (this.localStream) {
|
||||||
|
this.localStream.getTracks().forEach((track) => track.stop());
|
||||||
|
this.localStream = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.remoteStream) {
|
||||||
|
this.remoteStream.getTracks().forEach((track) => track.stop());
|
||||||
|
this.remoteStream = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.peerConnection) {
|
||||||
|
this.peerConnection.close();
|
||||||
|
this.peerConnection = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const webrtcManager = new WebRTCManager();
|
||||||
89
src/services/webrtc/WebRTCManager.web.ts
Normal file
89
src/services/webrtc/WebRTCManager.web.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
// WebRTCManager for Web platform (stub implementation)
|
||||||
|
import type {
|
||||||
|
ICEServer,
|
||||||
|
ConnectionState,
|
||||||
|
WebRTCManagerEvent,
|
||||||
|
} from './WebRTCManager';
|
||||||
|
|
||||||
|
type EventHandler = (event: WebRTCManagerEvent) => void;
|
||||||
|
|
||||||
|
class WebRTCManagerWeb {
|
||||||
|
private eventHandlers: Set<EventHandler> = new Set();
|
||||||
|
private disposed = false;
|
||||||
|
|
||||||
|
async initialize(iceServers: ICEServer[] = []): Promise<void> {
|
||||||
|
console.log('[WebRTC] WebRTC not supported on web platform');
|
||||||
|
}
|
||||||
|
|
||||||
|
async createLocalStream(voiceOnly = true): Promise<MediaStream | null> {
|
||||||
|
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async startCall(isInitiator: boolean): Promise<RTCSessionDescriptionInit | null> {
|
||||||
|
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createOffer(): Promise<RTCSessionDescriptionInit | null> {
|
||||||
|
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAnswer(): Promise<RTCSessionDescriptionInit | null> {
|
||||||
|
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async createAnswerFromOffer(offer: RTCSessionDescriptionInit): Promise<RTCSessionDescriptionInit | null> {
|
||||||
|
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setRemoteDescription(description: RTCSessionDescriptionInit): Promise<void> {
|
||||||
|
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||||
|
}
|
||||||
|
|
||||||
|
async addIceCandidate(candidate: RTCIceCandidateInit): Promise<void> {
|
||||||
|
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||||
|
}
|
||||||
|
|
||||||
|
setMuted(muted: boolean): void {
|
||||||
|
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||||
|
}
|
||||||
|
|
||||||
|
isMuted(): boolean {
|
||||||
|
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRemoteStream(): MediaStream | null {
|
||||||
|
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getLocalStream(): MediaStream | null {
|
||||||
|
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPeerConnection(): RTCPeerConnection | null {
|
||||||
|
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
onEvent(handler: EventHandler): () => void {
|
||||||
|
console.warn('[WebRTC] WebRTC not supported on web platform');
|
||||||
|
return () => {
|
||||||
|
this.eventHandlers.delete(handler);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
this.disposed = true;
|
||||||
|
this.eventHandlers.clear();
|
||||||
|
console.log('[WebRTC] WebRTC disposed on web platform');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const webrtcManager = new WebRTCManagerWeb();
|
||||||
2
src/services/webrtc/index.ts
Normal file
2
src/services/webrtc/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { webrtcManager } from './WebRTCManager';
|
||||||
|
export type { ICEServer, ConnectionState, WebRTCManagerEvent } from './WebRTCManager';
|
||||||
1160
src/services/wsService.ts
Normal file
1160
src/services/wsService.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,8 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { User } from '../types';
|
import { User } from '../types';
|
||||||
import { authService, resolveAuthApiError, LoginRequest, RegisterRequest } from '../services';
|
import { authService, resolveAuthApiError, LoginRequest, RegisterRequest } from '../services';
|
||||||
import { sseService } from '../services/sseService';
|
import { wsService } from '../services/wsService';
|
||||||
|
import { callStore } from './callStore';
|
||||||
import {
|
import {
|
||||||
initDatabase,
|
initDatabase,
|
||||||
closeDatabase,
|
closeDatabase,
|
||||||
@@ -95,7 +96,8 @@ function resolveLoginError(error: any): string {
|
|||||||
// ── 启动 SSE 实时服务 ──
|
// ── 启动 SSE 实时服务 ──
|
||||||
async function startRealtime(): Promise<void> {
|
async function startRealtime(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await sseService.start();
|
await wsService.start();
|
||||||
|
callStore.getState().initCall();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[AuthStore] 启动 SSE 服务失败:', error);
|
console.error('[AuthStore] 启动 SSE 服务失败:', error);
|
||||||
}
|
}
|
||||||
@@ -212,7 +214,7 @@ export const useAuthStore = create<AuthState>((set) => ({
|
|||||||
// 1. 通知服务端(Token 清理在 authService 内部完成)
|
// 1. 通知服务端(Token 清理在 authService 内部完成)
|
||||||
await authService.logout();
|
await authService.logout();
|
||||||
// 2. 停止 SSE
|
// 2. 停止 SSE
|
||||||
sseService.stop();
|
wsService.stop();
|
||||||
// 3. 清除 DB 中的用户缓存(DB 此时一定已初始化)
|
// 3. 清除 DB 中的用户缓存(DB 此时一定已初始化)
|
||||||
await clearCurrentUserCache().catch(() => {});
|
await clearCurrentUserCache().catch(() => {});
|
||||||
// 4. 关闭数据库连接
|
// 4. 关闭数据库连接
|
||||||
|
|||||||
127
src/stores/baseManager.ts
Normal file
127
src/stores/baseManager.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* BaseManager - 通用 Manager 基类
|
||||||
|
*
|
||||||
|
* 提取自 postManager/groupManager/userManager 的重复逻辑:
|
||||||
|
* - 请求去重 (dedupe)
|
||||||
|
* - 缓存条目管理 (CacheEntry)
|
||||||
|
* - 过期检查 (isExpired)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CacheBus, CacheEvent } from './cacheBus';
|
||||||
|
|
||||||
|
/** 缓存条目结构 */
|
||||||
|
export interface CacheEntry<T> {
|
||||||
|
data: T;
|
||||||
|
timestamp: number;
|
||||||
|
ttl: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 创建缓存条目 */
|
||||||
|
export function createCacheEntry<T>(data: T, ttl: number): CacheEntry<T> {
|
||||||
|
return { data, timestamp: Date.now(), ttl };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 检查缓存是否过期 */
|
||||||
|
export function isCacheExpired(entry: CacheEntry<any>): boolean {
|
||||||
|
return Date.now() - entry.timestamp > entry.ttl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用 Manager 基类
|
||||||
|
*
|
||||||
|
* 子类只需关注业务逻辑,无需重复实现缓存和去重机制
|
||||||
|
*/
|
||||||
|
export abstract class BaseManager<
|
||||||
|
TSnapshot,
|
||||||
|
TEvent extends CacheEvent = CacheEvent
|
||||||
|
> extends CacheBus<TSnapshot, TEvent> {
|
||||||
|
/** 待处理请求 Map,用于去重 */
|
||||||
|
protected pendingRequests = new Map<string, Promise<any>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求去重:相同 key 的并发请求会被合并为一个
|
||||||
|
* @param key 唯一标识
|
||||||
|
* @param fetcher 实际请求函数
|
||||||
|
*/
|
||||||
|
protected async dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||||
|
const pending = this.pendingRequests.get(key) as Promise<T> | undefined;
|
||||||
|
if (pending) return pending;
|
||||||
|
|
||||||
|
const request = fetcher().finally(() => {
|
||||||
|
this.pendingRequests.delete(key);
|
||||||
|
});
|
||||||
|
this.pendingRequests.set(key, request);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除所有待处理请求 */
|
||||||
|
protected clearPendingRequests(): void {
|
||||||
|
this.pendingRequests.clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 带缓存的 Manager 基类
|
||||||
|
*
|
||||||
|
* 进一步封装内存缓存逻辑
|
||||||
|
*/
|
||||||
|
export abstract class CachedManager<
|
||||||
|
TSnapshot,
|
||||||
|
TEvent extends CacheEvent = CacheEvent
|
||||||
|
> extends BaseManager<TSnapshot, TEvent> {
|
||||||
|
/** 内存缓存 Map */
|
||||||
|
protected memoryCache = new Map<string, CacheEntry<any>>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取缓存
|
||||||
|
* @param key 缓存 key
|
||||||
|
* @returns 缓存数据,过期或不存在返回 null
|
||||||
|
*/
|
||||||
|
protected getFromCache<T>(key: string): T | null {
|
||||||
|
const entry = this.memoryCache.get(key) as CacheEntry<T> | undefined;
|
||||||
|
if (!entry) return null;
|
||||||
|
if (isCacheExpired(entry)) {
|
||||||
|
this.memoryCache.delete(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置缓存
|
||||||
|
* @param key 缓存 key
|
||||||
|
* @param data 数据
|
||||||
|
* @param ttl 过期时间(毫秒)
|
||||||
|
*/
|
||||||
|
protected setCache<T>(key: string, data: T, ttl: number): void {
|
||||||
|
this.memoryCache.set(key, createCacheEntry(data, ttl));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查缓存是否存在且未过期
|
||||||
|
*/
|
||||||
|
protected hasValidCache(key: string): boolean {
|
||||||
|
const entry = this.memoryCache.get(key);
|
||||||
|
if (!entry) return false;
|
||||||
|
return !isCacheExpired(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查缓存是否存在但已过期
|
||||||
|
*/
|
||||||
|
protected hasExpiredCache(key: string): boolean {
|
||||||
|
const entry = this.memoryCache.get(key);
|
||||||
|
if (!entry) return false;
|
||||||
|
return isCacheExpired(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除所有缓存 */
|
||||||
|
protected clearCache(): void {
|
||||||
|
this.memoryCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 清除指定 key 的缓存 */
|
||||||
|
protected deleteCache(key: string): void {
|
||||||
|
this.memoryCache.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
824
src/stores/callStore.ts
Normal file
824
src/stores/callStore.ts
Normal file
@@ -0,0 +1,824 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import { MediaStream } from 'react-native-webrtc';
|
||||||
|
import {
|
||||||
|
wsService,
|
||||||
|
WSCallIncomingMessage,
|
||||||
|
WSCallSDPMessage,
|
||||||
|
WSCallICEMessage,
|
||||||
|
WSErrorMessage,
|
||||||
|
} from '../services/wsService';
|
||||||
|
import { webrtcManager, ICEServer } from '../services/webrtc';
|
||||||
|
import { useAuthStore } from './authStore';
|
||||||
|
import { useUserStore } from './userStore';
|
||||||
|
import { userManager } from './userManager';
|
||||||
|
|
||||||
|
export type CallStatus =
|
||||||
|
| 'idle' // 空闲状态
|
||||||
|
| 'calling' // 正在呼出(已发送邀请,等待对方响应)
|
||||||
|
| 'ringing' // 来电响铃中
|
||||||
|
| 'connecting' // 正在建立连接(WebRTC 协商中)
|
||||||
|
| 'connected' // 已接通
|
||||||
|
| 'reconnecting' // 网络断开,正在重连
|
||||||
|
| 'ended' // 已结束
|
||||||
|
| 'failed'; // 连接失败
|
||||||
|
|
||||||
|
export type CallType = 'voice' | 'video';
|
||||||
|
|
||||||
|
export interface CallSession {
|
||||||
|
id: string;
|
||||||
|
conversationId: string;
|
||||||
|
peerId: string;
|
||||||
|
peerName?: string;
|
||||||
|
peerAvatar?: string | null;
|
||||||
|
status: CallStatus;
|
||||||
|
callType: CallType;
|
||||||
|
startedAt?: number;
|
||||||
|
duration: number;
|
||||||
|
isMuted: boolean;
|
||||||
|
isSpeakerOn: boolean;
|
||||||
|
isVideoEnabled: boolean;
|
||||||
|
isPeerVideoEnabled: boolean;
|
||||||
|
isInitiator: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IncomingCallInfo {
|
||||||
|
callId: string;
|
||||||
|
conversationId: string;
|
||||||
|
callerId: string;
|
||||||
|
callerName?: string;
|
||||||
|
callerAvatar?: string | null;
|
||||||
|
callType: string;
|
||||||
|
iceServers: ICEServer[];
|
||||||
|
receivedAt: number;
|
||||||
|
lifetime?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CallState {
|
||||||
|
currentCall: CallSession | null;
|
||||||
|
incomingCall: IncomingCallInfo | null;
|
||||||
|
callDuration: number;
|
||||||
|
peerStream: MediaStream | null;
|
||||||
|
localStream: MediaStream | null;
|
||||||
|
isMinimized: boolean;
|
||||||
|
|
||||||
|
initCall: () => () => void;
|
||||||
|
startCall: (
|
||||||
|
conversationId: string,
|
||||||
|
calleeId: string,
|
||||||
|
calleeInfo?: { nickname?: string; username?: string; avatar?: string | null },
|
||||||
|
callType?: CallType
|
||||||
|
) => Promise<void>;
|
||||||
|
acceptCall: () => Promise<void>;
|
||||||
|
rejectCall: () => void;
|
||||||
|
endCall: (reason?: string) => Promise<void>;
|
||||||
|
toggleMute: () => void;
|
||||||
|
toggleSpeaker: () => void;
|
||||||
|
toggleMinimize: () => void;
|
||||||
|
toggleVideo: () => Promise<void>;
|
||||||
|
setVideoEnabled: (enabled: boolean) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Module-level variables for timers
|
||||||
|
let durationTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let callTimeoutTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let initCallUnsub: (() => void) | null = null;
|
||||||
|
let unsubInvited: (() => void) | null = null;
|
||||||
|
let pendingOffer: { callId: string; sdp: string } | null = null;
|
||||||
|
let rtcUnsubscribe: (() => void) | null = null; // WebRTC event subscription
|
||||||
|
|
||||||
|
// Constants
|
||||||
|
const CALL_LIFETIME_MS = 55000;
|
||||||
|
const CALL_TIMEOUT_MS = 115000;
|
||||||
|
const IGNORE_CALL_ID_TTL = 30000;
|
||||||
|
|
||||||
|
// Track processed call IDs to prevent duplicates
|
||||||
|
const processedCallIds = new Map<string, number>();
|
||||||
|
|
||||||
|
function cleanupProcessedCallIds() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [callId, timestamp] of processedCallIds) {
|
||||||
|
if (now - timestamp > IGNORE_CALL_ID_TTL) {
|
||||||
|
processedCallIds.delete(callId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified WebRTC event handler
|
||||||
|
* This is called from both initiator and receiver paths
|
||||||
|
*/
|
||||||
|
function setupWebRTCEvents(callId: string, myUserId: string): void {
|
||||||
|
// Clean up any existing subscription
|
||||||
|
if (rtcUnsubscribe) {
|
||||||
|
rtcUnsubscribe();
|
||||||
|
rtcUnsubscribe = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
rtcUnsubscribe = webrtcManager.onEvent((event) => {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'icecandidate':
|
||||||
|
if (event.candidate) {
|
||||||
|
wsService.sendCallICE(callId, JSON.stringify(event.candidate.toJSON()));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'remotestream':
|
||||||
|
handleRemoteStream(event.stream);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'negotiationneeded':
|
||||||
|
handleNegotiationNeeded(callId, event.offer);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'connectionstatechange':
|
||||||
|
handleConnectionStateChange(event.state);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
console.error('[CallStore] WebRTC error:', event.error);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle remote stream with video track detection
|
||||||
|
*/
|
||||||
|
function handleRemoteStream(stream: MediaStream): void {
|
||||||
|
callStore.setState({ peerStream: stream });
|
||||||
|
|
||||||
|
// Detect video tracks in remote stream
|
||||||
|
const videoTracks = stream.getVideoTracks();
|
||||||
|
const hasPeerVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled);
|
||||||
|
|
||||||
|
console.log('[CallStore] Remote stream received, hasVideo:', hasPeerVideo, 'videoTracks:', videoTracks.length);
|
||||||
|
|
||||||
|
callStore.setState((s) => ({
|
||||||
|
currentCall: s.currentCall
|
||||||
|
? { ...s.currentCall, isPeerVideoEnabled: hasPeerVideo }
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle negotiation needed event - send offer to peer
|
||||||
|
*/
|
||||||
|
function handleNegotiationNeeded(callId: string, offer: RTCSessionDescriptionInit): void {
|
||||||
|
console.log('[CallStore] Negotiation needed, sending offer');
|
||||||
|
wsService.sendCallSDP(callId, 'offer', offer.sdp || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle connection state change with enhanced state machine
|
||||||
|
*/
|
||||||
|
function handleConnectionStateChange(state: string): void {
|
||||||
|
console.log('[CallStore] Connection state changed:', state);
|
||||||
|
|
||||||
|
const { currentCall } = callStore.getState();
|
||||||
|
if (!currentCall) return;
|
||||||
|
|
||||||
|
switch (state) {
|
||||||
|
case 'connected':
|
||||||
|
// 连接成功,开始计时
|
||||||
|
const now = Date.now();
|
||||||
|
callStore.setState((s) => ({
|
||||||
|
currentCall: s.currentCall
|
||||||
|
? { ...s.currentCall, status: 'connected', startedAt: now }
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (durationTimer) clearInterval(durationTimer);
|
||||||
|
durationTimer = setInterval(() => {
|
||||||
|
callStore.setState((s) => ({ callDuration: s.callDuration + 1 }));
|
||||||
|
}, 1000);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'disconnected':
|
||||||
|
// 临时断开,进入重连状态
|
||||||
|
if (currentCall.status === 'connected') {
|
||||||
|
callStore.setState((s) => ({
|
||||||
|
currentCall: s.currentCall
|
||||||
|
? { ...s.currentCall, status: 'reconnecting' }
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'failed':
|
||||||
|
// 连接失败
|
||||||
|
if (currentCall.status === 'connected' || currentCall.status === 'reconnecting') {
|
||||||
|
callStore.getState().endCall('connection_failed');
|
||||||
|
} else if (currentCall.status === 'connecting') {
|
||||||
|
// 初始连接失败
|
||||||
|
callStore.getState().endCall('connection_failed');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'closed':
|
||||||
|
// 连接关闭
|
||||||
|
if (currentCall.status !== 'ended' && currentCall.status !== 'failed') {
|
||||||
|
callStore.getState().endCall('connection_closed');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming SDP offer with Glare handling
|
||||||
|
*/
|
||||||
|
async function handleIncomingOffer(msg: WSCallSDPMessage, myUserId: string): Promise<void> {
|
||||||
|
let pc = webrtcManager.getPeerConnection();
|
||||||
|
if (!pc) {
|
||||||
|
// Cache offer for later processing
|
||||||
|
pendingOffer = { callId: msg.call_id, sdp: msg.payload.sdp };
|
||||||
|
console.log('[CallStore] Caching pending offer, PeerConnection not ready yet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let signalingState = pc.signalingState;
|
||||||
|
console.log('[CallStore] Received offer, signalingState:', signalingState);
|
||||||
|
|
||||||
|
// Glare handling: if we're not in stable state, we have a conflict
|
||||||
|
if (signalingState !== 'stable') {
|
||||||
|
const remoteUserId = msg.from_id;
|
||||||
|
|
||||||
|
// Compare user IDs to determine who wins
|
||||||
|
// Higher user ID wins the negotiation
|
||||||
|
if (myUserId > remoteUserId) {
|
||||||
|
console.log('[CallStore] Glare: I win (my ID > remote ID), ignoring incoming offer');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[CallStore] Glare: Remote wins (remote ID > my ID), rolling back');
|
||||||
|
|
||||||
|
// Rollback to stable state
|
||||||
|
try {
|
||||||
|
await webrtcManager.rollback();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CallStore] Rollback failed:', err);
|
||||||
|
// If rollback fails because we're already stable, that's fine - just proceed
|
||||||
|
if (pc.signalingState !== 'stable') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-check state after rollback
|
||||||
|
signalingState = pc.signalingState;
|
||||||
|
if (signalingState !== 'stable') {
|
||||||
|
console.warn('[CallStore] Not stable after rollback, state:', signalingState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingOffer = null;
|
||||||
|
|
||||||
|
// Set remote description and create answer
|
||||||
|
try {
|
||||||
|
await webrtcManager.setRemoteDescription({
|
||||||
|
type: msg.payload.sdp_type as 'offer' | 'answer',
|
||||||
|
sdp: msg.payload.sdp,
|
||||||
|
});
|
||||||
|
|
||||||
|
const answer = await webrtcManager.createAnswer();
|
||||||
|
wsService.sendCallSDP(msg.call_id, 'answer', answer.sdp || '');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CallStore] Failed to handle incoming offer:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle incoming SDP answer
|
||||||
|
*/
|
||||||
|
async function handleIncomingAnswer(msg: WSCallSDPMessage): Promise<void> {
|
||||||
|
const pc = webrtcManager.getPeerConnection();
|
||||||
|
if (!pc) return;
|
||||||
|
|
||||||
|
const signalingState = pc.signalingState;
|
||||||
|
console.log('[CallStore] Received answer, signalingState:', signalingState);
|
||||||
|
|
||||||
|
if (signalingState !== 'have-local-offer') {
|
||||||
|
console.warn('[CallStore] Ignoring answer, signaling state is', signalingState);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await webrtcManager.setRemoteDescription({
|
||||||
|
type: msg.payload.sdp_type as 'offer' | 'answer',
|
||||||
|
sdp: msg.payload.sdp,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CallStore] Failed to set remote description from answer:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up all resources
|
||||||
|
*/
|
||||||
|
function cleanupResources(): void {
|
||||||
|
if (callTimeoutTimer) {
|
||||||
|
clearTimeout(callTimeoutTimer);
|
||||||
|
callTimeoutTimer = null;
|
||||||
|
}
|
||||||
|
if (durationTimer) {
|
||||||
|
clearInterval(durationTimer);
|
||||||
|
durationTimer = null;
|
||||||
|
}
|
||||||
|
if (rtcUnsubscribe) {
|
||||||
|
rtcUnsubscribe();
|
||||||
|
rtcUnsubscribe = null;
|
||||||
|
}
|
||||||
|
pendingOffer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const callStore = create<CallState>((set, get) => ({
|
||||||
|
currentCall: null,
|
||||||
|
incomingCall: null,
|
||||||
|
callDuration: 0,
|
||||||
|
peerStream: null,
|
||||||
|
localStream: null,
|
||||||
|
isMinimized: false,
|
||||||
|
|
||||||
|
initCall: () => {
|
||||||
|
// Prevent duplicate handler registration
|
||||||
|
if (initCallUnsub) {
|
||||||
|
initCallUnsub();
|
||||||
|
initCallUnsub = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubs: Array<() => void> = [];
|
||||||
|
|
||||||
|
unsubs.push(
|
||||||
|
wsService.on('call_incoming', async (msg) => {
|
||||||
|
cleanupProcessedCallIds();
|
||||||
|
|
||||||
|
if (processedCallIds.has(msg.call_id)) {
|
||||||
|
console.log('[CallStore] Ignoring already processed call:', msg.call_id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { currentCall, incomingCall } = get();
|
||||||
|
if (incomingCall) {
|
||||||
|
wsService.sendCallBusy(msg.call_id);
|
||||||
|
processedCallIds.set(msg.call_id, Date.now());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentCall && currentCall.status !== 'idle') {
|
||||||
|
wsService.sendCallBusy(msg.call_id);
|
||||||
|
processedCallIds.set(msg.call_id, Date.now());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check lifetime expiry
|
||||||
|
const callAge = Date.now() - msg.created_at;
|
||||||
|
const lifetime = msg.lifetime || 60000;
|
||||||
|
if (callAge > lifetime - 5000) {
|
||||||
|
console.log('[CallStore] Ignoring stale incoming call, age:', callAge);
|
||||||
|
wsService.sendCallReject(msg.call_id);
|
||||||
|
processedCallIds.set(msg.call_id, Date.now());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch caller info
|
||||||
|
let caller: { nickname?: string; username?: string; avatar?: string | null } | null =
|
||||||
|
useUserStore.getState().userCache[msg.caller_id];
|
||||||
|
if (!caller) {
|
||||||
|
try {
|
||||||
|
const fetchedCaller = await userManager.getUserById(msg.caller_id);
|
||||||
|
if (fetchedCaller) {
|
||||||
|
caller = {
|
||||||
|
nickname: fetchedCaller.nickname,
|
||||||
|
username: fetchedCaller.username,
|
||||||
|
avatar: fetchedCaller.avatar,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log('[CallStore] Failed to fetch caller info:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const callerName = caller?.nickname || caller?.username || msg.caller_id;
|
||||||
|
|
||||||
|
set({
|
||||||
|
incomingCall: {
|
||||||
|
callId: msg.call_id,
|
||||||
|
conversationId: msg.conversation_id,
|
||||||
|
callerId: msg.caller_id,
|
||||||
|
callerName,
|
||||||
|
callerAvatar: caller?.avatar,
|
||||||
|
callType: msg.call_type,
|
||||||
|
iceServers: msg.ice_servers || [],
|
||||||
|
receivedAt: Date.now(),
|
||||||
|
lifetime: msg.lifetime,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
processedCallIds.set(msg.call_id, Date.now());
|
||||||
|
|
||||||
|
// Set timeout based on lifetime
|
||||||
|
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
|
||||||
|
const remainingTime = Math.max(lifetime - callAge - 1000, 5000);
|
||||||
|
callTimeoutTimer = setTimeout(() => {
|
||||||
|
const { incomingCall: ic } = get();
|
||||||
|
if (ic?.callId === msg.call_id) {
|
||||||
|
console.log('[CallStore] Incoming call timeout');
|
||||||
|
wsService.sendCallReject(msg.call_id);
|
||||||
|
processedCallIds.set(msg.call_id, Date.now());
|
||||||
|
set({ incomingCall: null });
|
||||||
|
}
|
||||||
|
}, remainingTime);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
unsubs.push(
|
||||||
|
wsService.on('call_accepted', async (msg) => {
|
||||||
|
const { currentCall } = get();
|
||||||
|
if (!currentCall || currentCall.id !== msg.call_id) return;
|
||||||
|
|
||||||
|
if (!currentCall.isInitiator) {
|
||||||
|
console.log('[CallStore] Ignoring call_accepted, we are not the initiator');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callTimeoutTimer) {
|
||||||
|
clearTimeout(callTimeoutTimer);
|
||||||
|
callTimeoutTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const myUserId = useAuthStore.getState().currentUser?.id || '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isVideoCall = currentCall.callType === 'video';
|
||||||
|
await webrtcManager.initialize(msg.ice_servers || []);
|
||||||
|
const newStream = await webrtcManager.createLocalStream(!isVideoCall);
|
||||||
|
|
||||||
|
set({ localStream: newStream });
|
||||||
|
|
||||||
|
// Setup unified WebRTC event handler
|
||||||
|
setupWebRTCEvents(currentCall.id, myUserId);
|
||||||
|
|
||||||
|
// Start call with transceiver-based approach
|
||||||
|
const offer = await webrtcManager.startCall(true, currentCall.callType);
|
||||||
|
if (offer) {
|
||||||
|
wsService.sendCallSDP(currentCall.id, 'offer', offer.sdp || '');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CallStore] call_accepted error:', err);
|
||||||
|
get().endCall('connection_failed');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
unsubs.push(
|
||||||
|
wsService.on('call_rejected', (msg) => {
|
||||||
|
const { currentCall } = get();
|
||||||
|
if (currentCall?.id !== msg.call_id) return;
|
||||||
|
console.log('[CallStore] Call rejected');
|
||||||
|
get().endCall('rejected');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
unsubs.push(
|
||||||
|
wsService.on('call_busy', (msg) => {
|
||||||
|
const { currentCall } = get();
|
||||||
|
if (currentCall?.id !== msg.call_id) return;
|
||||||
|
console.log('[CallStore] Call busy');
|
||||||
|
get().endCall('busy');
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
unsubs.push(
|
||||||
|
wsService.on('call_ended', (msg) => {
|
||||||
|
const { currentCall, incomingCall } = get();
|
||||||
|
|
||||||
|
processedCallIds.set(msg.call_id, Date.now());
|
||||||
|
|
||||||
|
if (currentCall?.id === msg.call_id) {
|
||||||
|
console.log('[CallStore] Call ended, duration:', msg.duration);
|
||||||
|
get().endCall('ended');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (incomingCall?.callId === msg.call_id) {
|
||||||
|
console.log('[CallStore] Incoming call cancelled by caller');
|
||||||
|
if (callTimeoutTimer) {
|
||||||
|
clearTimeout(callTimeoutTimer);
|
||||||
|
callTimeoutTimer = null;
|
||||||
|
}
|
||||||
|
set({ incomingCall: null });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
unsubs.push(
|
||||||
|
wsService.on('call_answered_elsewhere', (msg) => {
|
||||||
|
const { incomingCall } = get();
|
||||||
|
if (incomingCall?.callId === msg.call_id) {
|
||||||
|
console.log('[CallStore] Call answered on another device');
|
||||||
|
processedCallIds.set(msg.call_id, Date.now());
|
||||||
|
if (callTimeoutTimer) {
|
||||||
|
clearTimeout(callTimeoutTimer);
|
||||||
|
callTimeoutTimer = null;
|
||||||
|
}
|
||||||
|
set({ incomingCall: null });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
unsubs.push(
|
||||||
|
wsService.on('error', (msg: WSErrorMessage) => {
|
||||||
|
const { currentCall, incomingCall } = get();
|
||||||
|
console.log('[CallStore] Server error:', msg.code, msg.message);
|
||||||
|
|
||||||
|
if (msg.code === 'callee_offline') {
|
||||||
|
if (currentCall && currentCall.status === 'ringing') {
|
||||||
|
console.log('[CallStore] Callee is offline');
|
||||||
|
get().endCall('callee_offline');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.code === 'call_already_answered') {
|
||||||
|
if (incomingCall) {
|
||||||
|
console.log('[CallStore] Call already answered on another device');
|
||||||
|
processedCallIds.set(incomingCall.callId, Date.now());
|
||||||
|
if (callTimeoutTimer) {
|
||||||
|
clearTimeout(callTimeoutTimer);
|
||||||
|
callTimeoutTimer = null;
|
||||||
|
}
|
||||||
|
set({ incomingCall: null });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
unsubs.push(
|
||||||
|
wsService.on('call_sdp', async (msg: WSCallSDPMessage) => {
|
||||||
|
const { currentCall } = get();
|
||||||
|
if (!currentCall || currentCall.id !== msg.call_id) return;
|
||||||
|
|
||||||
|
const myUserId = useAuthStore.getState().currentUser?.id || '';
|
||||||
|
if (msg.from_id === myUserId) return;
|
||||||
|
|
||||||
|
if (msg.payload.sdp_type === 'offer') {
|
||||||
|
await handleIncomingOffer(msg, myUserId);
|
||||||
|
} else if (msg.payload.sdp_type === 'answer') {
|
||||||
|
await handleIncomingAnswer(msg);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
unsubs.push(
|
||||||
|
wsService.on('call_ice', (msg: WSCallICEMessage) => {
|
||||||
|
const { currentCall } = get();
|
||||||
|
if (!currentCall || currentCall.id !== msg.call_id) return;
|
||||||
|
|
||||||
|
const myUserId = useAuthStore.getState().currentUser?.id || '';
|
||||||
|
if (msg.from_id === myUserId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const candidate = typeof msg.payload.candidate === 'string'
|
||||||
|
? JSON.parse(msg.payload.candidate)
|
||||||
|
: msg.payload.candidate;
|
||||||
|
webrtcManager.addIceCandidate(candidate);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CallStore] call_ice error:', err);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
unsubs.push(
|
||||||
|
wsService.on('call_peer_muted', (msg) => {
|
||||||
|
console.log('[CallStore] Peer muted:', msg.user_id, msg.muted);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
cleanupResources();
|
||||||
|
if (unsubInvited) {
|
||||||
|
unsubInvited();
|
||||||
|
unsubInvited = null;
|
||||||
|
}
|
||||||
|
unsubs.forEach((unsub) => unsub());
|
||||||
|
initCallUnsub = null;
|
||||||
|
};
|
||||||
|
initCallUnsub = cleanup;
|
||||||
|
return cleanup;
|
||||||
|
},
|
||||||
|
|
||||||
|
startCall: async (
|
||||||
|
conversationId: string,
|
||||||
|
calleeId: string,
|
||||||
|
calleeInfo?: { nickname?: string; username?: string; avatar?: string | null },
|
||||||
|
callType: CallType = 'voice'
|
||||||
|
) => {
|
||||||
|
const { currentCall } = get();
|
||||||
|
if (currentCall && currentCall.status !== 'idle') {
|
||||||
|
console.warn('[CallStore] Already in a call');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const myUserId = useAuthStore.getState().currentUser?.id;
|
||||||
|
if (!myUserId) {
|
||||||
|
console.error('[CallStore] Not logged in');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedCallee = useUserStore.getState().userCache[calleeId];
|
||||||
|
const callee = calleeInfo || cachedCallee;
|
||||||
|
const calleeName = callee?.nickname || callee?.username || calleeId;
|
||||||
|
|
||||||
|
set({
|
||||||
|
currentCall: {
|
||||||
|
id: '',
|
||||||
|
conversationId,
|
||||||
|
peerId: calleeId,
|
||||||
|
peerName: calleeName,
|
||||||
|
peerAvatar: callee?.avatar,
|
||||||
|
callType,
|
||||||
|
status: 'calling', // 改为 'calling' 表示正在呼出
|
||||||
|
duration: 0,
|
||||||
|
isMuted: false,
|
||||||
|
isSpeakerOn: false,
|
||||||
|
isVideoEnabled: callType === 'video',
|
||||||
|
isPeerVideoEnabled: false,
|
||||||
|
isInitiator: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
wsService.sendCallInvite(conversationId, calleeId, callType);
|
||||||
|
|
||||||
|
if (unsubInvited) {
|
||||||
|
unsubInvited();
|
||||||
|
}
|
||||||
|
unsubInvited = wsService.on('call_invited', (msg) => {
|
||||||
|
set((s) => ({
|
||||||
|
currentCall: s.currentCall
|
||||||
|
? { ...s.currentCall, id: msg.call_id }
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (callTimeoutTimer) clearTimeout(callTimeoutTimer);
|
||||||
|
callTimeoutTimer = setTimeout(() => {
|
||||||
|
const { currentCall: cc } = get();
|
||||||
|
// 只有在 'calling' 状态(呼出中)才超时
|
||||||
|
if (cc && cc.status === 'calling') {
|
||||||
|
console.warn('[CallStore] Call timeout');
|
||||||
|
get().endCall('timeout');
|
||||||
|
}
|
||||||
|
}, CALL_TIMEOUT_MS);
|
||||||
|
},
|
||||||
|
|
||||||
|
acceptCall: async () => {
|
||||||
|
const { incomingCall } = get();
|
||||||
|
if (!incomingCall) return;
|
||||||
|
|
||||||
|
console.log('[CallStore] acceptCall, incomingCall.callType:', incomingCall.callType);
|
||||||
|
|
||||||
|
if (callTimeoutTimer) {
|
||||||
|
clearTimeout(callTimeoutTimer);
|
||||||
|
callTimeoutTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isVideoCall = incomingCall.callType === 'video';
|
||||||
|
console.log('[CallStore] acceptCall, isVideoCall:', isVideoCall);
|
||||||
|
const myUserId = useAuthStore.getState().currentUser?.id || '';
|
||||||
|
|
||||||
|
set({
|
||||||
|
currentCall: {
|
||||||
|
id: incomingCall.callId,
|
||||||
|
conversationId: incomingCall.conversationId,
|
||||||
|
peerId: incomingCall.callerId,
|
||||||
|
peerName: incomingCall.callerName,
|
||||||
|
peerAvatar: incomingCall.callerAvatar,
|
||||||
|
callType: incomingCall.callType as CallType,
|
||||||
|
status: 'connecting',
|
||||||
|
duration: 0,
|
||||||
|
isMuted: false,
|
||||||
|
isSpeakerOn: false,
|
||||||
|
isVideoEnabled: isVideoCall,
|
||||||
|
isPeerVideoEnabled: isVideoCall,
|
||||||
|
isInitiator: false,
|
||||||
|
},
|
||||||
|
incomingCall: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
wsService.sendCallAnswer(incomingCall.callId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await webrtcManager.initialize(incomingCall.iceServers);
|
||||||
|
const newStream = await webrtcManager.createLocalStream(!isVideoCall);
|
||||||
|
|
||||||
|
set({ localStream: newStream });
|
||||||
|
|
||||||
|
// Setup unified WebRTC event handler
|
||||||
|
setupWebRTCEvents(incomingCall.callId, myUserId);
|
||||||
|
|
||||||
|
// Start call (non-initiator, will wait for offer)
|
||||||
|
await webrtcManager.startCall(false, incomingCall.callType as CallType);
|
||||||
|
|
||||||
|
// Note: For non-initiator, we don't create an offer here.
|
||||||
|
// We wait for the initiator's offer via handleIncomingOffer.
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CallStore] Failed to accept call:', err);
|
||||||
|
get().endCall('connection_failed');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
rejectCall: () => {
|
||||||
|
const { incomingCall } = get();
|
||||||
|
if (!incomingCall) return;
|
||||||
|
|
||||||
|
if (callTimeoutTimer) {
|
||||||
|
clearTimeout(callTimeoutTimer);
|
||||||
|
callTimeoutTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
wsService.sendCallReject(incomingCall.callId);
|
||||||
|
set({ incomingCall: null });
|
||||||
|
},
|
||||||
|
|
||||||
|
endCall: async (reason = 'ended') => {
|
||||||
|
const { currentCall } = get();
|
||||||
|
if (!currentCall) return;
|
||||||
|
|
||||||
|
cleanupResources();
|
||||||
|
|
||||||
|
if (unsubInvited) {
|
||||||
|
unsubInvited();
|
||||||
|
unsubInvited = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const callId = currentCall.id;
|
||||||
|
|
||||||
|
set({
|
||||||
|
currentCall: null,
|
||||||
|
callDuration: 0,
|
||||||
|
peerStream: null,
|
||||||
|
localStream: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
webrtcManager.dispose();
|
||||||
|
|
||||||
|
if (callId && reason !== 'ended') {
|
||||||
|
wsService.sendCallEnd(callId, reason);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleMute: () => {
|
||||||
|
const { currentCall } = get();
|
||||||
|
if (!currentCall) return;
|
||||||
|
|
||||||
|
const newMuted = !currentCall.isMuted;
|
||||||
|
webrtcManager.setMuted(newMuted);
|
||||||
|
if (currentCall.id) {
|
||||||
|
wsService.sendCallMute(currentCall.id, newMuted);
|
||||||
|
}
|
||||||
|
set((s) => ({
|
||||||
|
currentCall: s.currentCall ? { ...s.currentCall, isMuted: newMuted } : null,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleSpeaker: () => {
|
||||||
|
const { currentCall } = get();
|
||||||
|
if (!currentCall) return;
|
||||||
|
set((s) => ({
|
||||||
|
currentCall: s.currentCall
|
||||||
|
? { ...s.currentCall, isSpeakerOn: !s.currentCall.isSpeakerOn }
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleMinimize: () => {
|
||||||
|
set((s) => ({ isMinimized: !s.isMinimized }));
|
||||||
|
},
|
||||||
|
|
||||||
|
toggleVideo: async () => {
|
||||||
|
const { currentCall } = get();
|
||||||
|
if (!currentCall) return;
|
||||||
|
|
||||||
|
const newVideoEnabled = !currentCall.isVideoEnabled;
|
||||||
|
await get().setVideoEnabled(newVideoEnabled);
|
||||||
|
},
|
||||||
|
|
||||||
|
setVideoEnabled: async (enabled: boolean) => {
|
||||||
|
const { currentCall } = get();
|
||||||
|
if (!currentCall) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (enabled) {
|
||||||
|
const newStream = await webrtcManager.enableVideo();
|
||||||
|
set({ localStream: newStream });
|
||||||
|
} else {
|
||||||
|
const newStream = await webrtcManager.disableVideo();
|
||||||
|
set({ localStream: newStream });
|
||||||
|
}
|
||||||
|
|
||||||
|
set((s) => ({
|
||||||
|
currentCall: s.currentCall
|
||||||
|
? { ...s.currentCall, isVideoEnabled: enabled, callType: enabled ? 'video' : 'voice' }
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[CallStore] Failed to toggle video:', err);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}));
|
||||||
301
src/stores/groupListSources.ts
Normal file
301
src/stores/groupListSources.ts
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
/**
|
||||||
|
* 群组列表数据源抽象:游标、偏移分页实现同一契约,GroupManager 只依赖接口。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
GroupResponse,
|
||||||
|
GroupMemberResponse,
|
||||||
|
GroupListResponse,
|
||||||
|
GroupMemberListResponse,
|
||||||
|
CursorPaginationRequest,
|
||||||
|
} from '../types/dto';
|
||||||
|
import { groupService } from '../services/groupService';
|
||||||
|
import { getAllGroupsCache, getGroupMembersCache } from '../services/database';
|
||||||
|
|
||||||
|
export const GROUP_LIST_PAGE_SIZE = 20;
|
||||||
|
export const GROUP_MEMBER_LIST_PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
// ==================== 群组列表数据源 ====================
|
||||||
|
|
||||||
|
/** 单次拉取结果(一页或一批) */
|
||||||
|
export interface GroupListPage {
|
||||||
|
items: GroupResponse[];
|
||||||
|
/** 在当前源上是否还能再 loadNext 一页 */
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页式群组列表源:restart 后开始新序列;首次 loadNext 为第一页,之后为后续页。
|
||||||
|
*/
|
||||||
|
export interface IGroupListPagedSource {
|
||||||
|
restart(): void;
|
||||||
|
loadNext(): Promise<GroupListPage>;
|
||||||
|
/** 至少成功 loadNext 过一次且仍有下一页时为 true */
|
||||||
|
readonly hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 远端群组列表(游标分页)
|
||||||
|
*/
|
||||||
|
export class NetworkCursorGroupListPagedSource implements IGroupListPagedSource {
|
||||||
|
private nextCursor: string | null = null;
|
||||||
|
private hasMoreAfterLastLoad = false;
|
||||||
|
private loadedOnce = false;
|
||||||
|
private readonly pageSize: number;
|
||||||
|
|
||||||
|
constructor(pageSize: number = GROUP_LIST_PAGE_SIZE) {
|
||||||
|
this.pageSize = pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
restart(): void {
|
||||||
|
this.nextCursor = null;
|
||||||
|
this.hasMoreAfterLastLoad = false;
|
||||||
|
this.loadedOnce = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasMore(): boolean {
|
||||||
|
return this.loadedOnce && this.hasMoreAfterLastLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadNext(): Promise<GroupListPage> {
|
||||||
|
const params: CursorPaginationRequest = {
|
||||||
|
cursor: this.nextCursor ?? '',
|
||||||
|
page_size: this.pageSize,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await groupService.getGroupsCursor(params);
|
||||||
|
|
||||||
|
this.hasMoreAfterLastLoad = result.has_more ?? false;
|
||||||
|
this.nextCursor = result.next_cursor ?? null;
|
||||||
|
this.loadedOnce = true;
|
||||||
|
|
||||||
|
return { items: result.list ?? [], hasMore: this.hasMoreAfterLastLoad };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 远端群组列表(偏移分页)
|
||||||
|
*/
|
||||||
|
export class NetworkOffsetGroupListPagedSource implements IGroupListPagedSource {
|
||||||
|
private nextPage = 1;
|
||||||
|
private hasMoreAfterLastLoad = false;
|
||||||
|
private loadedOnce = false;
|
||||||
|
private readonly pageSize: number;
|
||||||
|
|
||||||
|
constructor(pageSize: number = GROUP_LIST_PAGE_SIZE) {
|
||||||
|
this.pageSize = pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
restart(): void {
|
||||||
|
this.nextPage = 1;
|
||||||
|
this.hasMoreAfterLastLoad = false;
|
||||||
|
this.loadedOnce = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasMore(): boolean {
|
||||||
|
return this.loadedOnce && this.hasMoreAfterLastLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadNext(): Promise<GroupListPage> {
|
||||||
|
const result = await groupService.getGroups(this.nextPage, this.pageSize);
|
||||||
|
|
||||||
|
const items = result.list ?? [];
|
||||||
|
const totalPages = result.total_pages ?? 1;
|
||||||
|
|
||||||
|
this.hasMoreAfterLastLoad = this.nextPage < totalPages;
|
||||||
|
this.nextPage++;
|
||||||
|
this.loadedOnce = true;
|
||||||
|
|
||||||
|
return { items, hasMore: this.hasMoreAfterLastLoad };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite 群组列表缓存(单批,无后续页)
|
||||||
|
*/
|
||||||
|
export class SqliteGroupListPagedSource implements IGroupListPagedSource {
|
||||||
|
private consumed = false;
|
||||||
|
|
||||||
|
restart(): void {
|
||||||
|
this.consumed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasMore(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadNext(): Promise<GroupListPage> {
|
||||||
|
if (this.consumed) {
|
||||||
|
return { items: [], hasMore: false };
|
||||||
|
}
|
||||||
|
this.consumed = true;
|
||||||
|
try {
|
||||||
|
const items = await getAllGroupsCache();
|
||||||
|
return { items, hasMore: false };
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[SqliteGroupListPagedSource] 读取群组列表缓存失败:', e);
|
||||||
|
return { items: [], hasMore: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 群组成员列表数据源 ====================
|
||||||
|
|
||||||
|
/** 成员列表单次拉取结果 */
|
||||||
|
export interface GroupMemberListPage {
|
||||||
|
items: GroupMemberResponse[];
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页式群组成员列表源
|
||||||
|
*/
|
||||||
|
export interface IGroupMemberListPagedSource {
|
||||||
|
restart(): void;
|
||||||
|
loadNext(): Promise<GroupMemberListPage>;
|
||||||
|
readonly hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 远端群组成员列表(游标分页)
|
||||||
|
*/
|
||||||
|
export class NetworkCursorGroupMemberListPagedSource implements IGroupMemberListPagedSource {
|
||||||
|
private nextCursor: string | null = null;
|
||||||
|
private hasMoreAfterLastLoad = false;
|
||||||
|
private loadedOnce = false;
|
||||||
|
private readonly pageSize: number;
|
||||||
|
private readonly groupId: string;
|
||||||
|
|
||||||
|
constructor(groupId: string, pageSize: number = GROUP_MEMBER_LIST_PAGE_SIZE) {
|
||||||
|
this.groupId = groupId;
|
||||||
|
this.pageSize = pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
restart(): void {
|
||||||
|
this.nextCursor = null;
|
||||||
|
this.hasMoreAfterLastLoad = false;
|
||||||
|
this.loadedOnce = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasMore(): boolean {
|
||||||
|
return this.loadedOnce && this.hasMoreAfterLastLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadNext(): Promise<GroupMemberListPage> {
|
||||||
|
const params: CursorPaginationRequest = {
|
||||||
|
cursor: this.nextCursor ?? '',
|
||||||
|
page_size: this.pageSize,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await groupService.getGroupMembersCursor(this.groupId, params);
|
||||||
|
|
||||||
|
this.hasMoreAfterLastLoad = result.has_more ?? false;
|
||||||
|
this.nextCursor = result.next_cursor ?? null;
|
||||||
|
this.loadedOnce = true;
|
||||||
|
|
||||||
|
return { items: result.list ?? [], hasMore: this.hasMoreAfterLastLoad };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 远端群组成员列表(偏移分页)
|
||||||
|
*/
|
||||||
|
export class NetworkOffsetGroupMemberListPagedSource implements IGroupMemberListPagedSource {
|
||||||
|
private nextPage = 1;
|
||||||
|
private hasMoreAfterLastLoad = false;
|
||||||
|
private loadedOnce = false;
|
||||||
|
private readonly pageSize: number;
|
||||||
|
private readonly groupId: string;
|
||||||
|
|
||||||
|
constructor(groupId: string, pageSize: number = GROUP_MEMBER_LIST_PAGE_SIZE) {
|
||||||
|
this.groupId = groupId;
|
||||||
|
this.pageSize = pageSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
restart(): void {
|
||||||
|
this.nextPage = 1;
|
||||||
|
this.hasMoreAfterLastLoad = false;
|
||||||
|
this.loadedOnce = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasMore(): boolean {
|
||||||
|
return this.loadedOnce && this.hasMoreAfterLastLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadNext(): Promise<GroupMemberListPage> {
|
||||||
|
const result = await groupService.getMembers(
|
||||||
|
this.groupId,
|
||||||
|
this.nextPage,
|
||||||
|
this.pageSize
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = result.list ?? [];
|
||||||
|
const totalPages = result.total_pages ?? 1;
|
||||||
|
|
||||||
|
this.hasMoreAfterLastLoad = this.nextPage < totalPages;
|
||||||
|
this.nextPage++;
|
||||||
|
this.loadedOnce = true;
|
||||||
|
|
||||||
|
return { items, hasMore: this.hasMoreAfterLastLoad };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQLite 群组成员列表缓存(单批,无后续页)
|
||||||
|
*/
|
||||||
|
export class SqliteGroupMemberListPagedSource implements IGroupMemberListPagedSource {
|
||||||
|
private consumed = false;
|
||||||
|
private readonly groupId: string;
|
||||||
|
|
||||||
|
constructor(groupId: string) {
|
||||||
|
this.groupId = groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
restart(): void {
|
||||||
|
this.consumed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasMore(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadNext(): Promise<GroupMemberListPage> {
|
||||||
|
if (this.consumed) {
|
||||||
|
return { items: [], hasMore: false };
|
||||||
|
}
|
||||||
|
this.consumed = true;
|
||||||
|
try {
|
||||||
|
const items = await getGroupMembersCache(this.groupId);
|
||||||
|
return { items, hasMore: false };
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[SqliteGroupMemberListPagedSource] 读取成员列表缓存失败:', e);
|
||||||
|
return { items: [], hasMore: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 工厂函数 ====================
|
||||||
|
|
||||||
|
/** 远端群组列表分页策略 */
|
||||||
|
export type RemoteGroupListSourceKind = 'cursor' | 'offset';
|
||||||
|
|
||||||
|
export function createRemoteGroupListSource(
|
||||||
|
kind: RemoteGroupListSourceKind = 'cursor',
|
||||||
|
pageSize: number = GROUP_LIST_PAGE_SIZE
|
||||||
|
): IGroupListPagedSource {
|
||||||
|
if (kind === 'offset') {
|
||||||
|
return new NetworkOffsetGroupListPagedSource(pageSize);
|
||||||
|
}
|
||||||
|
return new NetworkCursorGroupListPagedSource(pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createRemoteGroupMemberListSource(
|
||||||
|
groupId: string,
|
||||||
|
kind: RemoteGroupListSourceKind = 'cursor',
|
||||||
|
pageSize: number = GROUP_MEMBER_LIST_PAGE_SIZE
|
||||||
|
): IGroupMemberListPagedSource {
|
||||||
|
if (kind === 'offset') {
|
||||||
|
return new NetworkOffsetGroupMemberListPagedSource(groupId, pageSize);
|
||||||
|
}
|
||||||
|
return new NetworkCursorGroupMemberListPagedSource(groupId, pageSize);
|
||||||
|
}
|
||||||
@@ -1,3 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* GroupManager - 群组管理核心模块
|
||||||
|
*
|
||||||
|
* 架构特点:
|
||||||
|
* - 使用 CachedManager 基类,消除重复的缓存/去重逻辑
|
||||||
|
* - 支持 Sources 模式进行分页加载
|
||||||
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
GroupListResponse,
|
GroupListResponse,
|
||||||
GroupMemberListResponse,
|
GroupMemberListResponse,
|
||||||
@@ -14,7 +22,19 @@ import {
|
|||||||
saveGroupsCache,
|
saveGroupsCache,
|
||||||
saveUsersCache,
|
saveUsersCache,
|
||||||
} from '../services/database';
|
} from '../services/database';
|
||||||
import { CacheBus, CacheEvent } from './cacheBus';
|
import { CacheEvent } from './cacheBus';
|
||||||
|
import { CachedManager, CacheEntry, createCacheEntry, isCacheExpired } from './baseManager';
|
||||||
|
import {
|
||||||
|
IGroupListPagedSource,
|
||||||
|
IGroupMemberListPagedSource,
|
||||||
|
createRemoteGroupListSource,
|
||||||
|
createRemoteGroupMemberListSource,
|
||||||
|
RemoteGroupListSourceKind,
|
||||||
|
GROUP_LIST_PAGE_SIZE,
|
||||||
|
GROUP_MEMBER_LIST_PAGE_SIZE,
|
||||||
|
} from './groupListSources';
|
||||||
|
|
||||||
|
// ==================== 类型定义 ====================
|
||||||
|
|
||||||
interface GroupManagerSnapshot {
|
interface GroupManagerSnapshot {
|
||||||
groupIds: string[];
|
groupIds: string[];
|
||||||
@@ -28,20 +48,18 @@ type GroupManagerEvent =
|
|||||||
| CacheEvent<GroupManagerSnapshot>
|
| CacheEvent<GroupManagerSnapshot>
|
||||||
| CacheEvent<{ error: unknown; context: string }>;
|
| CacheEvent<{ error: unknown; context: string }>;
|
||||||
|
|
||||||
|
// ==================== 常量 ====================
|
||||||
|
|
||||||
const GROUP_TTL = 60 * 1000;
|
const GROUP_TTL = 60 * 1000;
|
||||||
const MEMBERS_TTL = 120 * 1000;
|
const MEMBERS_TTL = 120 * 1000;
|
||||||
|
|
||||||
interface CacheEntry<T> {
|
// ==================== Manager 实现 ====================
|
||||||
data: T;
|
|
||||||
timestamp: number;
|
|
||||||
ttl: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
class GroupManager extends CacheBus<GroupManagerSnapshot, GroupManagerEvent> {
|
class GroupManager extends CachedManager<GroupManagerSnapshot, GroupManagerEvent> {
|
||||||
private groupsListCache: CacheEntry<GroupResponse[]> | null = null;
|
/** 群组详情缓存 */
|
||||||
private groupDetailCache = new Map<string, CacheEntry<GroupResponse | null>>();
|
private groupDetailCache = new Map<string, CacheEntry<GroupResponse | null>>();
|
||||||
|
/** 群组成员缓存 */
|
||||||
private groupMembersCache = new Map<string, CacheEntry<GroupMemberResponse[]>>();
|
private groupMembersCache = new Map<string, CacheEntry<GroupMemberResponse[]>>();
|
||||||
private pendingRequests = new Map<string, Promise<any>>();
|
|
||||||
|
|
||||||
protected getSnapshot(): GroupManagerSnapshot {
|
protected getSnapshot(): GroupManagerSnapshot {
|
||||||
return {
|
return {
|
||||||
@@ -50,67 +68,48 @@ class GroupManager extends CacheBus<GroupManagerSnapshot, GroupManagerEvent> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private isExpired(entry: CacheEntry<any>): boolean {
|
// ==================== 群组列表相关 ====================
|
||||||
return Date.now() - entry.timestamp > entry.ttl;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
/**
|
||||||
const pending = this.pendingRequests.get(key) as Promise<T> | undefined;
|
* 获取群组列表
|
||||||
if (pending) return pending;
|
*/
|
||||||
const request = fetcher().finally(() => {
|
async getGroups(page = 1, pageSize = GROUP_LIST_PAGE_SIZE, forceRefresh = false): Promise<GroupListResponse> {
|
||||||
this.pendingRequests.delete(key);
|
const key = `list:${page}:${pageSize}`;
|
||||||
});
|
|
||||||
this.pendingRequests.set(key, request);
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getGroups(page = 1, pageSize = 20, forceRefresh = false): Promise<GroupListResponse> {
|
// 第一页且有有效缓存
|
||||||
if (page === 1 && !forceRefresh && this.groupsListCache && !this.isExpired(this.groupsListCache)) {
|
if (page === 1 && !forceRefresh && this.hasValidCache(key)) {
|
||||||
return {
|
const groups = this.getFromCache<GroupResponse[]>(key)!;
|
||||||
list: this.groupsListCache.data,
|
return this.buildGroupListResponse(groups, pageSize);
|
||||||
total: this.groupsListCache.data.length,
|
|
||||||
page: 1,
|
|
||||||
page_size: pageSize,
|
|
||||||
total_pages: 1,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page === 1 && !forceRefresh && this.groupsListCache && this.isExpired(this.groupsListCache)) {
|
// 第一页且缓存过期:后台刷新
|
||||||
|
if (page === 1 && !forceRefresh && this.hasExpiredCache(key)) {
|
||||||
this.refreshGroupsInBackground(page, pageSize);
|
this.refreshGroupsInBackground(page, pageSize);
|
||||||
return {
|
const groups = this.getFromCache<GroupResponse[]>(key)!;
|
||||||
list: this.groupsListCache.data,
|
return this.buildGroupListResponse(groups, pageSize);
|
||||||
total: this.groupsListCache.data.length,
|
|
||||||
page: 1,
|
|
||||||
page_size: pageSize,
|
|
||||||
total_pages: 1,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 第一页:尝试本地缓存
|
||||||
if (page === 1 && !forceRefresh) {
|
if (page === 1 && !forceRefresh) {
|
||||||
const localGroups = await getAllGroupsCache();
|
const localGroups = await getAllGroupsCache();
|
||||||
if (localGroups.length > 0) {
|
if (localGroups.length > 0) {
|
||||||
this.groupsListCache = { data: localGroups, timestamp: Date.now(), ttl: GROUP_TTL };
|
this.setCache(key, localGroups, GROUP_TTL);
|
||||||
this.notify({
|
this.notify({
|
||||||
type: 'list_updated',
|
type: 'list_updated',
|
||||||
payload: { groups: localGroups },
|
payload: { groups: localGroups },
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
this.refreshGroupsInBackground(page, pageSize);
|
this.refreshGroupsInBackground(page, pageSize);
|
||||||
return {
|
return this.buildGroupListResponse(localGroups, pageSize);
|
||||||
list: localGroups,
|
|
||||||
total: localGroups.length,
|
|
||||||
page: 1,
|
|
||||||
page_size: pageSize,
|
|
||||||
total_pages: 1,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 发起请求
|
||||||
return this.dedupe(`groups:list:${page}:${pageSize}`, async () => {
|
return this.dedupe(`groups:list:${page}:${pageSize}`, async () => {
|
||||||
const response = await groupService.fetchGroupsFromApi(page, pageSize);
|
const response = await groupService.getGroups(page, pageSize);
|
||||||
const groups = response.list || [];
|
const groups = response.list || [];
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
this.groupsListCache = { data: groups, timestamp: Date.now(), ttl: GROUP_TTL };
|
this.setCache(key, groups, GROUP_TTL);
|
||||||
}
|
}
|
||||||
await saveGroupsCache(groups).catch(() => {});
|
await saveGroupsCache(groups).catch(() => {});
|
||||||
this.notify({
|
this.notify({
|
||||||
@@ -122,12 +121,23 @@ class GroupManager extends CacheBus<GroupManagerSnapshot, GroupManagerEvent> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private refreshGroupsInBackground(page = 1, pageSize = 20): void {
|
private buildGroupListResponse(groups: GroupResponse[], pageSize: number): GroupListResponse {
|
||||||
this.dedupe(`groups:list:bg:${page}:${pageSize}`, async () => {
|
return {
|
||||||
const response = await groupService.fetchGroupsFromApi(page, pageSize);
|
list: groups,
|
||||||
|
total: groups.length,
|
||||||
|
page: 1,
|
||||||
|
page_size: pageSize,
|
||||||
|
total_pages: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshGroupsInBackground(page = 1, pageSize = GROUP_LIST_PAGE_SIZE): void {
|
||||||
|
const key = `list:${page}:${pageSize}`;
|
||||||
|
this.dedupe(`groups:bg:${key}`, async () => {
|
||||||
|
const response = await groupService.getGroups(page, pageSize);
|
||||||
const groups = response.list || [];
|
const groups = response.list || [];
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
this.groupsListCache = { data: groups, timestamp: Date.now(), ttl: GROUP_TTL };
|
this.setCache(key, groups, GROUP_TTL);
|
||||||
saveGroupsCache(groups).catch(() => {});
|
saveGroupsCache(groups).catch(() => {});
|
||||||
}
|
}
|
||||||
this.notify({
|
this.notify({
|
||||||
@@ -145,39 +155,58 @@ class GroupManager extends CacheBus<GroupManagerSnapshot, GroupManagerEvent> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建群组列表数据源(用于 Sources 模式)
|
||||||
|
*/
|
||||||
|
createGroupListSource(
|
||||||
|
kind: RemoteGroupListSourceKind = 'cursor',
|
||||||
|
pageSize: number = GROUP_LIST_PAGE_SIZE
|
||||||
|
): IGroupListPagedSource {
|
||||||
|
return createRemoteGroupListSource(kind, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 群组详情相关 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取群组详情
|
||||||
|
*/
|
||||||
async getGroup(groupId: string, forceRefresh = false): Promise<GroupResponse> {
|
async getGroup(groupId: string, forceRefresh = false): Promise<GroupResponse> {
|
||||||
const id = groupId;
|
const cached = this.groupDetailCache.get(groupId);
|
||||||
const cached = this.groupDetailCache.get(id);
|
|
||||||
if (!forceRefresh && cached && !this.isExpired(cached) && cached.data) {
|
// 有效缓存
|
||||||
|
if (!forceRefresh && cached && !isCacheExpired(cached) && cached.data) {
|
||||||
return cached.data;
|
return cached.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!forceRefresh && cached && this.isExpired(cached) && cached.data) {
|
// 过期缓存:后台刷新
|
||||||
this.refreshGroupInBackground(id);
|
if (!forceRefresh && cached && isCacheExpired(cached) && cached.data) {
|
||||||
|
this.refreshGroupInBackground(groupId);
|
||||||
return cached.data;
|
return cached.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 尝试本地缓存
|
||||||
if (!forceRefresh) {
|
if (!forceRefresh) {
|
||||||
const localGroup = await getGroupCache(id);
|
const localGroup = await getGroupCache(groupId);
|
||||||
if (localGroup) {
|
if (localGroup) {
|
||||||
this.groupDetailCache.set(id, { data: localGroup, timestamp: Date.now(), ttl: GROUP_TTL });
|
this.groupDetailCache.set(groupId, createCacheEntry(localGroup, GROUP_TTL));
|
||||||
this.notify({
|
this.notify({
|
||||||
type: 'detail_updated',
|
type: 'detail_updated',
|
||||||
payload: { groupId: id, group: localGroup },
|
payload: { groupId, group: localGroup },
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
this.refreshGroupInBackground(id);
|
this.refreshGroupInBackground(groupId);
|
||||||
return localGroup;
|
return localGroup;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.dedupe(`groups:detail:${id}`, async () => {
|
// 发起请求
|
||||||
const group = await groupService.fetchGroupFromApi(id);
|
return this.dedupe(`groups:detail:${groupId}`, async () => {
|
||||||
this.groupDetailCache.set(id, { data: group, timestamp: Date.now(), ttl: GROUP_TTL });
|
const group = await groupService.getGroup(groupId);
|
||||||
|
this.groupDetailCache.set(groupId, createCacheEntry(group, GROUP_TTL));
|
||||||
await saveGroupCache(group).catch(() => {});
|
await saveGroupCache(group).catch(() => {});
|
||||||
this.notify({
|
this.notify({
|
||||||
type: 'detail_updated',
|
type: 'detail_updated',
|
||||||
payload: { groupId: id, group },
|
payload: { groupId, group },
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
return group;
|
return group;
|
||||||
@@ -186,8 +215,8 @@ class GroupManager extends CacheBus<GroupManagerSnapshot, GroupManagerEvent> {
|
|||||||
|
|
||||||
private refreshGroupInBackground(groupId: string): void {
|
private refreshGroupInBackground(groupId: string): void {
|
||||||
this.dedupe(`groups:detail:bg:${groupId}`, async () => {
|
this.dedupe(`groups:detail:bg:${groupId}`, async () => {
|
||||||
const group = await groupService.fetchGroupFromApi(groupId);
|
const group = await groupService.getGroup(groupId);
|
||||||
this.groupDetailCache.set(groupId, { data: group, timestamp: Date.now(), ttl: GROUP_TTL });
|
this.groupDetailCache.set(groupId, createCacheEntry(group, GROUP_TTL));
|
||||||
saveGroupCache(group).catch(() => {});
|
saveGroupCache(group).catch(() => {});
|
||||||
this.notify({
|
this.notify({
|
||||||
type: 'detail_updated',
|
type: 'detail_updated',
|
||||||
@@ -204,61 +233,53 @@ class GroupManager extends CacheBus<GroupManagerSnapshot, GroupManagerEvent> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async getMembers(groupId: string, page = 1, pageSize = 50, forceRefresh = false): Promise<GroupMemberListResponse> {
|
// ==================== 群组成员相关 ====================
|
||||||
const id = groupId;
|
|
||||||
const key = `${id}:${page}:${pageSize}`;
|
/**
|
||||||
|
* 获取群组成员列表
|
||||||
|
*/
|
||||||
|
async getMembers(
|
||||||
|
groupId: string,
|
||||||
|
page = 1,
|
||||||
|
pageSize = GROUP_MEMBER_LIST_PAGE_SIZE,
|
||||||
|
forceRefresh = false
|
||||||
|
): Promise<GroupMemberListResponse> {
|
||||||
|
const key = `members:${groupId}:${page}:${pageSize}`;
|
||||||
const cached = this.groupMembersCache.get(key);
|
const cached = this.groupMembersCache.get(key);
|
||||||
if (!forceRefresh && cached && !this.isExpired(cached)) {
|
|
||||||
return {
|
// 有效缓存
|
||||||
list: cached.data,
|
if (!forceRefresh && cached && !isCacheExpired(cached)) {
|
||||||
total: cached.data.length,
|
return this.buildMemberListResponse(cached.data, page, pageSize);
|
||||||
page,
|
|
||||||
page_size: pageSize,
|
|
||||||
total_pages: 1,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!forceRefresh && cached && this.isExpired(cached)) {
|
// 过期缓存:后台刷新
|
||||||
this.refreshMembersInBackground(id, page, pageSize);
|
if (!forceRefresh && cached && isCacheExpired(cached)) {
|
||||||
return {
|
this.refreshMembersInBackground(groupId, page, pageSize);
|
||||||
list: cached.data,
|
return this.buildMemberListResponse(cached.data, page, pageSize);
|
||||||
total: cached.data.length,
|
|
||||||
page,
|
|
||||||
page_size: pageSize,
|
|
||||||
total_pages: 1,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 第一页:尝试本地缓存
|
||||||
if (page === 1 && !forceRefresh) {
|
if (page === 1 && !forceRefresh) {
|
||||||
const localMembers = await getGroupMembersCache(id);
|
const localMembers = await getGroupMembersCache(groupId);
|
||||||
if (localMembers.length > 0) {
|
if (localMembers.length > 0) {
|
||||||
this.groupMembersCache.set(key, {
|
this.groupMembersCache.set(key, createCacheEntry(localMembers, MEMBERS_TTL));
|
||||||
data: localMembers,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
ttl: MEMBERS_TTL,
|
|
||||||
});
|
|
||||||
this.notify({
|
this.notify({
|
||||||
type: 'detail_updated',
|
type: 'detail_updated',
|
||||||
payload: { groupId: id, members: localMembers },
|
payload: { groupId, members: localMembers },
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
this.refreshMembersInBackground(id, page, pageSize);
|
this.refreshMembersInBackground(groupId, page, pageSize);
|
||||||
return {
|
return this.buildMemberListResponse(localMembers, page, pageSize);
|
||||||
list: localMembers,
|
|
||||||
total: localMembers.length,
|
|
||||||
page,
|
|
||||||
page_size: pageSize,
|
|
||||||
total_pages: 1,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 发起请求
|
||||||
return this.dedupe(`groups:members:${key}`, async () => {
|
return this.dedupe(`groups:members:${key}`, async () => {
|
||||||
const response = await groupService.fetchMembersFromApi(id, page, pageSize);
|
const response = await groupService.getMembers(groupId, page, pageSize);
|
||||||
const members = response.list || [];
|
const members = response.list || [];
|
||||||
this.groupMembersCache.set(key, { data: members, timestamp: Date.now(), ttl: MEMBERS_TTL });
|
this.groupMembersCache.set(key, createCacheEntry(members, MEMBERS_TTL));
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
await saveGroupMembersCache(id, members).catch(() => {});
|
await saveGroupMembersCache(groupId, members).catch(() => {});
|
||||||
}
|
}
|
||||||
await saveUsersCache(
|
await saveUsersCache(
|
||||||
members
|
members
|
||||||
@@ -267,19 +288,37 @@ class GroupManager extends CacheBus<GroupManagerSnapshot, GroupManagerEvent> {
|
|||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
this.notify({
|
this.notify({
|
||||||
type: 'detail_updated',
|
type: 'detail_updated',
|
||||||
payload: { groupId: id, members },
|
payload: { groupId, members },
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
return response;
|
return response;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private refreshMembersInBackground(groupId: string, page = 1, pageSize = 50): void {
|
private buildMemberListResponse(
|
||||||
this.dedupe(`groups:members:bg:${groupId}:${page}:${pageSize}`, async () => {
|
members: GroupMemberResponse[],
|
||||||
const response = await groupService.fetchMembersFromApi(groupId, page, pageSize);
|
page: number,
|
||||||
|
pageSize: number
|
||||||
|
): GroupMemberListResponse {
|
||||||
|
return {
|
||||||
|
list: members,
|
||||||
|
total: members.length,
|
||||||
|
page,
|
||||||
|
page_size: pageSize,
|
||||||
|
total_pages: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private refreshMembersInBackground(
|
||||||
|
groupId: string,
|
||||||
|
page = 1,
|
||||||
|
pageSize = GROUP_MEMBER_LIST_PAGE_SIZE
|
||||||
|
): void {
|
||||||
|
const key = `members:${groupId}:${page}:${pageSize}`;
|
||||||
|
this.dedupe(`groups:members:bg:${key}`, async () => {
|
||||||
|
const response = await groupService.getMembers(groupId, page, pageSize);
|
||||||
const members = response.list || [];
|
const members = response.list || [];
|
||||||
const key = `${groupId}:${page}:${pageSize}`;
|
this.groupMembersCache.set(key, createCacheEntry(members, MEMBERS_TTL));
|
||||||
this.groupMembersCache.set(key, { data: members, timestamp: Date.now(), ttl: MEMBERS_TTL });
|
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
saveGroupMembersCache(groupId, members).catch(() => {});
|
saveGroupMembersCache(groupId, members).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -303,21 +342,46 @@ class GroupManager extends CacheBus<GroupManagerSnapshot, GroupManagerEvent> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建群组成员列表数据源(用于 Sources 模式)
|
||||||
|
*/
|
||||||
|
createGroupMemberListSource(
|
||||||
|
groupId: string,
|
||||||
|
kind: RemoteGroupListSourceKind = 'cursor',
|
||||||
|
pageSize: number = GROUP_MEMBER_LIST_PAGE_SIZE
|
||||||
|
): IGroupMemberListPagedSource {
|
||||||
|
return createRemoteGroupMemberListSource(groupId, kind, pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 缓存管理 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使缓存失效
|
||||||
|
*/
|
||||||
invalidate(groupId?: string): void {
|
invalidate(groupId?: string): void {
|
||||||
if (!groupId) {
|
if (!groupId) {
|
||||||
this.groupsListCache = null;
|
this.clearCache();
|
||||||
this.groupDetailCache.clear();
|
this.groupDetailCache.clear();
|
||||||
this.groupMembersCache.clear();
|
this.groupMembersCache.clear();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.groupDetailCache.delete(groupId);
|
this.groupDetailCache.delete(groupId);
|
||||||
[...this.groupMembersCache.keys()].forEach((key) => {
|
[...this.groupMembersCache.keys()].forEach((key) => {
|
||||||
if (key.startsWith(`${groupId}:`)) {
|
if (key.startsWith(`members:${groupId}:`)) {
|
||||||
this.groupMembersCache.delete(key);
|
this.groupMembersCache.delete(key);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有数据
|
||||||
|
*/
|
||||||
|
clear(): void {
|
||||||
|
this.clearCache();
|
||||||
|
this.groupDetailCache.clear();
|
||||||
|
this.groupMembersCache.clear();
|
||||||
|
this.clearPendingRequests();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const groupManager = new GroupManager();
|
export const groupManager = new GroupManager();
|
||||||
|
|
||||||
|
|||||||
13
src/stores/homeTabPressStore.ts
Normal file
13
src/stores/homeTabPressStore.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
|
||||||
|
interface HomeTabPressState {
|
||||||
|
/** 用于触发首页回到顶部的事件计数器 */
|
||||||
|
pressCount: number;
|
||||||
|
/** 触发首页 Tab 点击事件 */
|
||||||
|
triggerPress: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useHomeTabPressStore = create<HomeTabPressState>((set) => ({
|
||||||
|
pressCount: 0,
|
||||||
|
triggerPress: () => set((state) => ({ pressCount: state.pressCount + 1 })),
|
||||||
|
}));
|
||||||
@@ -44,6 +44,11 @@ export { userManager } from './userManager';
|
|||||||
export {
|
export {
|
||||||
useHomeTabBarVisibilityStore,
|
useHomeTabBarVisibilityStore,
|
||||||
} from './homeTabBarVisibilityStore';
|
} from './homeTabBarVisibilityStore';
|
||||||
|
export {
|
||||||
|
useHomeTabPressStore,
|
||||||
|
} from './homeTabPressStore';
|
||||||
|
export { callStore } from './callStore';
|
||||||
|
export type { CallType, CallSession, CallStatus } from './callStore';
|
||||||
export {
|
export {
|
||||||
useAppColors,
|
useAppColors,
|
||||||
useThemePreference,
|
useThemePreference,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { messageStateManager, MessageStateManager } from './MessageStateManager';
|
import { messageStateManager, MessageStateManager } from './MessageStateManager';
|
||||||
import { sseMessageHandler, SSEMessageHandler } from './SSEMessageHandler';
|
import { wsMessageHandler, WSMessageHandler } from './WSMessageHandler';
|
||||||
import { messageSyncService, MessageSyncService } from './MessageSyncService';
|
import { messageSyncService, MessageSyncService } from './MessageSyncService';
|
||||||
import { readReceiptManager, ReadReceiptManager } from './ReadReceiptManager';
|
import { readReceiptManager, ReadReceiptManager } from './ReadReceiptManager';
|
||||||
import { messageRepository } from '../../data/repositories/MessageRepository';
|
import { messageRepository } from '../../data/repositories/MessageRepository';
|
||||||
@@ -20,23 +20,23 @@ export type {
|
|||||||
|
|
||||||
export class MessageManager {
|
export class MessageManager {
|
||||||
private stateManager: MessageStateManager;
|
private stateManager: MessageStateManager;
|
||||||
private sseHandler: SSEMessageHandler;
|
private wsHandler: WSMessageHandler;
|
||||||
private syncService: MessageSyncService;
|
private syncService: MessageSyncService;
|
||||||
private readManager: ReadReceiptManager;
|
private readManager: ReadReceiptManager;
|
||||||
|
|
||||||
private initialized: boolean = false;
|
private initialized: boolean = false;
|
||||||
private authUnsubscribe: (() => void) | null = null;
|
private authUnsubscribe: (() => void) | null = null;
|
||||||
private sseUnsubscribe: (() => void) | null = null;
|
private wsUnsubscribe: (() => void) | null = null;
|
||||||
private currentUserId: string | null = null;
|
private currentUserId: string | null = null;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.stateManager = messageStateManager;
|
this.stateManager = messageStateManager;
|
||||||
this.sseHandler = sseMessageHandler;
|
this.wsHandler = wsMessageHandler;
|
||||||
this.syncService = messageSyncService;
|
this.syncService = messageSyncService;
|
||||||
this.readManager = readReceiptManager;
|
this.readManager = readReceiptManager;
|
||||||
|
|
||||||
this.readManager.setStateManager(this.stateManager);
|
this.readManager.setStateManager(this.stateManager);
|
||||||
this.setupSSEHandlers();
|
this.setupWSHandlers();
|
||||||
this.initAuthListener();
|
this.initAuthListener();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,8 +58,8 @@ export class MessageManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private setupSSEHandlers(): void {
|
private setupWSHandlers(): void {
|
||||||
this.sseUnsubscribe = this.sseHandler.subscribe((event) => {
|
this.wsUnsubscribe = this.wsHandler.subscribe((event) => {
|
||||||
switch (event.type) {
|
switch (event.type) {
|
||||||
case 'chat_message':
|
case 'chat_message':
|
||||||
case 'group_message':
|
case 'group_message':
|
||||||
@@ -89,7 +89,7 @@ export class MessageManager {
|
|||||||
try {
|
try {
|
||||||
console.log('[MessageManager] Initializing...');
|
console.log('[MessageManager] Initializing...');
|
||||||
|
|
||||||
this.sseHandler.connect();
|
this.wsHandler.connect();
|
||||||
|
|
||||||
const conversations = await this.syncService.syncConversations();
|
const conversations = await this.syncService.syncConversations();
|
||||||
this.stateManager.setConversations(conversations);
|
this.stateManager.setConversations(conversations);
|
||||||
@@ -224,14 +224,14 @@ export class MessageManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isConnected(): boolean {
|
isConnected(): boolean {
|
||||||
return this.sseHandler.isConnected();
|
return this.wsHandler.isConnected();
|
||||||
}
|
}
|
||||||
|
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
this.stateManager.reset();
|
this.stateManager.reset();
|
||||||
this.readManager.reset();
|
this.readManager.reset();
|
||||||
this.sseHandler.disconnect();
|
this.wsHandler.disconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
@@ -239,9 +239,9 @@ export class MessageManager {
|
|||||||
this.authUnsubscribe();
|
this.authUnsubscribe();
|
||||||
this.authUnsubscribe = null;
|
this.authUnsubscribe = null;
|
||||||
}
|
}
|
||||||
if (this.sseUnsubscribe) {
|
if (this.wsUnsubscribe) {
|
||||||
this.sseUnsubscribe();
|
this.wsUnsubscribe();
|
||||||
this.sseUnsubscribe = null;
|
this.wsUnsubscribe = null;
|
||||||
}
|
}
|
||||||
this.reset();
|
this.reset();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* SSE 消息处理器
|
* WS 消息处理器
|
||||||
* 只负责处理 SSE 消息,不管理状态
|
* 只负责处理 WS 消息,不管理状态
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { sseService, WSChatMessage, WSGroupChatMessage, WSReadMessage, WSGroupReadMessage, WSRecallMessage, WSGroupRecallMessage, WSGroupTypingMessage, WSGroupNoticeMessage, GroupNoticeType, WSMessage } from '../../services/sseService';
|
import { wsService, WSChatMessage, WSGroupChatMessage, WSReadMessage, WSGroupReadMessage, WSRecallMessage, WSGroupRecallMessage, WSGroupTypingMessage, WSGroupNoticeMessage, GroupNoticeType, WSMessage } from '../../services/wsService';
|
||||||
import type { Message, GroupNotice } from '../../core/entities/Message';
|
import type { Message, GroupNotice } from '../../core/entities/Message';
|
||||||
|
|
||||||
export type SSEEventType =
|
export type WSEventType =
|
||||||
| 'chat_message'
|
| 'chat_message'
|
||||||
| 'group_message'
|
| 'group_message'
|
||||||
| 'read_receipt'
|
| 'read_receipt'
|
||||||
@@ -16,58 +16,58 @@ export type SSEEventType =
|
|||||||
| 'typing'
|
| 'typing'
|
||||||
| 'group_notice';
|
| 'group_notice';
|
||||||
|
|
||||||
export interface SSEEvent {
|
export interface WSEvent {
|
||||||
type: SSEEventType;
|
type: WSEventType;
|
||||||
payload: any;
|
payload: any;
|
||||||
raw: any;
|
raw: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SSEEventHandler = (event: SSEEvent) => void;
|
export type WSEventHandler = (event: WSEvent) => void;
|
||||||
|
|
||||||
export class SSEMessageHandler {
|
export class WSMessageHandler {
|
||||||
private unsubscribeFns: Array<() => void> = [];
|
private unsubscribeFns: Array<() => void> = [];
|
||||||
private handlers: Set<SSEEventHandler> = new Set();
|
private handlers: Set<WSEventHandler> = new Set();
|
||||||
|
|
||||||
connect(): void {
|
connect(): void {
|
||||||
if (this.unsubscribeFns.length > 0) return;
|
if (this.unsubscribeFns.length > 0) return;
|
||||||
|
|
||||||
// 监听私聊消息
|
// 监听私聊消息
|
||||||
const unsubChat = sseService.on('chat', (message) => {
|
const unsubChat = wsService.on('chat', (message) => {
|
||||||
this.emit('chat_message', this.parseChatMessage(message), message);
|
this.emit('chat_message', this.parseChatMessage(message), message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊消息
|
// 监听群聊消息
|
||||||
const unsubGroupMessage = sseService.on('group_message', (message) => {
|
const unsubGroupMessage = wsService.on('group_message', (message) => {
|
||||||
this.emit('group_message', this.parseGroupMessage(message), message);
|
this.emit('group_message', this.parseGroupMessage(message), message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听私聊已读回执
|
// 监听私聊已读回执
|
||||||
const unsubRead = sseService.on('read', (message) => {
|
const unsubRead = wsService.on('read', (message) => {
|
||||||
this.emit('read_receipt', message, message);
|
this.emit('read_receipt', message, message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊已读回执
|
// 监听群聊已读回执
|
||||||
const unsubGroupRead = sseService.on('group_read', (message) => {
|
const unsubGroupRead = wsService.on('group_read', (message) => {
|
||||||
this.emit('group_read_receipt', message, message);
|
this.emit('group_read_receipt', message, message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听私聊消息撤回
|
// 监听私聊消息撤回
|
||||||
const unsubRecall = sseService.on('recall', (message) => {
|
const unsubRecall = wsService.on('recall', (message) => {
|
||||||
this.emit('message_recalled', message, message);
|
this.emit('message_recalled', message, message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊消息撤回
|
// 监听群聊消息撤回
|
||||||
const unsubGroupRecall = sseService.on('group_recall', (message) => {
|
const unsubGroupRecall = wsService.on('group_recall', (message) => {
|
||||||
this.emit('group_message_recalled', message, message);
|
this.emit('group_message_recalled', message, message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊输入状态
|
// 监听群聊输入状态
|
||||||
const unsubGroupTyping = sseService.on('group_typing', (message) => {
|
const unsubGroupTyping = wsService.on('group_typing', (message) => {
|
||||||
this.emit('typing', message, message);
|
this.emit('typing', message, message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群通知
|
// 监听群通知
|
||||||
const unsubGroupNotice = sseService.on('group_notice', (message) => {
|
const unsubGroupNotice = wsService.on('group_notice', (message) => {
|
||||||
this.emit('group_notice', this.parseGroupNotice(message), message);
|
this.emit('group_notice', this.parseGroupNotice(message), message);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,18 +89,18 @@ export class SSEMessageHandler {
|
|||||||
this.handlers.clear();
|
this.handlers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribe(handler: SSEEventHandler): () => void {
|
subscribe(handler: WSEventHandler): () => void {
|
||||||
this.handlers.add(handler);
|
this.handlers.add(handler);
|
||||||
return () => this.handlers.delete(handler);
|
return () => this.handlers.delete(handler);
|
||||||
}
|
}
|
||||||
|
|
||||||
private emit(type: SSEEventType, payload: any, raw: any): void {
|
private emit(type: WSEventType, payload: any, raw: any): void {
|
||||||
const event: SSEEvent = { type, payload, raw };
|
const event: WSEvent = { type, payload, raw };
|
||||||
this.handlers.forEach(handler => {
|
this.handlers.forEach(handler => {
|
||||||
try {
|
try {
|
||||||
handler(event);
|
handler(event);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[SSEMessageHandler] Handler error:', error);
|
console.error('[WSMessageHandler] Handler error:', error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -141,8 +141,8 @@ export class SSEMessageHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isConnected(): boolean {
|
isConnected(): boolean {
|
||||||
return sseService.isConnected();
|
return wsService.isConnected();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sseMessageHandler = new SSEMessageHandler();
|
export const wsMessageHandler = new WSMessageHandler();
|
||||||
@@ -12,8 +12,8 @@ export type { MessageState, MessageEvent, MessageSubscriber, MessageEventType }
|
|||||||
// 导出同步服务
|
// 导出同步服务
|
||||||
export { messageSyncService, MessageSyncService } from './MessageSyncService';
|
export { messageSyncService, MessageSyncService } from './MessageSyncService';
|
||||||
|
|
||||||
// 导出SSE处理器
|
// 导出WS处理器
|
||||||
export { sseMessageHandler, SSEMessageHandler } from './SSEMessageHandler';
|
export { wsMessageHandler, WSMessageHandler } from './WSMessageHandler';
|
||||||
|
|
||||||
// 导出已读管理器
|
// 导出已读管理器
|
||||||
export { readReceiptManager, ReadReceiptManager } from './ReadReceiptManager';
|
export { readReceiptManager, ReadReceiptManager } from './ReadReceiptManager';
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
import { ConversationResponse, MessageResponse, MessageSegment, UserDTO } from '../types/dto';
|
import { ConversationResponse, MessageResponse, MessageSegment, UserDTO } from '../types/dto';
|
||||||
import { messageService } from '../services/messageService';
|
import { messageService } from '../services/messageService';
|
||||||
import {
|
import {
|
||||||
sseService,
|
wsService,
|
||||||
WSChatMessage,
|
WSChatMessage,
|
||||||
WSGroupChatMessage,
|
WSGroupChatMessage,
|
||||||
WSReadMessage,
|
WSReadMessage,
|
||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
WSGroupTypingMessage,
|
WSGroupTypingMessage,
|
||||||
WSGroupNoticeMessage,
|
WSGroupNoticeMessage,
|
||||||
GroupNoticeType,
|
GroupNoticeType,
|
||||||
} from '../services/sseService';
|
} from '../services/wsService';
|
||||||
import {
|
import {
|
||||||
saveMessage,
|
saveMessage,
|
||||||
saveMessagesBatch,
|
saveMessagesBatch,
|
||||||
@@ -94,7 +94,7 @@ interface MessageManagerState {
|
|||||||
systemUnreadCount: number;
|
systemUnreadCount: number;
|
||||||
|
|
||||||
// 连接状态
|
// 连接状态
|
||||||
isSSEConnected: boolean;
|
isWSConnected: boolean;
|
||||||
|
|
||||||
// 当前活动会话ID(用户正在查看的会话)
|
// 当前活动会话ID(用户正在查看的会话)
|
||||||
currentConversationId: string | null;
|
currentConversationId: string | null;
|
||||||
@@ -210,7 +210,7 @@ class MessageManager {
|
|||||||
messagesMap: new Map(),
|
messagesMap: new Map(),
|
||||||
totalUnreadCount: 0,
|
totalUnreadCount: 0,
|
||||||
systemUnreadCount: 0,
|
systemUnreadCount: 0,
|
||||||
isSSEConnected: false,
|
isWSConnected: false,
|
||||||
currentConversationId: null,
|
currentConversationId: null,
|
||||||
isLoadingConversations: false,
|
isLoadingConversations: false,
|
||||||
loadingMessagesSet: new Set(),
|
loadingMessagesSet: new Set(),
|
||||||
@@ -582,7 +582,7 @@ class MessageManager {
|
|||||||
|
|
||||||
|
|
||||||
// 监听私聊消息
|
// 监听私聊消息
|
||||||
sseService.on('chat', (message: WSChatMessage) => {
|
wsService.on('chat', (message: WSChatMessage) => {
|
||||||
if (!this.state.isInitialized || this.isBootstrapping) {
|
if (!this.state.isInitialized || this.isBootstrapping) {
|
||||||
this.bufferedChatMessages.push(message);
|
this.bufferedChatMessages.push(message);
|
||||||
return;
|
return;
|
||||||
@@ -591,7 +591,7 @@ class MessageManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊消息
|
// 监听群聊消息
|
||||||
sseService.on('group_message', (message: WSGroupChatMessage) => {
|
wsService.on('group_message', (message: WSGroupChatMessage) => {
|
||||||
if (!this.state.isInitialized || this.isBootstrapping) {
|
if (!this.state.isInitialized || this.isBootstrapping) {
|
||||||
this.bufferedChatMessages.push(message);
|
this.bufferedChatMessages.push(message);
|
||||||
return;
|
return;
|
||||||
@@ -600,7 +600,7 @@ class MessageManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 监听私聊已读回执
|
// 监听私聊已读回执
|
||||||
sseService.on('read', (message: WSReadMessage) => {
|
wsService.on('read', (message: WSReadMessage) => {
|
||||||
if (!this.state.isInitialized || this.isBootstrapping) {
|
if (!this.state.isInitialized || this.isBootstrapping) {
|
||||||
this.bufferedReadReceipts.push(message);
|
this.bufferedReadReceipts.push(message);
|
||||||
return;
|
return;
|
||||||
@@ -609,7 +609,7 @@ class MessageManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊已读回执
|
// 监听群聊已读回执
|
||||||
sseService.on('group_read', (message: WSGroupReadMessage) => {
|
wsService.on('group_read', (message: WSGroupReadMessage) => {
|
||||||
if (!this.state.isInitialized || this.isBootstrapping) {
|
if (!this.state.isInitialized || this.isBootstrapping) {
|
||||||
this.bufferedReadReceipts.push(message);
|
this.bufferedReadReceipts.push(message);
|
||||||
return;
|
return;
|
||||||
@@ -618,28 +618,28 @@ class MessageManager {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 监听私聊消息撤回
|
// 监听私聊消息撤回
|
||||||
sseService.on('recall', (message: WSRecallMessage) => {
|
wsService.on('recall', (message: WSRecallMessage) => {
|
||||||
this.handleRecallMessage(message);
|
this.handleRecallMessage(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊消息撤回
|
// 监听群聊消息撤回
|
||||||
sseService.on('group_recall', (message: WSGroupRecallMessage) => {
|
wsService.on('group_recall', (message: WSGroupRecallMessage) => {
|
||||||
this.handleGroupRecallMessage(message);
|
this.handleGroupRecallMessage(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊输入状态
|
// 监听群聊输入状态
|
||||||
sseService.on('group_typing', (message: WSGroupTypingMessage) => {
|
wsService.on('group_typing', (message: WSGroupTypingMessage) => {
|
||||||
this.handleGroupTyping(message);
|
this.handleGroupTyping(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群通知
|
// 监听群通知
|
||||||
sseService.on('group_notice', (message: WSGroupNoticeMessage) => {
|
wsService.on('group_notice', (message: WSGroupNoticeMessage) => {
|
||||||
this.handleGroupNotice(message);
|
this.handleGroupNotice(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听连接状态
|
// 监听连接状态
|
||||||
sseService.onConnect(() => {
|
wsService.onConnect(() => {
|
||||||
this.state.isSSEConnected = true;
|
this.state.isWSConnected = true;
|
||||||
this.notifySubscribers({
|
this.notifySubscribers({
|
||||||
type: 'connection_changed',
|
type: 'connection_changed',
|
||||||
payload: { connected: true },
|
payload: { connected: true },
|
||||||
@@ -668,8 +668,8 @@ class MessageManager {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
sseService.onDisconnect(() => {
|
wsService.onDisconnect(() => {
|
||||||
this.state.isSSEConnected = false;
|
this.state.isWSConnected = false;
|
||||||
this.notifySubscribers({
|
this.notifySubscribers({
|
||||||
type: 'connection_changed',
|
type: 'connection_changed',
|
||||||
payload: { connected: false },
|
payload: { connected: false },
|
||||||
@@ -2375,7 +2375,7 @@ class MessageManager {
|
|||||||
* 获取SSE连接状态
|
* 获取SSE连接状态
|
||||||
*/
|
*/
|
||||||
isConnected(): boolean {
|
isConnected(): boolean {
|
||||||
return this.state.isSSEConnected;
|
return this.state.isWSConnected;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -2486,7 +2486,7 @@ class MessageManager {
|
|||||||
|
|
||||||
subscriber({
|
subscriber({
|
||||||
type: 'connection_changed',
|
type: 'connection_changed',
|
||||||
payload: { connected: this.state.isSSEConnected },
|
payload: { connected: this.state.isWSConnected },
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
137
src/stores/postListSources.ts
Normal file
137
src/stores/postListSources.ts
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
/**
|
||||||
|
* 帖子列表数据源抽象:游标、偏移分页实现同一契约,PostManager 只依赖接口。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Post } from '../types';
|
||||||
|
import { postService } from '../services/postService';
|
||||||
|
import type { CursorPaginationRequest } from '../types/dto';
|
||||||
|
|
||||||
|
export const POST_LIST_PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
/** 帖子列表类型 */
|
||||||
|
export type PostListTab = 'hot' | 'latest' | 'follow';
|
||||||
|
|
||||||
|
/** 单次拉取结果(一页或一批) */
|
||||||
|
export interface PostListPage {
|
||||||
|
items: Post[];
|
||||||
|
/** 在当前源上是否还能再 loadNext 一页 */
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页式帖子列表源:restart 后开始新序列;首次 loadNext 为第一页,之后为后续页。
|
||||||
|
*/
|
||||||
|
export interface IPostListPagedSource {
|
||||||
|
restart(): void;
|
||||||
|
loadNext(): Promise<PostListPage>;
|
||||||
|
/** 至少成功 loadNext 过一次且仍有下一页时为 true */
|
||||||
|
readonly hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 远端帖子列表(游标分页)
|
||||||
|
*/
|
||||||
|
export class NetworkCursorPostListPagedSource implements IPostListPagedSource {
|
||||||
|
private nextCursor: string | null = null;
|
||||||
|
private hasMoreAfterLastLoad = false;
|
||||||
|
private loadedOnce = false;
|
||||||
|
private readonly pageSize: number;
|
||||||
|
private readonly tab?: PostListTab;
|
||||||
|
private readonly channelId?: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
options: { tab?: PostListTab; channelId?: string; pageSize?: number } = {}
|
||||||
|
) {
|
||||||
|
this.tab = options.tab;
|
||||||
|
this.channelId = options.channelId;
|
||||||
|
this.pageSize = options.pageSize ?? POST_LIST_PAGE_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
restart(): void {
|
||||||
|
this.nextCursor = null;
|
||||||
|
this.hasMoreAfterLastLoad = false;
|
||||||
|
this.loadedOnce = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasMore(): boolean {
|
||||||
|
return this.loadedOnce && this.hasMoreAfterLastLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadNext(): Promise<PostListPage> {
|
||||||
|
const params: CursorPaginationRequest = {
|
||||||
|
cursor: this.nextCursor ?? '',
|
||||||
|
page_size: this.pageSize,
|
||||||
|
post_type: this.tab,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await postService.getPostsCursor(params);
|
||||||
|
|
||||||
|
this.hasMoreAfterLastLoad = result.has_more ?? false;
|
||||||
|
this.nextCursor = result.next_cursor ?? null;
|
||||||
|
this.loadedOnce = true;
|
||||||
|
|
||||||
|
return { items: result.list ?? [], hasMore: this.hasMoreAfterLastLoad };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 远端帖子列表(偏移分页)
|
||||||
|
*/
|
||||||
|
export class NetworkOffsetPostListPagedSource implements IPostListPagedSource {
|
||||||
|
private nextPage = 1;
|
||||||
|
private hasMoreAfterLastLoad = false;
|
||||||
|
private loadedOnce = false;
|
||||||
|
private readonly pageSize: number;
|
||||||
|
private readonly tab?: PostListTab;
|
||||||
|
private readonly channelId?: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
options: { tab?: PostListTab; channelId?: string; pageSize?: number } = {}
|
||||||
|
) {
|
||||||
|
this.tab = options.tab;
|
||||||
|
this.channelId = options.channelId;
|
||||||
|
this.pageSize = options.pageSize ?? POST_LIST_PAGE_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
restart(): void {
|
||||||
|
this.nextPage = 1;
|
||||||
|
this.hasMoreAfterLastLoad = false;
|
||||||
|
this.loadedOnce = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasMore(): boolean {
|
||||||
|
return this.loadedOnce && this.hasMoreAfterLastLoad;
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadNext(): Promise<PostListPage> {
|
||||||
|
const result = await postService.getPosts(
|
||||||
|
this.nextPage,
|
||||||
|
this.pageSize,
|
||||||
|
this.tab,
|
||||||
|
this.channelId
|
||||||
|
);
|
||||||
|
|
||||||
|
const items = result.list ?? [];
|
||||||
|
const total = result.total ?? 0;
|
||||||
|
const totalPages = result.total_pages ?? 1;
|
||||||
|
|
||||||
|
this.hasMoreAfterLastLoad = this.nextPage < totalPages;
|
||||||
|
this.nextPage++;
|
||||||
|
this.loadedOnce = true;
|
||||||
|
|
||||||
|
return { items, hasMore: this.hasMoreAfterLastLoad };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 远端帖子列表分页策略 */
|
||||||
|
export type RemotePostListSourceKind = 'cursor' | 'offset';
|
||||||
|
|
||||||
|
export function createRemotePostListSource(
|
||||||
|
kind: RemotePostListSourceKind = 'cursor',
|
||||||
|
options: { tab?: PostListTab; channelId?: string; pageSize?: number } = {}
|
||||||
|
): IPostListPagedSource {
|
||||||
|
if (kind === 'offset') {
|
||||||
|
return new NetworkOffsetPostListPagedSource(options);
|
||||||
|
}
|
||||||
|
return new NetworkCursorPostListPagedSource(options);
|
||||||
|
}
|
||||||
@@ -1,6 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* PostManager - 帖子管理核心模块
|
||||||
|
*
|
||||||
|
* 架构特点:
|
||||||
|
* - 使用 CachedManager 基类,消除重复的缓存/去重逻辑
|
||||||
|
* - 支持 Sources 模式进行分页加载
|
||||||
|
*/
|
||||||
|
|
||||||
import { Post } from '../types';
|
import { Post } from '../types';
|
||||||
import { postService } from '../services/postService';
|
import { postService } from '../services/postService';
|
||||||
import { CacheBus, CacheEvent } from './cacheBus';
|
import { CacheEvent } from './cacheBus';
|
||||||
|
import { CachedManager, CacheEntry, createCacheEntry, isCacheExpired } from './baseManager';
|
||||||
|
import {
|
||||||
|
IPostListPagedSource,
|
||||||
|
PostListTab,
|
||||||
|
createRemotePostListSource,
|
||||||
|
RemotePostListSourceKind,
|
||||||
|
POST_LIST_PAGE_SIZE,
|
||||||
|
} from './postListSources';
|
||||||
|
|
||||||
|
// ==================== 类型定义 ====================
|
||||||
|
|
||||||
interface PostListPayload {
|
interface PostListPayload {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -23,66 +41,57 @@ type PostManagerEvent =
|
|||||||
| CacheEvent<PostManagerSnapshot>
|
| CacheEvent<PostManagerSnapshot>
|
||||||
| CacheEvent<{ error: unknown; context: string }>;
|
| CacheEvent<{ error: unknown; context: string }>;
|
||||||
|
|
||||||
|
// ==================== 常量 ====================
|
||||||
|
|
||||||
const LIST_TTL = 30 * 1000;
|
const LIST_TTL = 30 * 1000;
|
||||||
const DETAIL_TTL = 60 * 1000;
|
const DETAIL_TTL = 60 * 1000;
|
||||||
|
|
||||||
interface CacheEntry<T> {
|
// ==================== Manager 实现 ====================
|
||||||
data: T;
|
|
||||||
timestamp: number;
|
|
||||||
ttl: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
class PostManager extends CacheBus<PostManagerSnapshot, PostManagerEvent> {
|
class PostManager extends CachedManager<PostManagerSnapshot, PostManagerEvent> {
|
||||||
private listCache = new Map<string, CacheEntry<Post[]>>();
|
/** 详情缓存 */
|
||||||
private detailCache = new Map<string, CacheEntry<Post | null>>();
|
private detailCache = new Map<string, CacheEntry<Post | null>>();
|
||||||
private pendingRequests = new Map<string, Promise<any>>();
|
|
||||||
|
|
||||||
protected getSnapshot(): PostManagerSnapshot {
|
protected getSnapshot(): PostManagerSnapshot {
|
||||||
return {
|
return {
|
||||||
listKeys: [...this.listCache.keys()],
|
listKeys: [...this.memoryCache.keys()],
|
||||||
detailKeys: [...this.detailCache.keys()],
|
detailKeys: [...this.detailCache.keys()],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private isExpired(entry: CacheEntry<any>): boolean {
|
// ==================== 列表相关 ====================
|
||||||
return Date.now() - entry.timestamp > entry.ttl;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async dedupe<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
|
||||||
const pending = this.pendingRequests.get(key) as Promise<T> | undefined;
|
|
||||||
if (pending) return pending;
|
|
||||||
const request = fetcher().finally(() => {
|
|
||||||
this.pendingRequests.delete(key);
|
|
||||||
});
|
|
||||||
this.pendingRequests.set(key, request);
|
|
||||||
return request;
|
|
||||||
}
|
|
||||||
|
|
||||||
private listKey(type: string, page: number, pageSize: number): string {
|
private listKey(type: string, page: number, pageSize: number): string {
|
||||||
return `${type}:${page}:${pageSize}`;
|
return `list:${type}:${page}:${pageSize}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取帖子列表(传统分页模式)
|
||||||
|
*/
|
||||||
async getPosts(
|
async getPosts(
|
||||||
type = 'hot',
|
type: PostListTab = 'hot',
|
||||||
page = 1,
|
page = 1,
|
||||||
pageSize = 20,
|
pageSize = POST_LIST_PAGE_SIZE,
|
||||||
forceRefresh = false
|
forceRefresh = false
|
||||||
): Promise<Post[]> {
|
): Promise<Post[]> {
|
||||||
const key = this.listKey(type, page, pageSize);
|
const key = this.listKey(type, page, pageSize);
|
||||||
const cached = this.listCache.get(key);
|
|
||||||
if (!forceRefresh && cached && !this.isExpired(cached)) {
|
// 检查有效缓存
|
||||||
return cached.data;
|
if (!forceRefresh && this.hasValidCache(key)) {
|
||||||
|
return this.getFromCache<Post[]>(key)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!forceRefresh && cached && this.isExpired(cached)) {
|
// 过期缓存:后台刷新,立即返回旧数据
|
||||||
|
if (!forceRefresh && this.hasExpiredCache(key)) {
|
||||||
this.refreshPostsInBackground(key, type, page, pageSize);
|
this.refreshPostsInBackground(key, type, page, pageSize);
|
||||||
return cached.data;
|
return this.getFromCache<Post[]>(key)!;
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.dedupe(`posts:list:${key}`, async () => {
|
// 无缓存:发起请求
|
||||||
|
return this.dedupe(`posts:${key}`, async () => {
|
||||||
const response = await postService.getPosts(page, pageSize, type);
|
const response = await postService.getPosts(page, pageSize, type);
|
||||||
const posts = response.list || [];
|
const posts = response.list || [];
|
||||||
this.listCache.set(key, { data: posts, timestamp: Date.now(), ttl: LIST_TTL });
|
this.setCache(key, posts, LIST_TTL);
|
||||||
this.notify({
|
this.notify({
|
||||||
type: 'list_updated',
|
type: 'list_updated',
|
||||||
payload: { key, posts },
|
payload: { key, posts },
|
||||||
@@ -92,11 +101,16 @@ class PostManager extends CacheBus<PostManagerSnapshot, PostManagerEvent> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private refreshPostsInBackground(key: string, type: string, page: number, pageSize: number): void {
|
private refreshPostsInBackground(
|
||||||
this.dedupe(`posts:list:bg:${key}`, async () => {
|
key: string,
|
||||||
|
type: PostListTab,
|
||||||
|
page: number,
|
||||||
|
pageSize: number
|
||||||
|
): void {
|
||||||
|
this.dedupe(`posts:bg:${key}`, async () => {
|
||||||
const response = await postService.getPosts(page, pageSize, type);
|
const response = await postService.getPosts(page, pageSize, type);
|
||||||
const posts = response.list || [];
|
const posts = response.list || [];
|
||||||
this.listCache.set(key, { data: posts, timestamp: Date.now(), ttl: LIST_TTL });
|
this.setCache(key, posts, LIST_TTL);
|
||||||
this.notify({
|
this.notify({
|
||||||
type: 'list_updated',
|
type: 'list_updated',
|
||||||
payload: { key, posts },
|
payload: { key, posts },
|
||||||
@@ -112,20 +126,39 @@ class PostManager extends CacheBus<PostManagerSnapshot, PostManagerEvent> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建帖子列表数据源(用于 Sources 模式)
|
||||||
|
*/
|
||||||
|
createPostListSource(
|
||||||
|
options: { tab?: PostListTab; channelId?: string; pageSize?: number } = {},
|
||||||
|
kind: RemotePostListSourceKind = 'cursor'
|
||||||
|
): IPostListPagedSource {
|
||||||
|
return createRemotePostListSource(kind, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 详情相关 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取帖子详情
|
||||||
|
*/
|
||||||
async getPostDetail(postId: string, forceRefresh = false): Promise<Post | null> {
|
async getPostDetail(postId: string, forceRefresh = false): Promise<Post | null> {
|
||||||
const cached = this.detailCache.get(postId);
|
const cached = this.detailCache.get(postId);
|
||||||
if (!forceRefresh && cached && !this.isExpired(cached)) {
|
|
||||||
|
// 有效缓存
|
||||||
|
if (!forceRefresh && cached && !isCacheExpired(cached)) {
|
||||||
return cached.data;
|
return cached.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!forceRefresh && cached && this.isExpired(cached)) {
|
// 过期缓存:后台刷新
|
||||||
|
if (!forceRefresh && cached && isCacheExpired(cached) && cached.data) {
|
||||||
this.refreshPostDetailInBackground(postId);
|
this.refreshPostDetailInBackground(postId);
|
||||||
return cached.data;
|
return cached.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 无缓存:发起请求
|
||||||
return this.dedupe(`posts:detail:${postId}`, async () => {
|
return this.dedupe(`posts:detail:${postId}`, async () => {
|
||||||
const post = await postService.getPost(postId);
|
const post = await postService.getPost(postId);
|
||||||
this.detailCache.set(postId, { data: post, timestamp: Date.now(), ttl: DETAIL_TTL });
|
this.detailCache.set(postId, createCacheEntry(post, DETAIL_TTL));
|
||||||
this.notify({
|
this.notify({
|
||||||
type: 'detail_updated',
|
type: 'detail_updated',
|
||||||
payload: { postId, post },
|
payload: { postId, post },
|
||||||
@@ -138,7 +171,7 @@ class PostManager extends CacheBus<PostManagerSnapshot, PostManagerEvent> {
|
|||||||
private refreshPostDetailInBackground(postId: string): void {
|
private refreshPostDetailInBackground(postId: string): void {
|
||||||
this.dedupe(`posts:detail:bg:${postId}`, async () => {
|
this.dedupe(`posts:detail:bg:${postId}`, async () => {
|
||||||
const post = await postService.getPost(postId);
|
const post = await postService.getPost(postId);
|
||||||
this.detailCache.set(postId, { data: post, timestamp: Date.now(), ttl: DETAIL_TTL });
|
this.detailCache.set(postId, createCacheEntry(post, DETAIL_TTL));
|
||||||
this.notify({
|
this.notify({
|
||||||
type: 'detail_updated',
|
type: 'detail_updated',
|
||||||
payload: { postId, post },
|
payload: { postId, post },
|
||||||
@@ -154,21 +187,28 @@ class PostManager extends CacheBus<PostManagerSnapshot, PostManagerEvent> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 缓存管理 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使缓存失效
|
||||||
|
*/
|
||||||
invalidate(postId?: string): void {
|
invalidate(postId?: string): void {
|
||||||
if (postId) {
|
if (postId) {
|
||||||
this.detailCache.delete(postId);
|
this.detailCache.delete(postId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.listCache.clear();
|
this.clearCache();
|
||||||
this.detailCache.clear();
|
this.detailCache.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除所有数据
|
||||||
|
*/
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.listCache.clear();
|
this.clearCache();
|
||||||
this.detailCache.clear();
|
this.detailCache.clear();
|
||||||
this.pendingRequests.clear();
|
this.clearPendingRequests();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const postManager = new PostManager();
|
export const postManager = new PostManager();
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { useColorScheme } from 'react-native';
|
import { Appearance, AppState, AppStateStatus } from 'react-native';
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { lightColors, darkColors, type AppColors } from '../theme/palettes';
|
import { lightColors, darkColors, type AppColors } from '../theme/palettes';
|
||||||
import { buildPaperTheme } from '../theme/paperTheme';
|
import { buildPaperTheme } from '../theme/paperTheme';
|
||||||
import type { MD3Theme } from 'react-native-paper';
|
import type { MD3Theme } from 'react-native-paper';
|
||||||
@@ -46,13 +46,19 @@ type ThemeState = {
|
|||||||
hydrate: () => Promise<void>;
|
hydrate: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialSystem: ResolvedScheme = 'light';
|
/** 获取系统主题 */
|
||||||
|
function getSystemScheme(): ResolvedScheme {
|
||||||
|
const scheme = Appearance.getColorScheme();
|
||||||
|
return scheme === 'dark' ? 'dark' : 'light';
|
||||||
|
}
|
||||||
|
|
||||||
|
const initialSystemScheme = getSystemScheme();
|
||||||
|
|
||||||
export const useThemeStore = create<ThemeState>((set, get) => ({
|
export const useThemeStore = create<ThemeState>((set, get) => ({
|
||||||
preference: 'system',
|
preference: 'system',
|
||||||
systemScheme: initialSystem,
|
systemScheme: initialSystemScheme,
|
||||||
hydrated: false,
|
hydrated: false,
|
||||||
...buildState('system', initialSystem),
|
...buildState('system', initialSystemScheme),
|
||||||
|
|
||||||
setSystemScheme: (systemScheme) => {
|
setSystemScheme: (systemScheme) => {
|
||||||
const { preference, systemScheme: cur } = get();
|
const { preference, systemScheme: cur } = get();
|
||||||
@@ -86,10 +92,11 @@ export const useThemeStore = create<ThemeState>((set, get) => ({
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
const { systemScheme } = get();
|
const systemScheme = getSystemScheme();
|
||||||
set({
|
set({
|
||||||
preference,
|
preference,
|
||||||
hydrated: true,
|
hydrated: true,
|
||||||
|
systemScheme,
|
||||||
...buildState(preference, systemScheme),
|
...buildState(preference, systemScheme),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -97,17 +104,42 @@ export const useThemeStore = create<ThemeState>((set, get) => ({
|
|||||||
|
|
||||||
/** 在根布局中同步系统配色并恢复持久化偏好 */
|
/** 在根布局中同步系统配色并恢复持久化偏好 */
|
||||||
export function ThemeBootstrap() {
|
export function ThemeBootstrap() {
|
||||||
const system = useColorScheme();
|
|
||||||
const setSystemScheme = useThemeStore((s) => s.setSystemScheme);
|
const setSystemScheme = useThemeStore((s) => s.setSystemScheme);
|
||||||
const hydrate = useThemeStore((s) => s.hydrate);
|
const hydrate = useThemeStore((s) => s.hydrate);
|
||||||
|
const appState = useRef<AppStateStatus>(AppState.currentState);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void hydrate();
|
void hydrate();
|
||||||
}, [hydrate]);
|
}, [hydrate]);
|
||||||
|
|
||||||
|
// 监听系统主题变化
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSystemScheme(system === 'dark' ? 'dark' : 'light');
|
const subscription = Appearance.addChangeListener(({ colorScheme }) => {
|
||||||
}, [system, setSystemScheme]);
|
const newScheme = colorScheme === 'dark' ? 'dark' : 'light';
|
||||||
|
setSystemScheme(newScheme);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, [setSystemScheme]);
|
||||||
|
|
||||||
|
// 监听应用状态变化,从后台恢复时重新检测系统主题
|
||||||
|
useEffect(() => {
|
||||||
|
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||||
|
if (
|
||||||
|
appState.current.match(/inactive|background/) &&
|
||||||
|
nextAppState === 'active'
|
||||||
|
) {
|
||||||
|
setSystemScheme(getSystemScheme());
|
||||||
|
}
|
||||||
|
appState.current = nextAppState;
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
subscription.remove();
|
||||||
|
};
|
||||||
|
}, [setSystemScheme]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -131,3 +163,6 @@ export function useSetThemePreference() {
|
|||||||
export function usePaperThemeFromStore() {
|
export function usePaperThemeFromStore() {
|
||||||
return useThemeStore((s) => s.paperTheme);
|
return useThemeStore((s) => s.paperTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 导出调试函数
|
||||||
|
export { getSystemScheme };
|
||||||
@@ -76,8 +76,8 @@ export const lightColors = {
|
|||||||
link: '#4A88C7',
|
link: '#4A88C7',
|
||||||
success: '#34C759',
|
success: '#34C759',
|
||||||
danger: '#FF3B30',
|
danger: '#FF3B30',
|
||||||
bubbleOutgoing: '#E8E8E8',
|
bubbleOutgoing: '#E8E8E8', // 柔和的淡灰色
|
||||||
bubbleIncoming: '#FFFFFF',
|
bubbleIncoming: '#FFFFFF', // 对方发的消息 - 纯白
|
||||||
replyTint: '#DFF2FF',
|
replyTint: '#DFF2FF',
|
||||||
replyTintActive: '#CFEAFF',
|
replyTintActive: '#CFEAFF',
|
||||||
replyBorder: '#7FB6E6',
|
replyBorder: '#7FB6E6',
|
||||||
@@ -168,8 +168,8 @@ export const darkColors = {
|
|||||||
link: '#5AC8FA',
|
link: '#5AC8FA',
|
||||||
success: '#32D74B',
|
success: '#32D74B',
|
||||||
danger: '#FF453A',
|
danger: '#FF453A',
|
||||||
bubbleOutgoing: '#3A3A3C',
|
bubbleOutgoing: '#3A3A3C', // 暗色模式:深灰色
|
||||||
bubbleIncoming: '#2C2C2E',
|
bubbleIncoming: '#2C2C2E', // 暗色模式:灰色气泡
|
||||||
replyTint: '#1A3A52',
|
replyTint: '#1A3A52',
|
||||||
replyTintActive: '#224060',
|
replyTintActive: '#224060',
|
||||||
replyBorder: '#4A88C7',
|
replyBorder: '#4A88C7',
|
||||||
|
|||||||
@@ -2,6 +2,39 @@
|
|||||||
* 与明暗无关的设计 token
|
* 与明暗无关的设计 token
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
// 字体配置 - 使用系统字体栈,优化字重
|
||||||
|
export const fontFamily = {
|
||||||
|
// iOS 系统字体
|
||||||
|
ios: {
|
||||||
|
regular: 'System',
|
||||||
|
medium: 'System',
|
||||||
|
semibold: 'System',
|
||||||
|
bold: 'System',
|
||||||
|
},
|
||||||
|
// Android 系统字体
|
||||||
|
android: {
|
||||||
|
regular: 'Roboto',
|
||||||
|
medium: 'Roboto',
|
||||||
|
semibold: 'Roboto',
|
||||||
|
bold: 'Roboto',
|
||||||
|
},
|
||||||
|
// 通用字体栈
|
||||||
|
system: {
|
||||||
|
regular: 'System',
|
||||||
|
medium: 'System',
|
||||||
|
semibold: 'System',
|
||||||
|
bold: 'System',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 字体字重 - 使用数值更精确控制
|
||||||
|
export const fontWeights = {
|
||||||
|
regular: '400' as const,
|
||||||
|
medium: '500' as const,
|
||||||
|
semibold: '600' as const,
|
||||||
|
bold: '700' as const,
|
||||||
|
};
|
||||||
|
|
||||||
export const fontSizes = {
|
export const fontSizes = {
|
||||||
xs: 10,
|
xs: 10,
|
||||||
sm: 12,
|
sm: 12,
|
||||||
|
|||||||
@@ -684,31 +684,37 @@ export interface GroupAnnouncementListResponse {
|
|||||||
total_pages: number;
|
total_pages: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== SSE Event Types ====================
|
// ==================== WS Event Types ====================
|
||||||
|
|
||||||
// SSE 事件类型
|
// WS 事件类型
|
||||||
export type SSEEventType = 'message' | 'notice' | 'request' | 'meta';
|
export type WSEventType = 'message' | 'notice' | 'request' | 'meta';
|
||||||
|
|
||||||
// SSE 消息详细类型
|
// WS 消息详细类型
|
||||||
export type SSEDetailType = 'private' | 'group' | 'follow' | 'like' | 'comment' | 'request' | 'typing' | 'heartbeat' | 'read';
|
export type WSDetailType = 'private' | 'group' | 'follow' | 'like' | 'comment' | 'request' | 'typing' | 'heartbeat' | 'read';
|
||||||
|
|
||||||
// SSE 事件消息段(与后端 message 字段一致)
|
// WS 事件消息段(与后端 message 字段一致)
|
||||||
export interface SSESegment {
|
export interface WSSegment {
|
||||||
type: string;
|
type: string;
|
||||||
data: Record<string, any>;
|
data: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSE 事件(后端推送的事件格式)
|
// WS 事件(后端推送的事件格式)
|
||||||
export interface SSEEvent {
|
export interface WSEvent {
|
||||||
id: string; // 事件唯一ID
|
id: string; // 事件唯一ID
|
||||||
time: number; // 事件时间戳(毫秒)
|
time: number; // 事件时间戳(毫秒)
|
||||||
type: SSEEventType; // 事件类型: message(消息), notice(通知), request(请求), meta(元事件)
|
type: WSEventType; // 事件类型: message(消息), notice(通知), request(请求), meta(元事件)
|
||||||
detail_type: SSEDetailType; // 详细类型: private(私聊), group(群聊), follow(关注), like(点赞)等
|
detail_type: WSDetailType; // 详细类型: private(私聊), group(群聊), follow(关注), like(点赞)等
|
||||||
seq: string; // 消息序号
|
seq: string; // 消息序号
|
||||||
message?: SSESegment[]; // 消息内容数组
|
message?: WSSegment[]; // 消息内容数组
|
||||||
conversation_id: string; // 会话ID
|
conversation_id: string; // 会话ID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 旧的类型名称,保持向后兼容
|
||||||
|
export type SSEEventType = WSEventType;
|
||||||
|
export type SSEDetailType = WSDetailType;
|
||||||
|
export interface SSESegment extends WSSegment {}
|
||||||
|
export interface SSEEvent extends WSEvent {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 从消息segments中提取纯文本内容
|
* 从消息segments中提取纯文本内容
|
||||||
* 用于消息列表显示、通知等场景
|
* 用于消息列表显示、通知等场景
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// 导出DTO类型作为主要类型
|
// 导出DTO类型作为主要类型
|
||||||
export * from './dto';
|
export * from './dto';
|
||||||
export * from './schedule';
|
export * from './schedule';
|
||||||
|
export * from './material';
|
||||||
|
|
||||||
// 兼容旧类型(用于内部组件)
|
// 兼容旧类型(用于内部组件)
|
||||||
import type {
|
import type {
|
||||||
|
|||||||
103
src/types/material.ts
Normal file
103
src/types/material.ts
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
/**
|
||||||
|
* 学习资料类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 文件类型枚举
|
||||||
|
export type MaterialFileType = 'pdf' | 'word' | 'ppt';
|
||||||
|
|
||||||
|
// 学科分类
|
||||||
|
export interface MaterialSubject {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
icon: string; // MaterialCommunityIcons name
|
||||||
|
color: string; // 主题色
|
||||||
|
description?: string;
|
||||||
|
sort_order: number;
|
||||||
|
is_active: boolean;
|
||||||
|
material_count: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件资料
|
||||||
|
export interface MaterialFile {
|
||||||
|
id: string;
|
||||||
|
subject_id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
file_type: MaterialFileType;
|
||||||
|
file_size: number; // 字节数
|
||||||
|
file_url: string;
|
||||||
|
file_name: string;
|
||||||
|
download_count: number;
|
||||||
|
author_id?: string;
|
||||||
|
author_name?: string;
|
||||||
|
tags?: string[];
|
||||||
|
status: 'active' | 'inactive';
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
subject?: MaterialSubject;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 学科资料列表响应
|
||||||
|
export interface SubjectMaterialsResponse {
|
||||||
|
subject: MaterialSubject;
|
||||||
|
materials: MaterialFile[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索结果响应
|
||||||
|
export interface SearchMaterialsResponse {
|
||||||
|
materials: MaterialFile[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
page_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件类型图标映射
|
||||||
|
export const MATERIAL_FILE_TYPE_ICONS: Record<MaterialFileType, string> = {
|
||||||
|
pdf: 'file-pdf-box',
|
||||||
|
word: 'file-word-box',
|
||||||
|
ppt: 'file-powerpoint-box',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 文件类型颜色映射
|
||||||
|
export const MATERIAL_FILE_TYPE_COLORS: Record<MaterialFileType, string> = {
|
||||||
|
pdf: '#E53935',
|
||||||
|
word: '#1E88E5',
|
||||||
|
ppt: '#FF9800',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 文件类型名称映射
|
||||||
|
export const MATERIAL_FILE_TYPE_NAMES: Record<MaterialFileType, string> = {
|
||||||
|
pdf: 'PDF',
|
||||||
|
word: 'Word',
|
||||||
|
ppt: 'PPT',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 文件大小格式化
|
||||||
|
export function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 学科颜色预设
|
||||||
|
export const SUBJECT_COLORS = [
|
||||||
|
'#FF6B6B', // 红色
|
||||||
|
'#4ECDC4', // 青色
|
||||||
|
'#45B7D1', // 蓝色
|
||||||
|
'#96CEB4', // 绿色
|
||||||
|
'#FFEAA7', // 黄色
|
||||||
|
'#DDA0DD', // 紫色
|
||||||
|
'#F39C12', // 橙色
|
||||||
|
'#3498DB', // 深蓝
|
||||||
|
'#27AE60', // 深绿
|
||||||
|
'#9B59B6', // 深紫
|
||||||
|
'#1ABC9C', // 青绿
|
||||||
|
'#E74C3C', // 深红
|
||||||
|
];
|
||||||
Reference in New Issue
Block a user