Compare commits
2 Commits
4b5ce1ba21
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 2014be28c4 | |||
| cf466a3959 |
@@ -272,42 +272,6 @@ 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:
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ RUN npx expo export --platform web --output-dir dist-web
|
|||||||
|
|
||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
COPY --from=builder /app/dist-web /usr/share/nginx/html
|
COPY --from=builder /app/dist-web /usr/share/nginx/html
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -23,29 +23,6 @@ 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,
|
||||||
@@ -63,27 +40,10 @@ 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',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
};
|
||||||
|
|||||||
24
app.json
24
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "萝卜社区",
|
"name": "萝卜社区",
|
||||||
"slug": "qojo",
|
"slug": "qojo",
|
||||||
"version": "1.0.13",
|
"version": "1.0.11",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
@@ -15,13 +15,11 @@
|
|||||||
"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
|
||||||
},
|
},
|
||||||
@@ -36,10 +34,9 @@
|
|||||||
},
|
},
|
||||||
"predictiveBackGestureEnabled": false,
|
"predictiveBackGestureEnabled": false,
|
||||||
"package": "skin.carrot.bbs",
|
"package": "skin.carrot.bbs",
|
||||||
"versionCode": 7,
|
"versionCode": 6,
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"VIBRATE",
|
"VIBRATE",
|
||||||
"RECORD_AUDIO",
|
|
||||||
"RECEIVE_BOOT_COMPLETED",
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
"WAKE_LOCK",
|
"WAKE_LOCK",
|
||||||
"READ_EXTERNAL_STORAGE",
|
"READ_EXTERNAL_STORAGE",
|
||||||
@@ -62,17 +59,6 @@
|
|||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
"./plugins/withMainActivityConfigChange",
|
|
||||||
[
|
|
||||||
"./plugins/withForegroundService",
|
|
||||||
{
|
|
||||||
"notificationTitle": "萝卜社区",
|
|
||||||
"notificationBody": "正在后台同步消息",
|
|
||||||
"notificationIcon": "ic_notification",
|
|
||||||
"channelId": "carrot_sync_foreground",
|
|
||||||
"channelName": "消息同步"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
"expo-router",
|
"expo-router",
|
||||||
{
|
{
|
||||||
@@ -96,9 +82,9 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"expo-background-task",
|
"expo-background-fetch",
|
||||||
{
|
{
|
||||||
"minimumInterval": 15
|
"minimumInterval": 900
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useCallback } from 'react';
|
import { useMemo } 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, useHomeTabPressStore } from '../../../src/stores';
|
import { useHomeTabBarVisibilityStore, useTotalUnreadCount } 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,17 +19,10 @@ 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 };
|
||||||
@@ -88,9 +81,6 @@ export default function TabsLayout() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
listeners={{
|
|
||||||
tabPress: handleHomeTabPress,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="apps"
|
name="apps"
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MaterialDetailScreen } from '../../../../../src/screens/material';
|
|
||||||
|
|
||||||
export default function MaterialDetailRoute() {
|
|
||||||
return <MaterialDetailScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { MaterialsScreen } from '../../../../../src/screens/material';
|
|
||||||
|
|
||||||
export default function MaterialsRoute() {
|
|
||||||
return <MaterialsScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { SubjectMaterialsScreen } from '../../../../../src/screens/material';
|
|
||||||
|
|
||||||
export default function SubjectMaterialsRoute() {
|
|
||||||
return <SubjectMaterialsScreen />;
|
|
||||||
}
|
|
||||||
@@ -36,14 +36,6 @@ export default function ProfileStackLayout() {
|
|||||||
<Stack.Screen name="bookmarks" options={{ title: '收藏' }} />
|
<Stack.Screen name="bookmarks" options={{ title: '收藏' }} />
|
||||||
<Stack.Screen name="notification-settings" options={{ title: '通知设置' }} />
|
<Stack.Screen name="notification-settings" options={{ title: '通知设置' }} />
|
||||||
<Stack.Screen name="blocked-users" options={{ title: '黑名单' }} />
|
<Stack.Screen name="blocked-users" options={{ title: '黑名单' }} />
|
||||||
<Stack.Screen name="chat-settings" options={{ title: '聊天设置' }} />
|
|
||||||
<Stack.Screen name="about" options={{ title: '关于我们' }} />
|
|
||||||
<Stack.Screen name="terms" options={{ title: '用户协议' }} />
|
|
||||||
<Stack.Screen name="privacy" options={{ title: '隐私政策' }} />
|
|
||||||
<Stack.Screen name="verification" options={{ title: '身份认证' }} />
|
|
||||||
<Stack.Screen name="data-storage" options={{ title: '数据与存储' }} />
|
|
||||||
<Stack.Screen name="privacy-settings" options={{ title: '隐私设置' }} />
|
|
||||||
<Stack.Screen name="account-deletion" options={{ title: '注销账号' }} />
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { AboutScreen } from '../../../../src/screens/profile/AboutScreen';
|
|
||||||
|
|
||||||
export default function AboutRoute() {
|
|
||||||
return <AboutScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { AccountDeletionScreen } from '../../../../src/screens/profile';
|
|
||||||
|
|
||||||
export default function AccountDeletionRoute() {
|
|
||||||
return <AccountDeletionScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { ChatSettingsScreen } from '../../../../src/screens/profile/ChatSettingsScreen';
|
|
||||||
|
|
||||||
export default function ChatSettingsRoute() {
|
|
||||||
return <ChatSettingsScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { DataStorageScreen } from '../../../../src/screens/profile';
|
|
||||||
|
|
||||||
export default function DataStorageRoute() {
|
|
||||||
return <DataStorageScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { PrivacySettingsScreen } from '../../../../src/screens/profile';
|
|
||||||
|
|
||||||
export default function PrivacySettingsRoute() {
|
|
||||||
return <PrivacySettingsScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { PrivacyPolicyScreen } from '../../../../src/screens/profile/PrivacyPolicyScreen';
|
|
||||||
|
|
||||||
export default function PrivacyPolicyRoute() {
|
|
||||||
return <PrivacyPolicyScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { TermsOfServiceScreen } from '../../../../src/screens/profile/TermsOfServiceScreen';
|
|
||||||
|
|
||||||
export default function TermsOfServiceRoute() {
|
|
||||||
return <TermsOfServiceScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { VerificationSettingsScreen } from '@/screens/profile/VerificationSettingsScreen';
|
|
||||||
|
|
||||||
export default function VerificationSettingsPage() {
|
|
||||||
return <VerificationSettingsScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { VerificationFormScreen } from '@/screens/auth/VerificationFormScreen';
|
|
||||||
|
|
||||||
export default function VerificationFormPage() {
|
|
||||||
return <VerificationFormScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { VerificationGuideScreen } from '@/screens/auth/VerificationGuideScreen';
|
|
||||||
|
|
||||||
export default function VerificationGuidePage() {
|
|
||||||
return <VerificationGuideScreen />;
|
|
||||||
}
|
|
||||||
@@ -10,8 +10,6 @@ import * as Notifications from 'expo-notifications';
|
|||||||
import * as SystemUI from 'expo-system-ui';
|
import * as SystemUI from 'expo-system-ui';
|
||||||
|
|
||||||
import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences';
|
import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences';
|
||||||
import { api } from '../src/services/api';
|
|
||||||
import { wsService } from '../src/services/wsService';
|
|
||||||
import {
|
import {
|
||||||
ThemeBootstrap,
|
ThemeBootstrap,
|
||||||
useAppColors,
|
useAppColors,
|
||||||
@@ -23,8 +21,6 @@ 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();
|
||||||
|
|
||||||
@@ -52,25 +48,6 @@ if (Platform.OS === 'web' && typeof document !== 'undefined') {
|
|||||||
}
|
}
|
||||||
*:focus { outline: none !important; }
|
*:focus { outline: none !important; }
|
||||||
*:focus-visible { outline: none !important; }
|
*:focus-visible { outline: none !important; }
|
||||||
/* 修复移动端 FlatList/ScrollView 滑动问题 */
|
|
||||||
html, body {
|
|
||||||
overscroll-behavior: none;
|
|
||||||
touch-action: pan-y;
|
|
||||||
}
|
|
||||||
/* React Native Web 生成的滚动容器 */
|
|
||||||
[class*="css-"] {
|
|
||||||
touch-action: pan-y !important;
|
|
||||||
-webkit-overflow-scrolling: touch !important;
|
|
||||||
}
|
|
||||||
/* 确保所有可滚动元素都可以垂直滑动 */
|
|
||||||
div[style*="overflow"] {
|
|
||||||
touch-action: pan-y !important;
|
|
||||||
-webkit-overflow-scrolling: touch !important;
|
|
||||||
}
|
|
||||||
/* 禁用水平滑动,只允许垂直滑动 */
|
|
||||||
* {
|
|
||||||
touch-action: pan-y pinch-zoom;
|
|
||||||
}
|
|
||||||
`;
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
}
|
}
|
||||||
@@ -160,71 +137,21 @@ 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();
|
||||||
const resolved = useResolvedColorScheme();
|
const resolved = useResolvedColorScheme();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
api.setExpoRouter(router);
|
|
||||||
wsService.setRouter(router);
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
||||||
<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 }} />
|
||||||
<Stack.Screen name="(app)" options={{ headerShown: false }} />
|
<Stack.Screen name="(app)" options={{ headerShown: false }} />
|
||||||
<Stack.Screen
|
|
||||||
name="terms"
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: '用户协议',
|
|
||||||
headerStyle: { backgroundColor: colors.background.paper },
|
|
||||||
headerTintColor: colors.text.primary,
|
|
||||||
headerShadowVisible: false,
|
|
||||||
headerBackVisible: false,
|
|
||||||
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
|
||||||
name="privacy"
|
|
||||||
options={{
|
|
||||||
headerShown: true,
|
|
||||||
title: '隐私政策',
|
|
||||||
headerStyle: { backgroundColor: colors.background.paper },
|
|
||||||
headerTintColor: colors.text.primary,
|
|
||||||
headerShadowVisible: false,
|
|
||||||
headerBackVisible: false,
|
|
||||||
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="post/[postId]"
|
name="post/[postId]"
|
||||||
options={{
|
options={{
|
||||||
@@ -270,9 +197,6 @@ export default function RootLayout() {
|
|||||||
<ThemedStack />
|
<ThemedStack />
|
||||||
<AppPromptBar />
|
<AppPromptBar />
|
||||||
<AppDialogHost />
|
<AppDialogHost />
|
||||||
<IncomingCallModal />
|
|
||||||
<CallScreen />
|
|
||||||
<FloatingCallWindow />
|
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ThemedProviders>
|
</ThemedProviders>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ export default function Index() {
|
|||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
return <Redirect href="/home" />;
|
return <Redirect href="/home" />;
|
||||||
}
|
}
|
||||||
return <Redirect href="/welcome" />;
|
return <Redirect href="/login" />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
import { PrivacyPolicyScreen } from '../src/screens/profile/PrivacyPolicyScreen';
|
|
||||||
|
|
||||||
export default function PrivacyRoute() {
|
|
||||||
return <PrivacyPolicyScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { TermsOfServiceScreen } from '../src/screens/profile/TermsOfServiceScreen';
|
|
||||||
|
|
||||||
export default function TermsRoute() {
|
|
||||||
return <TermsOfServiceScreen />;
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { WelcomeScreen } from '../src/screens/auth';
|
|
||||||
|
|
||||||
export default function WelcomeRoute() {
|
|
||||||
return <WelcomeScreen />;
|
|
||||||
}
|
|
||||||
@@ -6,10 +6,6 @@ const config = getDefaultConfig(__dirname);
|
|||||||
// Add wasm asset support
|
// Add wasm asset support
|
||||||
config.resolver.assetExts.push('wasm');
|
config.resolver.assetExts.push('wasm');
|
||||||
|
|
||||||
// Fix for react-native-webrtc event-target-shim import warning
|
|
||||||
// The package doesn't properly export "./index" in its exports field
|
|
||||||
config.resolver.unstable_enablePackageExports = false;
|
|
||||||
|
|
||||||
// NOTE: enhanceMiddleware is marked as deprecated in Metro's types,
|
// NOTE: enhanceMiddleware is marked as deprecated in Metro's types,
|
||||||
// but there's currently no official alternative for custom dev server headers.
|
// but there's currently no official alternative for custom dev server headers.
|
||||||
// For production, configure COEP/COOP headers on your hosting platform.
|
// For production, configure COEP/COOP headers on your hosting platform.
|
||||||
|
|||||||
19
nginx.conf
19
nginx.conf
@@ -1,19 +0,0 @@
|
|||||||
server {
|
|
||||||
listen 80;
|
|
||||||
server_name _;
|
|
||||||
root /usr/share/nginx/html;
|
|
||||||
index index.html;
|
|
||||||
|
|
||||||
location / {
|
|
||||||
try_files $uri $uri/ /index.html;
|
|
||||||
}
|
|
||||||
|
|
||||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
|
||||||
expires 1y;
|
|
||||||
add_header Cache-Control "public, immutable";
|
|
||||||
}
|
|
||||||
|
|
||||||
gzip on;
|
|
||||||
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
|
|
||||||
gzip_min_length 256;
|
|
||||||
}
|
|
||||||
64
package-lock.json
generated
64
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "carrot_bbs",
|
"name": "carrot_bbs",
|
||||||
"version": "1.0.2",
|
"version": "1.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "carrot_bbs",
|
"name": "carrot_bbs",
|
||||||
"version": "1.0.2",
|
"version": "1.0.1",
|
||||||
"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,7 +29,6 @@
|
|||||||
"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",
|
||||||
@@ -54,7 +53,6 @@
|
|||||||
"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",
|
||||||
@@ -5749,15 +5747,6 @@
|
|||||||
"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",
|
||||||
@@ -9800,55 +9789,6 @@
|
|||||||
"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.3",
|
"version": "1.0.1",
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"expo": "~55.0.4",
|
"expo": "~55.0.4",
|
||||||
|
"expo-background-fetch": "~55.0.10",
|
||||||
"expo-background-task": "~55.0.10",
|
"expo-background-task": "~55.0.10",
|
||||||
"expo-camera": "^55.0.10",
|
"expo-camera": "^55.0.10",
|
||||||
"expo-constants": "~55.0.7",
|
"expo-constants": "~55.0.7",
|
||||||
@@ -34,7 +35,6 @@
|
|||||||
"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,7 +59,6 @@
|
|||||||
"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",
|
||||||
|
|||||||
@@ -1,467 +0,0 @@
|
|||||||
const { withAndroidManifest, withDangerousMod } = require('@expo/config-plugins');
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const FOREGROUND_SERVICE_PERMISSION = 'android.permission.FOREGROUND_SERVICE';
|
|
||||||
const FOREGROUND_SERVICE_DATA_SYNC = 'android.permission.FOREGROUND_SERVICE_DATA_SYNC';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expo Config Plugin:为 Android 添加前台服务保活
|
|
||||||
*
|
|
||||||
* 功能:
|
|
||||||
* 1. 添加 FOREGROUND_SERVICE 相关权限
|
|
||||||
* 2. 在 AndroidManifest 中声明 Service
|
|
||||||
* 3. 生成 Kotlin 原生服务代码
|
|
||||||
* 4. 生成 React Native Bridge 模块
|
|
||||||
*
|
|
||||||
* 参考:Element Android 的 GuardAndroidService
|
|
||||||
*/
|
|
||||||
const withForegroundService = (config, options = {}) => {
|
|
||||||
const {
|
|
||||||
notificationTitle = '萝卜社区',
|
|
||||||
notificationBody = '正在后台同步消息',
|
|
||||||
notificationIcon = 'ic_notification',
|
|
||||||
channelId = 'carrot_sync_foreground',
|
|
||||||
channelName = '消息同步',
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
// 1. 添加权限和 Service 声明到 AndroidManifest
|
|
||||||
config = withAndroidManifest(config, (config) => {
|
|
||||||
const manifest = config.modResults;
|
|
||||||
|
|
||||||
// 添加权限声明
|
|
||||||
const permissions = [
|
|
||||||
FOREGROUND_SERVICE_PERMISSION,
|
|
||||||
FOREGROUND_SERVICE_DATA_SYNC,
|
|
||||||
];
|
|
||||||
|
|
||||||
permissions.forEach((permission) => {
|
|
||||||
const existingPermission = manifest.manifest['uses-permission']?.find(
|
|
||||||
(p) => p.$['android:name'] === permission
|
|
||||||
);
|
|
||||||
if (!existingPermission) {
|
|
||||||
if (!manifest.manifest['uses-permission']) {
|
|
||||||
manifest.manifest['uses-permission'] = [];
|
|
||||||
}
|
|
||||||
manifest.manifest['uses-permission'].push({
|
|
||||||
$: { 'android:name': permission },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 添加 Service 声明
|
|
||||||
if (!manifest.manifest.application[0].service) {
|
|
||||||
manifest.manifest.application[0].service = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingService = manifest.manifest.application[0].service.find(
|
|
||||||
(s) => s.$['android:name'] === '.SyncForegroundService'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!existingService) {
|
|
||||||
manifest.manifest.application[0].service.push({
|
|
||||||
$: {
|
|
||||||
'android:name': '.SyncForegroundService',
|
|
||||||
'android:enabled': 'true',
|
|
||||||
'android:exported': 'false',
|
|
||||||
'android:foregroundServiceType': 'dataSync',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. 生成 Kotlin 原生代码
|
|
||||||
config = withDangerousMod(config, [
|
|
||||||
'android',
|
|
||||||
async (config) => {
|
|
||||||
const projectRoot = config.modRequest.projectRoot;
|
|
||||||
const packageName = config.android.package;
|
|
||||||
const kotlinPath = path.join(
|
|
||||||
projectRoot,
|
|
||||||
'android/app/src/main/java',
|
|
||||||
packageName.replace(/\./g, '/')
|
|
||||||
);
|
|
||||||
|
|
||||||
// 确保目录存在
|
|
||||||
if (!fs.existsSync(kotlinPath)) {
|
|
||||||
fs.mkdirSync(kotlinPath, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 生成 SyncForegroundService.kt
|
|
||||||
const serviceCode = generateServiceCode(packageName, {
|
|
||||||
notificationTitle,
|
|
||||||
notificationBody,
|
|
||||||
notificationIcon,
|
|
||||||
channelId,
|
|
||||||
channelName,
|
|
||||||
});
|
|
||||||
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(kotlinPath, 'SyncForegroundService.kt'),
|
|
||||||
serviceCode
|
|
||||||
);
|
|
||||||
|
|
||||||
// 生成 ForegroundServiceModule.kt (React Native Bridge)
|
|
||||||
const moduleCode = generateModuleCode(packageName);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(kotlinPath, 'ForegroundServiceModule.kt'),
|
|
||||||
moduleCode
|
|
||||||
);
|
|
||||||
|
|
||||||
// 生成 ForegroundServicePackage.kt
|
|
||||||
const packageCode = generatePackageCode(packageName);
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(kotlinPath, 'ForegroundServicePackage.kt'),
|
|
||||||
packageCode
|
|
||||||
);
|
|
||||||
|
|
||||||
// 修改 MainApplication.kt 注册模块
|
|
||||||
const mainAppPath = path.join(kotlinPath, 'MainApplication.kt');
|
|
||||||
if (fs.existsSync(mainAppPath)) {
|
|
||||||
let content = fs.readFileSync(mainAppPath, 'utf-8');
|
|
||||||
|
|
||||||
// 添加 import
|
|
||||||
if (!content.includes('ForegroundServicePackage')) {
|
|
||||||
content = content.replace(
|
|
||||||
/package .*\n/,
|
|
||||||
`$&\nimport ${packageName}.ForegroundServicePackage\n`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在 packages 列表中添加
|
|
||||||
if (!content.includes('ForegroundServicePackage()')) {
|
|
||||||
// 查找 PackageList(this).packages.apply 块
|
|
||||||
content = content.replace(
|
|
||||||
/(PackageList\(this\)\.packages\.apply\s*\{)/,
|
|
||||||
`$1\n // 前台服务模块\n add(ForegroundServicePackage())`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
fs.writeFileSync(mainAppPath, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 创建通知图标目录和文件
|
|
||||||
const drawablePath = path.join(projectRoot, 'android/app/src/main/res/drawable');
|
|
||||||
if (!fs.existsSync(drawablePath)) {
|
|
||||||
fs.mkdirSync(drawablePath, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
const iconPath = path.join(drawablePath, 'ic_notification.xml');
|
|
||||||
if (!fs.existsSync(iconPath)) {
|
|
||||||
fs.writeFileSync(iconPath, generateNotificationIcon());
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
return config;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成前台服务 Kotlin 代码
|
|
||||||
*/
|
|
||||||
function generateServiceCode(packageName, options) {
|
|
||||||
const { notificationTitle, notificationBody, notificationIcon, channelId, channelName } = options;
|
|
||||||
|
|
||||||
return `package ${packageName}
|
|
||||||
|
|
||||||
import android.app.Notification
|
|
||||||
import android.app.NotificationChannel
|
|
||||||
import android.app.NotificationManager
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.app.Service
|
|
||||||
import android.content.Context
|
|
||||||
import android.content.Intent
|
|
||||||
import android.content.pm.ServiceInfo
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.IBinder
|
|
||||||
import androidx.core.app.NotificationCompat
|
|
||||||
import androidx.core.content.ContextCompat
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 前台同步服务
|
|
||||||
*
|
|
||||||
* 在通知栏显示常驻通知,防止应用进程被系统杀死
|
|
||||||
* 类似 Element Android 的 GuardAndroidService 和 V2Ray 的保活机制
|
|
||||||
*/
|
|
||||||
class SyncForegroundService : Service() {
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
const val NOTIFICATION_ID = 1001
|
|
||||||
const val CHANNEL_ID = "${channelId}"
|
|
||||||
const val CHANNEL_NAME = "${channelName}"
|
|
||||||
|
|
||||||
const val ACTION_START = "${packageName}.foreground.START"
|
|
||||||
const val ACTION_STOP = "${packageName}.foreground.STOP"
|
|
||||||
const val ACTION_UPDATE = "${packageName}.foreground.UPDATE"
|
|
||||||
|
|
||||||
const val EXTRA_TITLE = "title"
|
|
||||||
const val EXTRA_BODY = "body"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动前台服务
|
|
||||||
*/
|
|
||||||
fun start(context: Context, title: String = "${notificationTitle}", body: String = "${notificationBody}") {
|
|
||||||
val intent = Intent(context, SyncForegroundService::class.java).apply {
|
|
||||||
action = ACTION_START
|
|
||||||
putExtra(EXTRA_TITLE, title)
|
|
||||||
putExtra(EXTRA_BODY, body)
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
ContextCompat.startForegroundService(context, intent)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
android.util.Log.e("SyncForegroundService", "启动前台服务失败", e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止前台服务
|
|
||||||
*/
|
|
||||||
fun stop(context: Context) {
|
|
||||||
val intent = Intent(context, SyncForegroundService::class.java).apply {
|
|
||||||
action = ACTION_STOP
|
|
||||||
}
|
|
||||||
context.startService(intent)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新通知内容
|
|
||||||
*/
|
|
||||||
fun update(context: Context, title: String, body: String) {
|
|
||||||
val intent = Intent(context, SyncForegroundService::class.java).apply {
|
|
||||||
action = ACTION_UPDATE
|
|
||||||
putExtra(EXTRA_TITLE, title)
|
|
||||||
putExtra(EXTRA_BODY, body)
|
|
||||||
}
|
|
||||||
context.startService(intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var notificationTitle: String = "${notificationTitle}"
|
|
||||||
private var notificationBody: String = "${notificationBody}"
|
|
||||||
|
|
||||||
override fun onCreate() {
|
|
||||||
super.onCreate()
|
|
||||||
createNotificationChannel()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
|
||||||
when (intent?.action) {
|
|
||||||
ACTION_START -> {
|
|
||||||
notificationTitle = intent.getStringExtra(EXTRA_TITLE) ?: "${notificationTitle}"
|
|
||||||
notificationBody = intent.getStringExtra(EXTRA_BODY) ?: "${notificationBody}"
|
|
||||||
startForegroundCompat()
|
|
||||||
}
|
|
||||||
ACTION_STOP -> {
|
|
||||||
stopForegroundCompat()
|
|
||||||
stopSelf()
|
|
||||||
}
|
|
||||||
ACTION_UPDATE -> {
|
|
||||||
notificationTitle = intent.getStringExtra(EXTRA_TITLE) ?: notificationTitle
|
|
||||||
notificationBody = intent.getStringExtra(EXTRA_BODY) ?: notificationBody
|
|
||||||
updateNotification()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// START_STICKY: 服务被杀后自动重启
|
|
||||||
return START_STICKY
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onBind(intent: Intent?): IBinder? = null
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动前台服务(兼容 Android Q+)
|
|
||||||
*/
|
|
||||||
private fun startForegroundCompat() {
|
|
||||||
val notification = buildNotification()
|
|
||||||
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
|
||||||
startForeground(
|
|
||||||
NOTIFICATION_ID,
|
|
||||||
notification,
|
|
||||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
startForeground(NOTIFICATION_ID, notification)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止前台服务(兼容 Android N+)
|
|
||||||
*/
|
|
||||||
private fun stopForegroundCompat() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
|
||||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
|
||||||
} else {
|
|
||||||
@Suppress("DEPRECATION")
|
|
||||||
stopForeground(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 创建通知渠道
|
|
||||||
*/
|
|
||||||
private fun createNotificationChannel() {
|
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
||||||
val channel = NotificationChannel(
|
|
||||||
CHANNEL_ID,
|
|
||||||
CHANNEL_NAME,
|
|
||||||
NotificationManager.IMPORTANCE_MIN // 最低重要性,不打扰用户
|
|
||||||
).apply {
|
|
||||||
description = "后台消息同步服务"
|
|
||||||
setSound(null, null)
|
|
||||||
setShowBadge(false)
|
|
||||||
enableLights(false)
|
|
||||||
enableVibration(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
|
||||||
manager.createNotificationChannel(channel)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 构建通知
|
|
||||||
*/
|
|
||||||
private fun buildNotification(): Notification {
|
|
||||||
// 点击通知打开应用
|
|
||||||
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
|
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
|
||||||
this,
|
|
||||||
0,
|
|
||||||
launchIntent,
|
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
||||||
)
|
|
||||||
|
|
||||||
return NotificationCompat.Builder(this, CHANNEL_ID)
|
|
||||||
.setContentTitle(notificationTitle)
|
|
||||||
.setContentText(notificationBody)
|
|
||||||
.setSmallIcon(R.drawable.${notificationIcon})
|
|
||||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
|
||||||
.setPriority(NotificationCompat.PRIORITY_MIN)
|
|
||||||
.setOngoing(true) // 不可滑动清除
|
|
||||||
.setShowWhen(false)
|
|
||||||
.setContentIntent(pendingIntent)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新通知内容
|
|
||||||
*/
|
|
||||||
private fun updateNotification() {
|
|
||||||
val manager = getSystemService(NotificationManager::class.java)
|
|
||||||
manager.notify(NOTIFICATION_ID, buildNotification())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成 React Native Bridge 模块代码
|
|
||||||
*/
|
|
||||||
function generateModuleCode(packageName) {
|
|
||||||
return `package ${packageName}
|
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import com.facebook.react.bridge.ReactApplicationContext
|
|
||||||
import com.facebook.react.bridge.ReactContextBaseJavaModule
|
|
||||||
import com.facebook.react.bridge.ReactMethod
|
|
||||||
import com.facebook.react.bridge.Promise
|
|
||||||
|
|
||||||
/**
|
|
||||||
* React Native Bridge: 前台服务控制模块
|
|
||||||
*/
|
|
||||||
class ForegroundServiceModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
|
|
||||||
|
|
||||||
override fun getName(): String = "ForegroundService"
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 启动前台服务
|
|
||||||
*/
|
|
||||||
@ReactMethod
|
|
||||||
fun start(title: String, body: String, promise: Promise) {
|
|
||||||
try {
|
|
||||||
SyncForegroundService.start(reactApplicationContext, title, body)
|
|
||||||
promise.resolve(true)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 停止前台服务
|
|
||||||
*/
|
|
||||||
@ReactMethod
|
|
||||||
fun stop(promise: Promise) {
|
|
||||||
try {
|
|
||||||
SyncForegroundService.stop(reactApplicationContext)
|
|
||||||
promise.resolve(true)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 更新通知内容
|
|
||||||
*/
|
|
||||||
@ReactMethod
|
|
||||||
fun update(title: String, body: String, promise: Promise) {
|
|
||||||
try {
|
|
||||||
SyncForegroundService.update(reactApplicationContext, title, body)
|
|
||||||
promise.resolve(true)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成 React Native Package 代码
|
|
||||||
*/
|
|
||||||
function generatePackageCode(packageName) {
|
|
||||||
return `package ${packageName}
|
|
||||||
|
|
||||||
import com.facebook.react.ReactPackage
|
|
||||||
import com.facebook.react.bridge.NativeModule
|
|
||||||
import com.facebook.react.bridge.ReactApplicationContext
|
|
||||||
import com.facebook.react.uimanager.ViewManager
|
|
||||||
|
|
||||||
class ForegroundServicePackage : ReactPackage {
|
|
||||||
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
|
|
||||||
return listOf(ForegroundServiceModule(reactContext))
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
|
|
||||||
return emptyList()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生成通知图标 XML
|
|
||||||
*/
|
|
||||||
function generateNotificationIcon() {
|
|
||||||
return `<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:width="24dp"
|
|
||||||
android:height="24dp"
|
|
||||||
android:viewportWidth="24"
|
|
||||||
android:viewportHeight="24">
|
|
||||||
<!-- 背景圆 -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#4CAF50"
|
|
||||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
|
|
||||||
<!-- 对勾符号 -->
|
|
||||||
<path
|
|
||||||
android:fillColor="#FFFFFF"
|
|
||||||
android:pathData="M10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z"/>
|
|
||||||
</vector>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = withForegroundService;
|
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
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: (comment: Comment) => void; // 点赞回调,传入评论对象
|
onLike: () => void;
|
||||||
floorNumber?: number; // 楼层号
|
floorNumber?: number; // 楼层号
|
||||||
isAuthor?: boolean; // 是否是楼主
|
isAuthor?: boolean; // 是否是楼主
|
||||||
replyToUser?: string; // 回复给哪位用户
|
replyToUser?: string; // 回复给哪位用户
|
||||||
@@ -29,7 +29,6 @@ interface CommentItemProps {
|
|||||||
onDelete?: (comment: Comment) => void; // 删除评论的回调
|
onDelete?: (comment: Comment) => void; // 删除评论的回调
|
||||||
onImagePress?: (images: ImageGridItem[], index: number) => void; // 点击图片查看大图
|
onImagePress?: (images: ImageGridItem[], index: number) => void; // 点击图片查看大图
|
||||||
currentUserId?: string; // 当前用户ID,用于判断子评论作者
|
currentUserId?: string; // 当前用户ID,用于判断子评论作者
|
||||||
onReport?: (comment: Comment) => void; // 举报评论的回调
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createCommentItemStyles(colors: AppColors) {
|
function createCommentItemStyles(colors: AppColors) {
|
||||||
@@ -215,7 +214,6 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onImagePress,
|
onImagePress,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
onReport,
|
|
||||||
}) => {
|
}) => {
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const styles = useMemo(() => createCommentItemStyles(colors), [colors]);
|
const styles = useMemo(() => createCommentItemStyles(colors), [colors]);
|
||||||
@@ -506,25 +504,6 @@ 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
|
||||||
@@ -542,18 +521,6 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
{/* 举报按钮 - 对非子评论作者显示 */}
|
|
||||||
{!isSubReplyAuthor && onReport && (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.subActionButton}
|
|
||||||
onPress={() => onReport(reply)}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons name="flag-outline" size={12} color={colors.text.hint} />
|
|
||||||
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
|
|
||||||
举报
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -625,7 +592,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
{/* 操作按钮 - 更紧凑 */}
|
{/* 操作按钮 - 更紧凑 */}
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
{/* 点赞 */}
|
{/* 点赞 */}
|
||||||
<TouchableOpacity style={styles.actionButton} onPress={() => onLike(comment)}>
|
<TouchableOpacity style={styles.actionButton} onPress={onLike}>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name={comment.is_liked ? 'heart' : 'heart-outline'}
|
name={comment.is_liked ? 'heart' : 'heart-outline'}
|
||||||
size={14}
|
size={14}
|
||||||
@@ -648,19 +615,6 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* 举报按钮 - 只对非评论作者显示 */}
|
|
||||||
{!isCommentAuthor && onReport && (
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.actionButton}
|
|
||||||
onPress={() => onReport(comment)}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons name="flag-outline" size={14} color={colors.text.hint} />
|
|
||||||
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
|
|
||||||
举报
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 删除按钮 - 只对评论作者显示 */}
|
{/* 删除按钮 - 只对评论作者显示 */}
|
||||||
{isCommentAuthor && (
|
{isCommentAuthor && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ export type PostCardActionType =
|
|||||||
| 'unbookmark' // 取消收藏
|
| 'unbookmark' // 取消收藏
|
||||||
| 'share' // 分享
|
| 'share' // 分享
|
||||||
| 'imagePress' // 点击图片
|
| 'imagePress' // 点击图片
|
||||||
| 'delete' // 删除
|
| 'delete'; // 删除
|
||||||
| 'report'; // 举报
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Action payload 类型
|
* Action payload 类型
|
||||||
|
|||||||
@@ -7,13 +7,12 @@ 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';
|
||||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||||
import { blurActiveElement } from '../../infrastructure/platform';
|
|
||||||
|
|
||||||
const { width, height } = Dimensions.get('window');
|
const { width, height } = Dimensions.get('window');
|
||||||
|
|
||||||
@@ -134,80 +133,22 @@ 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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Web 端不支持扫码组件
|
export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||||
const QRCodeScannerWeb: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
|
||||||
const themeColors = useAppColors();
|
|
||||||
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) {
|
|
||||||
blurActiveElement();
|
|
||||||
}
|
|
||||||
}, [visible]);
|
|
||||||
|
|
||||||
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 && Platform.OS !== 'web') {
|
if (visible) {
|
||||||
import('expo-camera').then((module) => {
|
setScanned(false);
|
||||||
setCameraModule(module);
|
if (!permission?.granted) {
|
||||||
const [perm, requestPerm] = module.useCameraPermissions();
|
requestPermission();
|
||||||
setPermission(perm);
|
|
||||||
if (!perm?.granted) {
|
|
||||||
requestPerm();
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
@@ -237,7 +178,7 @@ const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose })
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!cameraModule || !permission?.granted) {
|
if (!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}>
|
||||||
@@ -251,10 +192,7 @@ const QRCodeScannerNative: 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
|
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
|
||||||
style={styles.permissionButton}
|
|
||||||
onPress={() => cameraModule?.useCameraPermissions()[1]()}
|
|
||||||
>
|
|
||||||
<Text style={styles.permissionButtonText}>授予权限</Text>
|
<Text style={styles.permissionButtonText}>授予权限</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -263,8 +201,6 @@ const QRCodeScannerNative: 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}>
|
||||||
@@ -307,9 +243,4 @@ const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose })
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
// 根据平台导出不同组件
|
|
||||||
export const QRCodeScanner: React.FC<QRCodeScannerProps> = Platform.OS === 'web'
|
|
||||||
? QRCodeScannerWeb
|
|
||||||
: QRCodeScannerNative;
|
|
||||||
|
|
||||||
export default QRCodeScanner;
|
export default QRCodeScanner;
|
||||||
|
|||||||
@@ -1,493 +0,0 @@
|
|||||||
/**
|
|
||||||
* 举报对话框组件
|
|
||||||
* 支持举报帖子、评论、消息
|
|
||||||
* 提供友好的用户界面和流畅的交互体验
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
|
||||||
import {
|
|
||||||
Modal,
|
|
||||||
View,
|
|
||||||
TouchableOpacity,
|
|
||||||
StyleSheet,
|
|
||||||
ScrollView,
|
|
||||||
TextInput,
|
|
||||||
ActivityIndicator,
|
|
||||||
Alert,
|
|
||||||
Platform,
|
|
||||||
KeyboardAvoidingView,
|
|
||||||
} from 'react-native';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
||||||
import { useAppColors } from '../../../theme';
|
|
||||||
import { spacing, borderRadius, fontSizes, shadows } from '../../../theme';
|
|
||||||
import reportService, { ReportReason, ReportTargetType, REPORT_REASONS } from '../../../services/reportService';
|
|
||||||
import { blurActiveElement } from '../../../infrastructure/platform';
|
|
||||||
import Text from '../../common/Text';
|
|
||||||
|
|
||||||
export interface ReportDialogProps {
|
|
||||||
visible: boolean;
|
|
||||||
targetType: ReportTargetType;
|
|
||||||
targetId: string;
|
|
||||||
onClose: () => void;
|
|
||||||
onSuccess?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 目标类型配置
|
|
||||||
const TARGET_CONFIG: Record<ReportTargetType, { label: string; icon: string; color: string }> = {
|
|
||||||
post: { label: '帖子', icon: 'file-document-outline', color: '#FF6B35' },
|
|
||||||
comment: { label: '评论', icon: 'comment-text-outline', color: '#4CAF50' },
|
|
||||||
message: { label: '消息', icon: 'message-text-outline', color: '#2196F3' },
|
|
||||||
};
|
|
||||||
|
|
||||||
// 举报原因详细配置(带图标和说明)
|
|
||||||
const REPORT_REASONS_DETAILED = [
|
|
||||||
{ value: 'spam' as ReportReason, label: '垃圾广告', icon: 'email-newsletter', description: '包含广告、推广或重复内容' },
|
|
||||||
{ value: 'inappropriate' as ReportReason, label: '违规内容', icon: 'alert-circle-outline', description: '包含违法、色情或暴力内容' },
|
|
||||||
{ value: 'harassment' as ReportReason, label: '辱骂攻击', icon: 'account-off-outline', description: '包含人身攻击、骚扰或歧视' },
|
|
||||||
{ value: 'misinformation' as ReportReason, label: '虚假信息', icon: 'alert-outline', description: '包含谣言、诈骗或误导信息' },
|
|
||||||
{ value: 'other' as ReportReason, label: '其他原因', icon: 'dots-horizontal-circle-outline', description: '其他需要举报的情况' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ReportDialog: React.FC<ReportDialogProps> = ({
|
|
||||||
visible,
|
|
||||||
targetType,
|
|
||||||
targetId,
|
|
||||||
onClose,
|
|
||||||
onSuccess,
|
|
||||||
}) => {
|
|
||||||
const colors = useAppColors();
|
|
||||||
const styles = useStyles(colors);
|
|
||||||
const [selectedReason, setSelectedReason] = useState<ReportReason | null>(null);
|
|
||||||
const [description, setDescription] = useState('');
|
|
||||||
const [submitting, setSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const targetConfig = TARGET_CONFIG[targetType];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (visible) {
|
|
||||||
blurActiveElement();
|
|
||||||
}
|
|
||||||
}, [visible]);
|
|
||||||
|
|
||||||
// 判断补充说明是否必填(选择"其他原因"时必填)
|
|
||||||
const isDescriptionRequired = selectedReason === 'other';
|
|
||||||
const descriptionPlaceholder = isDescriptionRequired
|
|
||||||
? '请详细描述您举报的原因(必填)...'
|
|
||||||
: '请提供更多详细信息(选填)...';
|
|
||||||
|
|
||||||
// 提交举报
|
|
||||||
const handleSubmit = useCallback(async () => {
|
|
||||||
if (!selectedReason) {
|
|
||||||
Alert.alert('提示', '请选择举报原因', [{ text: '确定' }]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDescriptionRequired && !description.trim()) {
|
|
||||||
Alert.alert('提示', '请选择"其他原因"时,请详细描述举报原因', [{ text: '确定' }]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSubmitting(true);
|
|
||||||
try {
|
|
||||||
const result = await reportService.createReport(
|
|
||||||
targetType,
|
|
||||||
targetId,
|
|
||||||
selectedReason,
|
|
||||||
description.trim() || undefined
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result) {
|
|
||||||
Alert.alert('举报成功', '感谢您的反馈,我们会尽快处理', [
|
|
||||||
{
|
|
||||||
text: '确定',
|
|
||||||
onPress: () => {
|
|
||||||
onClose();
|
|
||||||
onSuccess?.();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
Alert.alert('举报失败', '请稍后重试', [{ text: '确定' }]);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Submit report error:', error);
|
|
||||||
Alert.alert('举报失败', '网络错误,请稍后重试', [{ text: '确定' }]);
|
|
||||||
} finally {
|
|
||||||
setSubmitting(false);
|
|
||||||
}
|
|
||||||
}, [selectedReason, description, targetType, targetId, onClose, onSuccess]);
|
|
||||||
|
|
||||||
// 重置状态
|
|
||||||
const handleClose = useCallback(() => {
|
|
||||||
setSelectedReason(null);
|
|
||||||
setDescription('');
|
|
||||||
onClose();
|
|
||||||
}, [onClose]);
|
|
||||||
|
|
||||||
const selectedReasonData = useMemo(
|
|
||||||
() => REPORT_REASONS_DETAILED.find(r => r.value === selectedReason),
|
|
||||||
[selectedReason]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
visible={visible}
|
|
||||||
transparent
|
|
||||||
animationType="fade"
|
|
||||||
onRequestClose={handleClose}
|
|
||||||
>
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
||||||
style={styles.keyboardView}
|
|
||||||
>
|
|
||||||
<View style={styles.overlay}>
|
|
||||||
<View style={styles.container}>
|
|
||||||
{/* 头部 */}
|
|
||||||
<View style={styles.header}>
|
|
||||||
<View style={styles.headerContent}>
|
|
||||||
<View style={[styles.headerIcon, { backgroundColor: targetConfig.color + '15' }]}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={targetConfig.icon as any}
|
|
||||||
size={20}
|
|
||||||
color={targetConfig.color}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={styles.headerText}>
|
|
||||||
<Text style={styles.title}>举报{targetConfig.label}</Text>
|
|
||||||
<Text style={styles.subtitle}>请选择合适的举报原因</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.closeButton}
|
|
||||||
onPress={handleClose}
|
|
||||||
disabled={submitting}
|
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="close"
|
|
||||||
size={24}
|
|
||||||
color={colors.chat.textSecondary}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 内容 */}
|
|
||||||
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
|
|
||||||
{/* 举报原因选择 */}
|
|
||||||
<View style={styles.section}>
|
|
||||||
<Text style={styles.sectionTitle}>举报原因</Text>
|
|
||||||
<View style={styles.reasonList}>
|
|
||||||
{REPORT_REASONS_DETAILED.map((item) => (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={item.value}
|
|
||||||
style={[
|
|
||||||
styles.reasonItem,
|
|
||||||
selectedReason === item.value && [
|
|
||||||
styles.reasonItemSelected,
|
|
||||||
{ borderColor: colors.primary.main + '40' }
|
|
||||||
],
|
|
||||||
]}
|
|
||||||
onPress={() => setSelectedReason(item.value)}
|
|
||||||
disabled={submitting}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<View style={styles.reasonLeft}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.reasonIcon,
|
|
||||||
selectedReason === item.value && styles.reasonIconSelected,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={item.icon as any}
|
|
||||||
size={20}
|
|
||||||
color={selectedReason === item.value ? colors.primary.main : colors.chat.textSecondary}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={styles.reasonTextContainer}>
|
|
||||||
<Text style={[
|
|
||||||
styles.reasonLabel,
|
|
||||||
...(selectedReason === item.value ? [styles.reasonLabelSelected] : []),
|
|
||||||
]}>
|
|
||||||
{item.label}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.reasonDescription}>{item.description}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.radio,
|
|
||||||
selectedReason === item.value && styles.radioSelected,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{selectedReason === item.value && (
|
|
||||||
<View style={styles.radioDot} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 补充说明 */}
|
|
||||||
<View style={styles.section}>
|
|
||||||
<View style={styles.sectionHeader}>
|
|
||||||
<Text style={styles.sectionTitle}>
|
|
||||||
补充说明{isDescriptionRequired ? ' *' : ''}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.charCount}>{description.length}/500</Text>
|
|
||||||
</View>
|
|
||||||
<TextInput
|
|
||||||
style={[
|
|
||||||
styles.textInput,
|
|
||||||
isDescriptionRequired && !description && styles.textInputError,
|
|
||||||
]}
|
|
||||||
placeholder={descriptionPlaceholder}
|
|
||||||
placeholderTextColor={colors.chat.textPlaceholder}
|
|
||||||
multiline
|
|
||||||
maxLength={500}
|
|
||||||
value={description}
|
|
||||||
onChangeText={setDescription}
|
|
||||||
editable={!submitting}
|
|
||||||
textAlignVertical="top"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
|
|
||||||
{/* 底部按钮 */}
|
|
||||||
<View style={styles.footer}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.cancelButton}
|
|
||||||
onPress={handleClose}
|
|
||||||
disabled={submitting}
|
|
||||||
activeOpacity={0.7}
|
|
||||||
>
|
|
||||||
<Text style={styles.cancelButtonText}>取消</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.submitButton,
|
|
||||||
(!selectedReason || submitting) && styles.submitButtonDisabled,
|
|
||||||
]}
|
|
||||||
onPress={handleSubmit}
|
|
||||||
disabled={!selectedReason || submitting}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
>
|
|
||||||
{submitting ? (
|
|
||||||
<ActivityIndicator size="small" color="#FFFFFF" />
|
|
||||||
) : (
|
|
||||||
<Text style={styles.submitButtonText}>提交举报</Text>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</KeyboardAvoidingView>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 创建样式
|
|
||||||
const useStyles = (colors: ReturnType<typeof useAppColors>) =>
|
|
||||||
StyleSheet.create({
|
|
||||||
keyboardView: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
overlay: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: spacing.lg,
|
|
||||||
},
|
|
||||||
container: {
|
|
||||||
backgroundColor: colors.chat.card,
|
|
||||||
borderRadius: borderRadius.xl,
|
|
||||||
width: '100%',
|
|
||||||
maxWidth: 420,
|
|
||||||
maxHeight: '85%',
|
|
||||||
...shadows.lg,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: spacing.lg,
|
|
||||||
paddingBottom: spacing.md,
|
|
||||||
},
|
|
||||||
headerContent: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: spacing.md,
|
|
||||||
},
|
|
||||||
headerIcon: {
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
borderRadius: borderRadius.md,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
headerText: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: fontSizes.xl,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.chat.textPrimary,
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: fontSizes.sm,
|
|
||||||
color: colors.chat.textSecondary,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
closeButton: {
|
|
||||||
padding: spacing.xs,
|
|
||||||
borderRadius: borderRadius.full,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
padding: spacing.lg,
|
|
||||||
paddingTop: 0,
|
|
||||||
maxHeight: 400,
|
|
||||||
},
|
|
||||||
section: {
|
|
||||||
marginBottom: spacing.lg,
|
|
||||||
},
|
|
||||||
sectionHeader: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: spacing.sm,
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.chat.textPrimary,
|
|
||||||
},
|
|
||||||
reasonList: {
|
|
||||||
gap: spacing.sm,
|
|
||||||
},
|
|
||||||
reasonItem: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: spacing.md,
|
|
||||||
borderRadius: borderRadius.md,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'transparent',
|
|
||||||
backgroundColor: colors.chat.surfaceMuted,
|
|
||||||
},
|
|
||||||
reasonItemSelected: {
|
|
||||||
backgroundColor: colors.primary.main + '08',
|
|
||||||
borderWidth: 1,
|
|
||||||
},
|
|
||||||
reasonLeft: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
flex: 1,
|
|
||||||
gap: spacing.md,
|
|
||||||
},
|
|
||||||
reasonIcon: {
|
|
||||||
width: 36,
|
|
||||||
height: 36,
|
|
||||||
borderRadius: borderRadius.md,
|
|
||||||
backgroundColor: colors.chat.surfaceRaised,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
reasonIconSelected: {
|
|
||||||
backgroundColor: colors.primary.main + '15',
|
|
||||||
},
|
|
||||||
reasonTextContainer: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
reasonLabel: {
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: colors.chat.textPrimary,
|
|
||||||
},
|
|
||||||
reasonLabelSelected: {
|
|
||||||
color: colors.primary.main,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
reasonDescription: {
|
|
||||||
fontSize: fontSizes.sm,
|
|
||||||
color: colors.chat.textSecondary,
|
|
||||||
marginTop: 2,
|
|
||||||
},
|
|
||||||
radio: {
|
|
||||||
width: 22,
|
|
||||||
height: 22,
|
|
||||||
borderRadius: 11,
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: colors.chat.border,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginLeft: spacing.sm,
|
|
||||||
},
|
|
||||||
radioSelected: {
|
|
||||||
borderColor: colors.primary.main,
|
|
||||||
backgroundColor: colors.primary.main,
|
|
||||||
},
|
|
||||||
radioDot: {
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
borderRadius: 5,
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
},
|
|
||||||
textInput: {
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.chat.border,
|
|
||||||
borderRadius: borderRadius.md,
|
|
||||||
padding: spacing.md,
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
color: colors.chat.textPrimary,
|
|
||||||
minHeight: 100,
|
|
||||||
backgroundColor: colors.chat.surfaceMuted,
|
|
||||||
},
|
|
||||||
textInputError: {
|
|
||||||
borderColor: colors.error.main,
|
|
||||||
backgroundColor: colors.error.main + '08',
|
|
||||||
},
|
|
||||||
charCount: {
|
|
||||||
fontSize: fontSizes.sm,
|
|
||||||
color: colors.chat.textSecondary,
|
|
||||||
},
|
|
||||||
footer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
padding: spacing.lg,
|
|
||||||
gap: spacing.md,
|
|
||||||
borderTopWidth: 1,
|
|
||||||
borderTopColor: colors.chat.borderLight,
|
|
||||||
},
|
|
||||||
cancelButton: {
|
|
||||||
flex: 1,
|
|
||||||
paddingVertical: spacing.md,
|
|
||||||
borderRadius: borderRadius.md,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.chat.border,
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: colors.chat.surfaceMuted,
|
|
||||||
},
|
|
||||||
cancelButtonText: {
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: colors.chat.textSecondary,
|
|
||||||
},
|
|
||||||
submitButton: {
|
|
||||||
flex: 1,
|
|
||||||
paddingVertical: spacing.md,
|
|
||||||
borderRadius: borderRadius.md,
|
|
||||||
backgroundColor: colors.primary.main,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
submitButtonDisabled: {
|
|
||||||
backgroundColor: colors.background.disabled,
|
|
||||||
},
|
|
||||||
submitButtonText: {
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
color: '#FFFFFF',
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
export default ReportDialog;
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { default } from './ReportDialog';
|
|
||||||
export type { ReportDialogProps } from './ReportDialog';
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* SystemMessageItem 系统消息项组件 - 扁平化风格
|
* SystemMessageItem 系统消息项组件 - 美化版
|
||||||
* 根据系统消息类型显示不同图标和内容
|
* 根据系统消息类型显示不同图标和内容
|
||||||
* 与登录、注册、设置页面风格保持一致
|
* 采用卡片式设计,符合胡萝卜BBS整体风格
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
@@ -9,7 +9,7 @@ import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
|||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { zhCN } from 'date-fns/locale';
|
import { zhCN } from 'date-fns/locale';
|
||||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
|
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||||
import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
|
import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
|
||||||
import Text from '../common/Text';
|
import Text from '../common/Text';
|
||||||
import Avatar from '../common/Avatar';
|
import Avatar from '../common/Avatar';
|
||||||
@@ -124,13 +124,17 @@ function createSystemMessageStyles(colors: AppColors) {
|
|||||||
container: {
|
container: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
padding: 16,
|
padding: spacing.md,
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.paper,
|
||||||
borderBottomWidth: 1,
|
borderRadius: borderRadius.lg,
|
||||||
borderBottomColor: colors.divider || '#E5E5EA',
|
marginHorizontal: spacing.md,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
...shadows.sm,
|
||||||
},
|
},
|
||||||
unreadContainer: {
|
unreadContainer: {
|
||||||
backgroundColor: colors.primary.main + '0A',
|
backgroundColor: colors.primary.light + '08',
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: colors.primary.main,
|
||||||
},
|
},
|
||||||
unreadIndicator: {
|
unreadIndicator: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -139,19 +143,19 @@ function createSystemMessageStyles(colors: AppColors) {
|
|||||||
marginTop: -4,
|
marginTop: -4,
|
||||||
width: 8,
|
width: 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
borderRadius: 4,
|
borderRadius: borderRadius.full,
|
||||||
backgroundColor: colors.primary.main,
|
backgroundColor: colors.primary.main,
|
||||||
},
|
},
|
||||||
iconContainer: {
|
iconContainer: {
|
||||||
marginRight: 14,
|
marginRight: spacing.md,
|
||||||
},
|
},
|
||||||
avatarWrapper: {
|
avatarWrapper: {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
iconWrapper: {
|
iconWrapper: {
|
||||||
width: 44,
|
width: 48,
|
||||||
height: 44,
|
height: 48,
|
||||||
borderRadius: 12,
|
borderRadius: borderRadius.lg,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
@@ -159,9 +163,9 @@ function createSystemMessageStyles(colors: AppColors) {
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: -2,
|
bottom: -2,
|
||||||
right: -2,
|
right: -2,
|
||||||
width: 18,
|
width: 20,
|
||||||
height: 18,
|
height: 20,
|
||||||
borderRadius: 9,
|
borderRadius: borderRadius.full,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
@@ -176,17 +180,17 @@ function createSystemMessageStyles(colors: AppColors) {
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: 4,
|
marginBottom: spacing.xs,
|
||||||
},
|
},
|
||||||
titleLeft: {
|
titleLeft: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
marginRight: 8,
|
marginRight: spacing.sm,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
fontSize: 15,
|
fontSize: fontSizes.md,
|
||||||
color: colors.text.primary,
|
color: colors.text.primary,
|
||||||
},
|
},
|
||||||
unreadTitle: {
|
unreadTitle: {
|
||||||
@@ -196,10 +200,10 @@ function createSystemMessageStyles(colors: AppColors) {
|
|||||||
statusBadge: {
|
statusBadge: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: 6,
|
paddingHorizontal: spacing.xs,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
borderRadius: 4,
|
borderRadius: borderRadius.sm,
|
||||||
marginLeft: 6,
|
marginLeft: spacing.xs,
|
||||||
},
|
},
|
||||||
statusText: {
|
statusText: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
@@ -207,59 +211,55 @@ function createSystemMessageStyles(colors: AppColors) {
|
|||||||
marginLeft: 2,
|
marginLeft: 2,
|
||||||
},
|
},
|
||||||
time: {
|
time: {
|
||||||
fontSize: 13,
|
fontSize: fontSizes.sm,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
color: colors.text.hint,
|
|
||||||
},
|
},
|
||||||
messageContent: {
|
messageContent: {
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
fontSize: 14,
|
fontSize: fontSizes.sm,
|
||||||
color: colors.text.secondary,
|
|
||||||
},
|
},
|
||||||
extraInfo: {
|
extraInfo: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginTop: 8,
|
marginTop: spacing.xs,
|
||||||
backgroundColor: colors.background.default,
|
backgroundColor: colors.primary.light + '12',
|
||||||
paddingHorizontal: 10,
|
paddingHorizontal: spacing.sm,
|
||||||
paddingVertical: 6,
|
paddingVertical: spacing.xs,
|
||||||
borderRadius: 8,
|
borderRadius: borderRadius.md,
|
||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
},
|
},
|
||||||
extraText: {
|
extraText: {
|
||||||
marginLeft: 6,
|
marginLeft: spacing.xs,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
fontSize: 13,
|
|
||||||
},
|
},
|
||||||
actionsRow: {
|
actionsRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
marginTop: 12,
|
marginTop: spacing.sm,
|
||||||
gap: 10,
|
gap: spacing.sm,
|
||||||
},
|
},
|
||||||
actionBtn: {
|
actionBtn: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: 8,
|
paddingVertical: spacing.xs,
|
||||||
paddingHorizontal: 16,
|
paddingHorizontal: spacing.md,
|
||||||
borderRadius: 10,
|
borderRadius: borderRadius.md,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
gap: 4,
|
gap: spacing.xs,
|
||||||
},
|
},
|
||||||
rejectBtn: {
|
rejectBtn: {
|
||||||
borderColor: colors.error.main + '40',
|
borderColor: `${colors.error.main}55`,
|
||||||
backgroundColor: colors.error.light + '20',
|
backgroundColor: `${colors.error.main}18`,
|
||||||
},
|
},
|
||||||
approveBtn: {
|
approveBtn: {
|
||||||
borderColor: colors.success.main + '40',
|
borderColor: `${colors.success.main}55`,
|
||||||
backgroundColor: colors.success.light + '20',
|
backgroundColor: `${colors.success.main}18`,
|
||||||
},
|
},
|
||||||
actionBtnText: {
|
actionBtnText: {
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
fontSize: 13,
|
|
||||||
},
|
},
|
||||||
arrowContainer: {
|
arrowContainer: {
|
||||||
marginLeft: 8,
|
marginLeft: spacing.sm,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
height: 20,
|
height: 20,
|
||||||
|
|||||||
@@ -135,33 +135,26 @@ function createTabBarStyles(colors: AppColors) {
|
|||||||
modernContainer: {
|
modernContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.paper,
|
||||||
borderRadius: borderRadius['2xl'],
|
borderRadius: borderRadius.xl,
|
||||||
marginHorizontal: spacing.lg,
|
marginHorizontal: spacing.lg,
|
||||||
marginVertical: spacing.sm,
|
marginVertical: spacing.md,
|
||||||
padding: spacing.xs,
|
padding: spacing.xs,
|
||||||
shadowColor: colors.chat.shadow,
|
shadowColor: colors.chat.shadow,
|
||||||
shadowOffset: { width: 0, height: 4 },
|
shadowOffset: { width: 0, height: 2 },
|
||||||
shadowOpacity: 0.1,
|
shadowOpacity: 0.08,
|
||||||
shadowRadius: 16,
|
shadowRadius: 8,
|
||||||
elevation: 6,
|
elevation: 3,
|
||||||
},
|
},
|
||||||
modernTab: {
|
modernTab: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
paddingVertical: spacing.md,
|
paddingVertical: spacing.sm,
|
||||||
paddingHorizontal: spacing.sm,
|
|
||||||
borderRadius: borderRadius.lg,
|
borderRadius: borderRadius.lg,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
backgroundColor: 'transparent',
|
|
||||||
},
|
},
|
||||||
modernTabActive: {
|
modernTabActive: {
|
||||||
backgroundColor: colors.primary.main + '12',
|
backgroundColor: colors.primary.main + '15',
|
||||||
shadowColor: colors.primary.main + '20',
|
|
||||||
shadowOffset: { width: 0, height: 2 },
|
|
||||||
shadowOpacity: 0.1,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 3,
|
|
||||||
},
|
},
|
||||||
modernTabContent: {
|
modernTabContent: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -172,7 +165,7 @@ function createTabBarStyles(colors: AppColors) {
|
|||||||
marginRight: spacing.xs,
|
marginRight: spacing.xs,
|
||||||
},
|
},
|
||||||
modernTabText: {
|
modernTabText: {
|
||||||
fontWeight: '600',
|
fontWeight: '500',
|
||||||
fontSize: fontSizes.md,
|
fontSize: fontSizes.md,
|
||||||
},
|
},
|
||||||
modernTabTextActive: {
|
modernTabTextActive: {
|
||||||
@@ -213,7 +206,7 @@ const TabBar: React.FC<TabBarProps> = ({
|
|||||||
key={index}
|
key={index}
|
||||||
style={[styles.modernTab, isActive && styles.modernTabActive]}
|
style={[styles.modernTab, isActive && styles.modernTabActive]}
|
||||||
onPress={() => onTabChange(index)}
|
onPress={() => onTabChange(index)}
|
||||||
activeOpacity={0.7}
|
activeOpacity={0.85}
|
||||||
>
|
>
|
||||||
<View style={styles.modernTabContent}>
|
<View style={styles.modernTabContent}>
|
||||||
{icon && (
|
{icon && (
|
||||||
|
|||||||
@@ -22,4 +22,3 @@ export { default as TabBar } from './TabBar';
|
|||||||
export { default as VoteCard } from './VoteCard';
|
export { default as VoteCard } from './VoteCard';
|
||||||
export { default as VoteEditor } from './VoteEditor';
|
export { default as VoteEditor } from './VoteEditor';
|
||||||
export { default as VotePreview } from './VotePreview';
|
export { default as VotePreview } from './VotePreview';
|
||||||
export { default as ReportDialog } from './ReportDialog';
|
|
||||||
|
|||||||
@@ -1,412 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
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 'ended':
|
|
||||||
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;
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -1,279 +0,0 @@
|
|||||||
import React, { useEffect, useRef } from 'react';
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
StyleSheet,
|
|
||||||
TouchableOpacity,
|
|
||||||
Image,
|
|
||||||
Animated,
|
|
||||||
Modal,
|
|
||||||
StatusBar,
|
|
||||||
Dimensions,
|
|
||||||
Platform,
|
|
||||||
} from 'react-native';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
||||||
import { callStore } from '../../stores/callStore';
|
|
||||||
import { blurActiveElement } from '../../infrastructure/platform';
|
|
||||||
|
|
||||||
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) {
|
|
||||||
blurActiveElement();
|
|
||||||
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;
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
export { default as CallScreen } from './CallScreen';
|
|
||||||
export { default as IncomingCallModal } from './IncomingCallModal';
|
|
||||||
export { default as FloatingCallWindow } from './FloatingCallWindow';
|
|
||||||
@@ -15,14 +15,12 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Pressable,
|
Pressable,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
Platform,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import {
|
import {
|
||||||
useResponsive,
|
useResponsive,
|
||||||
useBreakpointGTE,
|
useBreakpointGTE,
|
||||||
FineBreakpointKey,
|
FineBreakpointKey,
|
||||||
} from '../../hooks/useResponsive';
|
} from '../../hooks/useResponsive';
|
||||||
import { blurActiveElement } from '../../infrastructure/platform';
|
|
||||||
|
|
||||||
export interface AdaptiveLayoutProps {
|
export interface AdaptiveLayoutProps {
|
||||||
/** 主内容区 */
|
/** 主内容区 */
|
||||||
@@ -161,9 +159,6 @@ export function AdaptiveLayout({
|
|||||||
// 抽屉动画
|
// 抽屉动画
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDrawerOpen) {
|
if (isDrawerOpen) {
|
||||||
if (isDrawerOpen) {
|
|
||||||
blurActiveElement();
|
|
||||||
}
|
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
Animated.timing(slideAnim, {
|
Animated.timing(slideAnim, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Modal, Pressable, StyleSheet, Text, TouchableOpacity, View, Platform } from 'react-native';
|
import { Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import type { AlertButton } from 'react-native';
|
import type { AlertButton } from 'react-native';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
|
||||||
import { bindDialogListener, DialogPayload } from '../../services/dialogService';
|
import { bindDialogListener, DialogPayload } from '../../services/dialogService';
|
||||||
import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme';
|
import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme';
|
||||||
import { blurActiveElement } from '../../infrastructure/platform';
|
|
||||||
|
|
||||||
function createDialogHostStyles(colors: AppColors) {
|
function createDialogHostStyles(colors: AppColors) {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
@@ -106,12 +105,6 @@ const AppDialogHost: React.FC = () => {
|
|||||||
return unbind;
|
return unbind;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (dialog) {
|
|
||||||
blurActiveElement();
|
|
||||||
}
|
|
||||||
}, [dialog]);
|
|
||||||
|
|
||||||
const actions = useMemo<AlertButton[]>(() => {
|
const actions = useMemo<AlertButton[]>(() => {
|
||||||
if (!dialog?.actions?.length) return [{ text: '确定' }];
|
if (!dialog?.actions?.length) return [{ text: '确定' }];
|
||||||
return dialog.actions;
|
return dialog.actions;
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
Alert,
|
Alert,
|
||||||
Platform,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Image as ExpoImage } from 'expo-image';
|
import { Image as ExpoImage } from 'expo-image';
|
||||||
import {
|
import {
|
||||||
@@ -33,7 +32,6 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|||||||
import * as MediaLibrary from 'expo-media-library';
|
import * as MediaLibrary from 'expo-media-library';
|
||||||
import { File, Paths } from 'expo-file-system';
|
import { File, Paths } from 'expo-file-system';
|
||||||
import { spacing, borderRadius, fontSizes } from '../../theme';
|
import { spacing, borderRadius, fontSizes } from '../../theme';
|
||||||
import { blurActiveElement } from '../../infrastructure/platform';
|
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
|
||||||
@@ -128,7 +126,6 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
// 打开/关闭时重置状态
|
// 打开/关闭时重置状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
blurActiveElement();
|
|
||||||
setCurrentIndex(initialIndex);
|
setCurrentIndex(initialIndex);
|
||||||
setShowControls(true);
|
setShowControls(true);
|
||||||
setError(false);
|
setError(false);
|
||||||
|
|||||||
@@ -132,10 +132,10 @@ function createImageGridStyles(colors: AppColors) {
|
|||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
},
|
},
|
||||||
gridItem2: {
|
gridItem2: {
|
||||||
width: '48.5%',
|
width: '49%',
|
||||||
},
|
},
|
||||||
gridItem3: {
|
gridItem3: {
|
||||||
width: '31.8%',
|
width: '32.5%',
|
||||||
},
|
},
|
||||||
masonryContainer: {
|
masonryContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { ViewStyle } from 'react-native';
|
|
||||||
import PagerViewNative from 'react-native-pager-view';
|
|
||||||
|
|
||||||
type PagerViewProps = {
|
|
||||||
style?: ViewStyle;
|
|
||||||
initialPage?: number;
|
|
||||||
onPageSelected?: (e: { nativeEvent: { position: number } }) => void;
|
|
||||||
overdrag?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PagerView = React.forwardRef<PagerViewNative, PagerViewProps>(
|
|
||||||
({ style, initialPage, onPageSelected, overdrag, children }, ref) => {
|
|
||||||
return (
|
|
||||||
<PagerViewNative
|
|
||||||
ref={ref}
|
|
||||||
style={style}
|
|
||||||
initialPage={initialPage}
|
|
||||||
onPageSelected={onPageSelected}
|
|
||||||
overdrag={overdrag}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</PagerViewNative>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
PagerView.displayName = 'PagerView';
|
|
||||||
|
|
||||||
export default PagerView;
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import React, { ForwardedRef } from 'react';
|
|
||||||
import { View, ViewStyle } from 'react-native';
|
|
||||||
|
|
||||||
type PagerViewProps = {
|
|
||||||
style?: ViewStyle;
|
|
||||||
initialPage?: number;
|
|
||||||
onPageSelected?: (e: { nativeEvent: { position: number } }) => void;
|
|
||||||
overdrag?: boolean;
|
|
||||||
children: React.ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
function PagerViewInternal(
|
|
||||||
{ style, children }: PagerViewProps,
|
|
||||||
_ref: ForwardedRef<{ setPage: (page: number) => void }>
|
|
||||||
) {
|
|
||||||
const childrenArray = React.Children.toArray(children);
|
|
||||||
return (
|
|
||||||
<View style={style}>
|
|
||||||
{childrenArray[0]}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const PagerView = React.forwardRef(PagerViewInternal);
|
|
||||||
PagerView.displayName = 'PagerView';
|
|
||||||
|
|
||||||
export default PagerView;
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
/**
|
|
||||||
* 步骤指示器组件 - 极简扁平设计
|
|
||||||
* 与下方表单风格统一
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React from 'react';
|
|
||||||
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
|
|
||||||
import { useAppColors, type AppColors } from '../../theme';
|
|
||||||
|
|
||||||
export interface Step {
|
|
||||||
id: number;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StepIndicatorProps {
|
|
||||||
steps: Step[];
|
|
||||||
currentStep: number;
|
|
||||||
containerStyle?: ViewStyle;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StepIndicator: React.FC<StepIndicatorProps> = ({
|
|
||||||
steps,
|
|
||||||
currentStep,
|
|
||||||
containerStyle,
|
|
||||||
}) => {
|
|
||||||
const colors = useAppColors();
|
|
||||||
const styles = createStyles(colors);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[styles.container, containerStyle]}>
|
|
||||||
{steps.map((step, index) => {
|
|
||||||
const isCompleted = index < currentStep;
|
|
||||||
const isCurrent = index === currentStep;
|
|
||||||
const isPending = index > currentStep;
|
|
||||||
const isLast = index === steps.length - 1;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment key={step.id}>
|
|
||||||
<View style={styles.stepWrapper}>
|
|
||||||
{/* 步骤编号/状态 */}
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.stepBadge,
|
|
||||||
isCompleted && styles.stepBadgeCompleted,
|
|
||||||
isCurrent && styles.stepBadgeCurrent,
|
|
||||||
isPending && styles.stepBadgePending,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.stepBadgeText,
|
|
||||||
isCompleted && styles.stepBadgeTextCompleted,
|
|
||||||
isCurrent && styles.stepBadgeTextCurrent,
|
|
||||||
isPending && styles.stepBadgeTextPending,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{isCompleted ? '✓' : index + 1}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 步骤标题 */}
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
styles.stepTitle,
|
|
||||||
isCompleted && styles.stepTitleCompleted,
|
|
||||||
isCurrent && styles.stepTitleCurrent,
|
|
||||||
isPending && styles.stepTitlePending,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{step.title}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 连接线 */}
|
|
||||||
{!isLast && (
|
|
||||||
<View style={styles.connector}>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.connectorLine,
|
|
||||||
isCompleted && styles.connectorLineCompleted,
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 注册流程专用步骤指示器
|
|
||||||
export const RegisterStepIndicator: React.FC<{
|
|
||||||
currentStep: number;
|
|
||||||
containerStyle?: ViewStyle;
|
|
||||||
}> = ({ currentStep, containerStyle }) => {
|
|
||||||
const steps: Step[] = [
|
|
||||||
{ id: 0, title: '输入邮箱' },
|
|
||||||
{ id: 1, title: '验证码' },
|
|
||||||
{ id: 2, title: '设置信息' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StepIndicator
|
|
||||||
steps={steps}
|
|
||||||
currentStep={currentStep}
|
|
||||||
containerStyle={containerStyle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function createStyles(colors: AppColors) {
|
|
||||||
return StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
width: '100%',
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
justifyContent: 'center',
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 8,
|
|
||||||
},
|
|
||||||
stepWrapper: {
|
|
||||||
alignItems: 'center',
|
|
||||||
minWidth: 80,
|
|
||||||
},
|
|
||||||
// 步骤徽章
|
|
||||||
stepBadge: {
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
borderRadius: 14,
|
|
||||||
backgroundColor: '#F5F5F7',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
borderWidth: 0,
|
|
||||||
},
|
|
||||||
stepBadgeCompleted: {
|
|
||||||
backgroundColor: '#FF6B35',
|
|
||||||
},
|
|
||||||
stepBadgeCurrent: {
|
|
||||||
backgroundColor: '#FFFFFF',
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: '#FF6B35',
|
|
||||||
},
|
|
||||||
stepBadgePending: {
|
|
||||||
backgroundColor: '#F5F5F7',
|
|
||||||
},
|
|
||||||
stepBadgeText: {
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: '#999',
|
|
||||||
},
|
|
||||||
stepBadgeTextCompleted: {
|
|
||||||
color: '#FFFFFF',
|
|
||||||
fontSize: 14,
|
|
||||||
},
|
|
||||||
stepBadgeTextCurrent: {
|
|
||||||
color: '#FF6B35',
|
|
||||||
},
|
|
||||||
stepBadgeTextPending: {
|
|
||||||
color: '#BBB',
|
|
||||||
},
|
|
||||||
// 步骤标题
|
|
||||||
stepTitle: {
|
|
||||||
marginTop: 8,
|
|
||||||
fontSize: 12,
|
|
||||||
color: '#999',
|
|
||||||
fontWeight: '500',
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
stepTitleCompleted: {
|
|
||||||
color: '#FF6B35',
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
stepTitleCurrent: {
|
|
||||||
color: '#333',
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
stepTitlePending: {
|
|
||||||
color: '#BBB',
|
|
||||||
},
|
|
||||||
// 连接线
|
|
||||||
connector: {
|
|
||||||
width: 60,
|
|
||||||
height: 28,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
connectorLine: {
|
|
||||||
width: '100%',
|
|
||||||
height: 2,
|
|
||||||
backgroundColor: '#E5E5E5',
|
|
||||||
borderRadius: 1,
|
|
||||||
},
|
|
||||||
connectorLineCompleted: {
|
|
||||||
backgroundColor: '#FF6B35',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default StepIndicator;
|
|
||||||
@@ -4,8 +4,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle, Platform } from 'react-native';
|
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle } from 'react-native';
|
||||||
import { useAppColors, fontSizes, fontWeights } from '../../theme';
|
import { useAppColors, fontSizes } from '../../theme';
|
||||||
|
|
||||||
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
|
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
|
||||||
|
|
||||||
@@ -16,45 +16,38 @@ 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: fontWeights.bold,
|
fontWeight: '700',
|
||||||
lineHeight: fontSizes['4xl'] * 1.3,
|
lineHeight: fontSizes['4xl'] * 1.4,
|
||||||
letterSpacing: -0.5,
|
|
||||||
},
|
},
|
||||||
h2: {
|
h2: {
|
||||||
fontSize: fontSizes['3xl'],
|
fontSize: fontSizes['3xl'],
|
||||||
fontWeight: fontWeights.semibold,
|
fontWeight: '600',
|
||||||
lineHeight: fontSizes['3xl'] * 1.3,
|
lineHeight: fontSizes['3xl'] * 1.4,
|
||||||
letterSpacing: -0.3,
|
|
||||||
},
|
},
|
||||||
h3: {
|
h3: {
|
||||||
fontSize: fontSizes['2xl'],
|
fontSize: fontSizes['2xl'],
|
||||||
fontWeight: fontWeights.semibold,
|
fontWeight: '600',
|
||||||
lineHeight: fontSizes['2xl'] * 1.3,
|
lineHeight: fontSizes['2xl'] * 1.3,
|
||||||
letterSpacing: -0.2,
|
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
fontSize: fontSizes.md,
|
fontSize: fontSizes.md,
|
||||||
fontWeight: fontWeights.regular,
|
fontWeight: '400',
|
||||||
lineHeight: fontSizes.md * 1.6,
|
lineHeight: fontSizes.md * 1.5,
|
||||||
letterSpacing: 0.1,
|
|
||||||
},
|
},
|
||||||
caption: {
|
caption: {
|
||||||
fontSize: fontSizes.sm,
|
fontSize: fontSizes.sm,
|
||||||
fontWeight: fontWeights.regular,
|
fontWeight: '400',
|
||||||
lineHeight: fontSizes.sm * 1.5,
|
lineHeight: fontSizes.sm * 1.4,
|
||||||
letterSpacing: 0.2,
|
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: fontSizes.xs,
|
fontSize: fontSizes.xs,
|
||||||
fontWeight: fontWeights.medium,
|
fontWeight: '500',
|
||||||
lineHeight: fontSizes.xs * 1.4,
|
lineHeight: fontSizes.xs * 1.4,
|
||||||
letterSpacing: 0.3,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,7 +55,6 @@ const Text: React.FC<CustomTextProps> = ({
|
|||||||
children,
|
children,
|
||||||
variant = 'body',
|
variant = 'body',
|
||||||
color,
|
color,
|
||||||
weight,
|
|
||||||
numberOfLines,
|
numberOfLines,
|
||||||
onPress,
|
onPress,
|
||||||
style,
|
style,
|
||||||
@@ -72,7 +64,6 @@ 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,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,256 +0,0 @@
|
|||||||
/**
|
|
||||||
* 验证码输入组件
|
|
||||||
* 参考设计:4-6位数字验证码,每个数字独立显示在一个方框中
|
|
||||||
* 支持自动聚焦、粘贴、删除回退
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useRef, useEffect, useCallback } from 'react';
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
TextInput,
|
|
||||||
StyleSheet,
|
|
||||||
Keyboard,
|
|
||||||
Platform,
|
|
||||||
Text,
|
|
||||||
ViewStyle,
|
|
||||||
} from 'react-native';
|
|
||||||
import { useAppColors, type AppColors } from '../../theme';
|
|
||||||
|
|
||||||
interface VerificationCodeInputProps {
|
|
||||||
value: string;
|
|
||||||
onChangeValue: (value: string) => void;
|
|
||||||
length?: number; // 验证码位数,默认4位
|
|
||||||
autoFocus?: boolean;
|
|
||||||
onComplete?: (value: string) => void; // 输入完成回调
|
|
||||||
containerStyle?: ViewStyle;
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CELL_WIDTH = 56;
|
|
||||||
const CELL_HEIGHT = 64;
|
|
||||||
const CELL_MARGIN = 8;
|
|
||||||
|
|
||||||
export const VerificationCodeInput: React.FC<VerificationCodeInputProps> = ({
|
|
||||||
value,
|
|
||||||
onChangeValue,
|
|
||||||
length = 4,
|
|
||||||
autoFocus = true,
|
|
||||||
onComplete,
|
|
||||||
containerStyle,
|
|
||||||
disabled = false,
|
|
||||||
}) => {
|
|
||||||
const colors = useAppColors();
|
|
||||||
const styles = createStyles(colors);
|
|
||||||
|
|
||||||
// 为每个数字创建一个 ref
|
|
||||||
const inputRefs = useRef<(TextInput | null)[]>(Array(length).fill(null));
|
|
||||||
|
|
||||||
// 将 value 分割为单个字符数组
|
|
||||||
const chars = value.split('').slice(0, length);
|
|
||||||
// 填充空位
|
|
||||||
while (chars.length < length) {
|
|
||||||
chars.push('');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取当前聚焦的位置
|
|
||||||
const getFocusedIndex = useCallback(() => {
|
|
||||||
return Math.min(value.length, length - 1);
|
|
||||||
}, [value.length, length]);
|
|
||||||
|
|
||||||
// 聚焦到指定位置
|
|
||||||
const focusInput = useCallback((index: number) => {
|
|
||||||
if (index >= 0 && index < length) {
|
|
||||||
inputRefs.current[index]?.focus();
|
|
||||||
}
|
|
||||||
}, [length]);
|
|
||||||
|
|
||||||
// 自动聚焦第一个输入框
|
|
||||||
useEffect(() => {
|
|
||||||
if (autoFocus && !disabled) {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
focusInput(0);
|
|
||||||
}, 100);
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}, [autoFocus, disabled, focusInput]);
|
|
||||||
|
|
||||||
// 当值改变时,自动聚焦到下一个输入框
|
|
||||||
useEffect(() => {
|
|
||||||
if (value.length < length) {
|
|
||||||
focusInput(value.length);
|
|
||||||
}
|
|
||||||
// 当输入完成时触发回调
|
|
||||||
if (value.length === length && onComplete) {
|
|
||||||
onComplete(value);
|
|
||||||
}
|
|
||||||
}, [value, length, onComplete, focusInput]);
|
|
||||||
|
|
||||||
// 处理文本变化
|
|
||||||
const handleChangeText = (text: string, index: number) => {
|
|
||||||
if (disabled) return;
|
|
||||||
|
|
||||||
// 只允许数字
|
|
||||||
const numericText = text.replace(/[^0-9]/g, '');
|
|
||||||
|
|
||||||
if (numericText.length === 0) {
|
|
||||||
// 删除操作
|
|
||||||
if (value.length > index) {
|
|
||||||
// 删除当前位置的字符
|
|
||||||
const newValue = value.slice(0, index) + value.slice(index + 1);
|
|
||||||
onChangeValue(newValue);
|
|
||||||
// 聚焦到前一个输入框
|
|
||||||
if (index > 0) {
|
|
||||||
focusInput(index - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (numericText.length === 1) {
|
|
||||||
// 单个字符输入
|
|
||||||
let newValue: string;
|
|
||||||
if (index < value.length) {
|
|
||||||
// 替换已有字符
|
|
||||||
newValue = value.slice(0, index) + numericText + value.slice(index + 1);
|
|
||||||
} else {
|
|
||||||
// 追加新字符
|
|
||||||
newValue = value + numericText;
|
|
||||||
}
|
|
||||||
onChangeValue(newValue.slice(0, length));
|
|
||||||
// 自动聚焦到下一个
|
|
||||||
if (index < length - 1) {
|
|
||||||
focusInput(index + 1);
|
|
||||||
}
|
|
||||||
} else if (numericText.length > 1) {
|
|
||||||
// 粘贴操作:尝试将粘贴的内容作为完整验证码
|
|
||||||
const newValue = numericText.slice(0, length);
|
|
||||||
onChangeValue(newValue);
|
|
||||||
// 聚焦到最后一个或下一个
|
|
||||||
const focusIndex = Math.min(newValue.length, length - 1);
|
|
||||||
focusInput(focusIndex);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 处理按键事件(用于删除键)
|
|
||||||
const handleKeyPress = (e: any, index: number) => {
|
|
||||||
if (disabled) return;
|
|
||||||
|
|
||||||
if (e.nativeEvent.key === 'Backspace') {
|
|
||||||
if (value.length > index) {
|
|
||||||
// 删除当前位置的字符
|
|
||||||
const newValue = value.slice(0, index) + value.slice(index + 1);
|
|
||||||
onChangeValue(newValue);
|
|
||||||
} else if (index > 0 && value.length === index) {
|
|
||||||
// 当前位置没有字符,删除前一个
|
|
||||||
const newValue = value.slice(0, -1);
|
|
||||||
onChangeValue(newValue);
|
|
||||||
focusInput(index - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 聚焦时全选文本
|
|
||||||
const handleFocus = (index: number) => {
|
|
||||||
if (disabled) return;
|
|
||||||
inputRefs.current[index]?.setNativeProps({
|
|
||||||
selection: { start: 0, end: 1 },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={[styles.container, containerStyle]}>
|
|
||||||
<View style={styles.cellsContainer}>
|
|
||||||
{chars.map((char, index) => {
|
|
||||||
const isFocused = value.length === index;
|
|
||||||
const isFilled = char !== '';
|
|
||||||
const isLastFilled = index === value.length - 1 && isFilled;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={index}
|
|
||||||
style={[
|
|
||||||
styles.cell,
|
|
||||||
isFocused && styles.cellFocused,
|
|
||||||
isFilled && styles.cellFilled,
|
|
||||||
disabled && styles.cellDisabled,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<TextInput
|
|
||||||
ref={(ref) => {
|
|
||||||
inputRefs.current[index] = ref;
|
|
||||||
}}
|
|
||||||
style={styles.cellInput}
|
|
||||||
value={char}
|
|
||||||
onChangeText={(text) => handleChangeText(text, index)}
|
|
||||||
onKeyPress={(e) => handleKeyPress(e, index)}
|
|
||||||
onFocus={() => handleFocus(index)}
|
|
||||||
keyboardType="number-pad"
|
|
||||||
maxLength={1}
|
|
||||||
selectTextOnFocus
|
|
||||||
editable={!disabled}
|
|
||||||
caretHidden={true}
|
|
||||||
textContentType="oneTimeCode" // iOS 自动填充支持
|
|
||||||
/>
|
|
||||||
{/* 光标指示器 */}
|
|
||||||
{isFocused && !isFilled && !disabled && (
|
|
||||||
<View style={styles.cursor} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function createStyles(colors: AppColors) {
|
|
||||||
return StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
cellsContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
cell: {
|
|
||||||
width: CELL_WIDTH,
|
|
||||||
height: CELL_HEIGHT,
|
|
||||||
borderRadius: 12,
|
|
||||||
borderWidth: 1.5,
|
|
||||||
borderColor: colors.divider,
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
marginHorizontal: CELL_MARGIN / 2,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
position: 'relative',
|
|
||||||
},
|
|
||||||
cellFocused: {
|
|
||||||
borderColor: colors.primary.main,
|
|
||||||
backgroundColor: `${colors.primary.main}10`,
|
|
||||||
},
|
|
||||||
cellFilled: {
|
|
||||||
borderColor: colors.primary.main,
|
|
||||||
},
|
|
||||||
cellDisabled: {
|
|
||||||
opacity: 0.5,
|
|
||||||
backgroundColor: colors.background.disabled,
|
|
||||||
},
|
|
||||||
cellInput: {
|
|
||||||
width: '100%',
|
|
||||||
height: '100%',
|
|
||||||
textAlign: 'center',
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.text.primary,
|
|
||||||
padding: 0,
|
|
||||||
},
|
|
||||||
cursor: {
|
|
||||||
position: 'absolute',
|
|
||||||
width: 2,
|
|
||||||
height: 28,
|
|
||||||
backgroundColor: colors.primary.main,
|
|
||||||
borderRadius: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default VerificationCodeInput;
|
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
* 全屏模态播放视频,基于 expo-video
|
* 全屏模态播放视频,基于 expo-video
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback, useEffect } from 'react';
|
import React, { useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
View,
|
View,
|
||||||
@@ -12,13 +12,11 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Text,
|
Text,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
Platform,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { VideoView, useVideoPlayer } from 'expo-video';
|
import { VideoView, useVideoPlayer } from 'expo-video';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { spacing, fontSizes, borderRadius } from '../../theme';
|
import { spacing, fontSizes, borderRadius } from '../../theme';
|
||||||
import { blurActiveElement } from '../../infrastructure/platform';
|
|
||||||
|
|
||||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
|
||||||
@@ -81,12 +79,6 @@ export const VideoPlayerModal: React.FC<VideoPlayerModalProps> = ({
|
|||||||
url,
|
url,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
useEffect(() => {
|
|
||||||
if (visible) {
|
|
||||||
blurActiveElement();
|
|
||||||
}
|
|
||||||
}, [visible]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
visible={visible}
|
visible={visible}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export { default as ResponsiveGrid } from './ResponsiveGrid';
|
|||||||
export { default as ResponsiveStack, HStack, VStack } from './ResponsiveStack';
|
export { default as ResponsiveStack, HStack, VStack } from './ResponsiveStack';
|
||||||
export { default as AdaptiveLayout, SidebarLayout } from './AdaptiveLayout';
|
export { default as AdaptiveLayout, SidebarLayout } from './AdaptiveLayout';
|
||||||
export { default as AppBackButton } from './AppBackButton';
|
export { default as AppBackButton } from './AppBackButton';
|
||||||
export { default as PagerView } from './PagerView';
|
|
||||||
|
|
||||||
// 图片相关组件
|
// 图片相关组件
|
||||||
export { default as SmartImage } from './SmartImage';
|
export { default as SmartImage } from './SmartImage';
|
||||||
@@ -33,8 +32,3 @@ export type { ImageGalleryProps, GalleryImageItem } from './ImageGallery';
|
|||||||
export type { ResponsiveGridProps } from './ResponsiveGrid';
|
export type { ResponsiveGridProps } from './ResponsiveGrid';
|
||||||
export type { ResponsiveStackProps, StackDirection, StackAlignment, StackJustify } from './ResponsiveStack';
|
export type { ResponsiveStackProps, StackDirection, StackAlignment, StackJustify } from './ResponsiveStack';
|
||||||
export type { AdaptiveLayoutProps, SidebarLayoutProps } from './AdaptiveLayout';
|
export type { AdaptiveLayoutProps, SidebarLayoutProps } from './AdaptiveLayout';
|
||||||
|
|
||||||
// 注册流程相关组件
|
|
||||||
export { default as VerificationCodeInput } from './VerificationCodeInput';
|
|
||||||
export { default as StepIndicator, RegisterStepIndicator } from './StepIndicator';
|
|
||||||
export type { Step, StepIndicatorProps } from './StepIndicator';
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
* 纯业务逻辑,无UI依赖
|
* 纯业务逻辑,无UI依赖
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { messageRepository, userCacheRepository, CachedMessage } from '@/database';
|
import { messageRepository } from '../../data/repositories/MessageRepository';
|
||||||
import { wsClient, WSEventType } from '../../data/datasources/WSClient';
|
import { sseClient, SSEEventType } from '../../data/datasources/SSEClient';
|
||||||
import {
|
import {
|
||||||
Message,
|
Message,
|
||||||
Conversation,
|
Conversation,
|
||||||
@@ -18,16 +18,6 @@ import {
|
|||||||
import { messageService } from '../../services/messageService';
|
import { messageService } from '../../services/messageService';
|
||||||
import { api } from '../../services/api';
|
import { api } from '../../services/api';
|
||||||
|
|
||||||
const cachedMessageToMessage = (cached: CachedMessage): Message => ({
|
|
||||||
id: cached.id,
|
|
||||||
conversationId: cached.conversationId,
|
|
||||||
senderId: cached.senderId,
|
|
||||||
seq: cached.seq,
|
|
||||||
segments: cached.segments || [],
|
|
||||||
createdAt: cached.createdAt,
|
|
||||||
status: cached.status as Message['status'],
|
|
||||||
});
|
|
||||||
|
|
||||||
// 事件类型定义
|
// 事件类型定义
|
||||||
export type MessageUseCaseEventType =
|
export type MessageUseCaseEventType =
|
||||||
| 'message_received'
|
| 'message_received'
|
||||||
@@ -72,7 +62,7 @@ class ProcessMessageUseCase {
|
|||||||
*/
|
*/
|
||||||
initialize(currentUserId: string | null): void {
|
initialize(currentUserId: string | null): void {
|
||||||
this.currentUserId = currentUserId;
|
this.currentUserId = currentUserId;
|
||||||
this.setupWSListeners();
|
this.setupSSEListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -91,49 +81,49 @@ class ProcessMessageUseCase {
|
|||||||
/**
|
/**
|
||||||
* 设置SSE监听器
|
* 设置SSE监听器
|
||||||
*/
|
*/
|
||||||
private setupWSListeners(): void {
|
private setupSSEListeners(): void {
|
||||||
// 监听私聊消息
|
// 监听私聊消息
|
||||||
const unsubChat = wsClient.on('chat', (message) => {
|
const unsubChat = sseClient.on('chat', (message) => {
|
||||||
this.handleNewMessage(message);
|
this.handleNewMessage(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊消息
|
// 监听群聊消息
|
||||||
const unsubGroupMessage = wsClient.on('group_message', (message) => {
|
const unsubGroupMessage = sseClient.on('group_message', (message) => {
|
||||||
this.handleNewMessage(message);
|
this.handleNewMessage(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听私聊已读回执
|
// 监听私聊已读回执
|
||||||
const unsubRead = wsClient.on('read', (message) => {
|
const unsubRead = sseClient.on('read', (message) => {
|
||||||
this.handleReadReceipt(message);
|
this.handleReadReceipt(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊已读回执
|
// 监听群聊已读回执
|
||||||
const unsubGroupRead = wsClient.on('group_read', (message) => {
|
const unsubGroupRead = sseClient.on('group_read', (message) => {
|
||||||
this.handleGroupReadReceipt(message);
|
this.handleGroupReadReceipt(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听私聊消息撤回
|
// 监听私聊消息撤回
|
||||||
const unsubRecall = wsClient.on('recall', (message) => {
|
const unsubRecall = sseClient.on('recall', (message) => {
|
||||||
this.handleRecallMessage(message);
|
this.handleRecallMessage(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊消息撤回
|
// 监听群聊消息撤回
|
||||||
const unsubGroupRecall = wsClient.on('group_recall', (message) => {
|
const unsubGroupRecall = sseClient.on('group_recall', (message) => {
|
||||||
this.handleGroupRecallMessage(message);
|
this.handleGroupRecallMessage(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊输入状态
|
// 监听群聊输入状态
|
||||||
const unsubGroupTyping = wsClient.on('group_typing', (message) => {
|
const unsubGroupTyping = sseClient.on('group_typing', (message) => {
|
||||||
this.handleGroupTyping(message);
|
this.handleGroupTyping(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群通知
|
// 监听群通知
|
||||||
const unsubGroupNotice = wsClient.on('group_notice', (message) => {
|
const unsubGroupNotice = sseClient.on('group_notice', (message) => {
|
||||||
this.handleGroupNotice(message);
|
this.handleGroupNotice(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听连接状态
|
// 监听连接状态
|
||||||
const unsubConnected = wsClient.on('connected', () => {
|
const unsubConnected = sseClient.on('connected', () => {
|
||||||
this.notifySubscribers({
|
this.notifySubscribers({
|
||||||
type: 'connection_changed',
|
type: 'connection_changed',
|
||||||
payload: { connected: true },
|
payload: { connected: true },
|
||||||
@@ -141,7 +131,7 @@ class ProcessMessageUseCase {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubDisconnected = wsClient.on('disconnected', () => {
|
const unsubDisconnected = sseClient.on('disconnected', () => {
|
||||||
this.notifySubscribers({
|
this.notifySubscribers({
|
||||||
type: 'connection_changed',
|
type: 'connection_changed',
|
||||||
payload: { connected: false },
|
payload: { connected: false },
|
||||||
@@ -192,16 +182,7 @@ class ProcessMessageUseCase {
|
|||||||
|
|
||||||
// 异步保存到本地数据库
|
// 异步保存到本地数据库
|
||||||
const isRead = _isAck || sender_id === this.currentUserId;
|
const isRead = _isAck || sender_id === this.currentUserId;
|
||||||
messageRepository.saveMessage({
|
messageRepository.saveMessage(newMessage, isRead).catch((error) => {
|
||||||
id: newMessage.id,
|
|
||||||
conversationId: newMessage.conversationId,
|
|
||||||
senderId: newMessage.senderId,
|
|
||||||
seq: newMessage.seq,
|
|
||||||
segments: newMessage.segments,
|
|
||||||
createdAt: newMessage.createdAt,
|
|
||||||
status: newMessage.status || 'normal',
|
|
||||||
isRead,
|
|
||||||
}).catch((error) => {
|
|
||||||
console.error('[ProcessMessageUseCase] 保存消息失败:', error);
|
console.error('[ProcessMessageUseCase] 保存消息失败:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -246,7 +227,7 @@ class ProcessMessageUseCase {
|
|||||||
*/
|
*/
|
||||||
private async getSenderInfo(userId: string): Promise<any | null> {
|
private async getSenderInfo(userId: string): Promise<any | null> {
|
||||||
// 先检查本地缓存
|
// 先检查本地缓存
|
||||||
const cachedUser = await userCacheRepository.get(userId);
|
const cachedUser = await messageRepository.getUserCache(userId);
|
||||||
if (cachedUser) {
|
if (cachedUser) {
|
||||||
return cachedUser;
|
return cachedUser;
|
||||||
}
|
}
|
||||||
@@ -274,9 +255,9 @@ class ProcessMessageUseCase {
|
|||||||
*/
|
*/
|
||||||
private async fetchUserInfo(userId: string): Promise<any | null> {
|
private async fetchUserInfo(userId: string): Promise<any | null> {
|
||||||
try {
|
try {
|
||||||
const response = await api.get<any>(`/users/${userId}`);
|
const response = await api.get(`/users/${userId}`);
|
||||||
if (response.code === 0 && response.data) {
|
if (response.code === 0 && response.data) {
|
||||||
await userCacheRepository.save(response.data as any);
|
await messageRepository.saveUserCache(response.data);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -341,8 +322,8 @@ class ProcessMessageUseCase {
|
|||||||
|
|
||||||
// 更新本地数据库状态
|
// 更新本地数据库状态
|
||||||
messageRepository
|
messageRepository
|
||||||
.updateStatus(message_id, 'recalled', true)
|
.updateMessageStatus(message_id, 'recalled', true)
|
||||||
.catch((error: unknown) => {
|
.catch((error) => {
|
||||||
console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error);
|
console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -368,8 +349,8 @@ class ProcessMessageUseCase {
|
|||||||
|
|
||||||
// 更新本地数据库状态
|
// 更新本地数据库状态
|
||||||
messageRepository
|
messageRepository
|
||||||
.updateStatus(message_id, 'recalled', true)
|
.updateMessageStatus(message_id, 'recalled', true)
|
||||||
.catch((error: unknown) => {
|
.catch((error) => {
|
||||||
console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error);
|
console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -525,16 +506,7 @@ class ProcessMessageUseCase {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// 保存到本地数据库
|
// 保存到本地数据库
|
||||||
await messageRepository.saveMessage({
|
await messageRepository.saveMessage(message, true);
|
||||||
id: message.id,
|
|
||||||
conversationId: message.conversationId,
|
|
||||||
senderId: message.senderId,
|
|
||||||
seq: message.seq,
|
|
||||||
segments: message.segments,
|
|
||||||
createdAt: message.createdAt,
|
|
||||||
status: message.status || 'normal',
|
|
||||||
isRead: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
@@ -550,8 +522,7 @@ class ProcessMessageUseCase {
|
|||||||
* 获取本地消息
|
* 获取本地消息
|
||||||
*/
|
*/
|
||||||
async getLocalMessages(conversationId: string, limit: number = 20): Promise<Message[]> {
|
async getLocalMessages(conversationId: string, limit: number = 20): Promise<Message[]> {
|
||||||
const cached = await messageRepository.getByConversation(conversationId, limit);
|
return messageRepository.getMessagesByConversation(conversationId, limit);
|
||||||
return cached.map(cachedMessageToMessage);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -563,14 +534,14 @@ class ProcessMessageUseCase {
|
|||||||
limit: number = 20
|
limit: number = 20
|
||||||
): Promise<Message[]> {
|
): Promise<Message[]> {
|
||||||
// 先从本地获取
|
// 先从本地获取
|
||||||
const localMessages = await messageRepository.getBeforeSeq(
|
const localMessages = await messageRepository.getMessagesBeforeSeq(
|
||||||
conversationId,
|
conversationId,
|
||||||
beforeSeq,
|
beforeSeq,
|
||||||
limit
|
limit
|
||||||
);
|
);
|
||||||
|
|
||||||
if (localMessages.length >= limit) {
|
if (localMessages.length >= limit) {
|
||||||
return localMessages.map(cachedMessageToMessage);
|
return localMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 本地数据不足,从服务端获取
|
// 本地数据不足,从服务端获取
|
||||||
@@ -596,16 +567,7 @@ class ProcessMessageUseCase {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 保存到本地
|
// 保存到本地
|
||||||
await messageRepository.saveMessagesBatch(serverMessages.map((m: any) => ({
|
await messageRepository.saveMessages(serverMessages, false);
|
||||||
id: m.id,
|
|
||||||
conversationId: m.conversation_id || conversationId,
|
|
||||||
senderId: m.sender_id,
|
|
||||||
seq: m.seq,
|
|
||||||
segments: m.segments || [],
|
|
||||||
createdAt: m.created_at,
|
|
||||||
status: m.status || 'normal',
|
|
||||||
isRead: false,
|
|
||||||
})));
|
|
||||||
|
|
||||||
return serverMessages;
|
return serverMessages;
|
||||||
}
|
}
|
||||||
@@ -613,7 +575,7 @@ class ProcessMessageUseCase {
|
|||||||
console.error('[ProcessMessageUseCase] 获取历史消息失败:', error);
|
console.error('[ProcessMessageUseCase] 获取历史消息失败:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
return localMessages.map(cachedMessageToMessage);
|
return localMessages;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -640,16 +602,7 @@ class ProcessMessageUseCase {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// 保存到本地
|
// 保存到本地
|
||||||
await messageRepository.saveMessagesBatch(messages.map((m: any) => ({
|
await messageRepository.saveMessages(messages, false);
|
||||||
id: m.id,
|
|
||||||
conversationId: m.conversation_id || conversationId,
|
|
||||||
senderId: m.sender_id,
|
|
||||||
seq: m.seq,
|
|
||||||
segments: m.segments || [],
|
|
||||||
createdAt: m.created_at,
|
|
||||||
status: m.status || 'normal',
|
|
||||||
isRead: false,
|
|
||||||
})));
|
|
||||||
|
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|||||||
323
src/data/datasources/LocalDataSource.ts
Normal file
323
src/data/datasources/LocalDataSource.ts
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
/**
|
||||||
|
* 本地数据库数据源实现
|
||||||
|
* 封装所有 SQLite 操作,提供统一的错误处理和事务支持
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as SQLite from 'expo-sqlite';
|
||||||
|
import { ILocalDataSource, DataSourceError } from './interfaces';
|
||||||
|
|
||||||
|
// 数据库实例管理
|
||||||
|
let dbInstance: SQLite.SQLiteDatabase | null = null;
|
||||||
|
let currentDbName: string | null = null;
|
||||||
|
let writeQueue: Promise<void> = Promise.resolve();
|
||||||
|
|
||||||
|
export interface LocalDataSourceConfig {
|
||||||
|
dbName?: string;
|
||||||
|
userId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LocalDataSource implements ILocalDataSource {
|
||||||
|
private db: SQLite.SQLiteDatabase | null = null;
|
||||||
|
private dbName: string;
|
||||||
|
private initialized = false;
|
||||||
|
private initializingPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
constructor(config: LocalDataSourceConfig = {}) {
|
||||||
|
// 如果提供了userId,使用用户专属数据库
|
||||||
|
this.dbName = config.dbName || (config.userId ? `carrot_bbs_${config.userId}.db` : 'carrot_bbs.db');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 初始化数据库连接
|
||||||
|
*/
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
if (this.initialized && this.db) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 防止并发初始化导致连接状态竞争
|
||||||
|
if (this.initializingPromise) {
|
||||||
|
await this.initializingPromise;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.initializingPromise = this.doInitialize();
|
||||||
|
try {
|
||||||
|
await this.initializingPromise;
|
||||||
|
} finally {
|
||||||
|
this.initializingPromise = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doInitialize(): Promise<void> {
|
||||||
|
try {
|
||||||
|
// 使用全局实例管理
|
||||||
|
if (dbInstance && currentDbName === this.dbName) {
|
||||||
|
this.db = dbInstance;
|
||||||
|
this.initialized = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 关闭旧连接
|
||||||
|
if (dbInstance) {
|
||||||
|
try {
|
||||||
|
await dbInstance.closeAsync();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('关闭旧数据库连接失败:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建新连接
|
||||||
|
this.db = await SQLite.openDatabaseAsync(this.dbName);
|
||||||
|
dbInstance = this.db;
|
||||||
|
currentDbName = this.dbName;
|
||||||
|
this.initialized = true;
|
||||||
|
|
||||||
|
// 初始化数据库表结构
|
||||||
|
await this.createTables();
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error, 'INITIALIZE');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建数据库表结构
|
||||||
|
*/
|
||||||
|
private async createTables(): Promise<void> {
|
||||||
|
if (!this.db) return;
|
||||||
|
|
||||||
|
const tables = [
|
||||||
|
// 消息表
|
||||||
|
`CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
conversationId TEXT NOT NULL,
|
||||||
|
senderId TEXT NOT NULL,
|
||||||
|
content TEXT,
|
||||||
|
type TEXT DEFAULT 'text',
|
||||||
|
isRead INTEGER DEFAULT 0,
|
||||||
|
createdAt TEXT NOT NULL,
|
||||||
|
seq INTEGER DEFAULT 0,
|
||||||
|
status TEXT DEFAULT 'normal',
|
||||||
|
segments TEXT
|
||||||
|
)`,
|
||||||
|
// 会话表
|
||||||
|
`CREATE TABLE IF NOT EXISTS conversations (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
participantId TEXT NOT NULL,
|
||||||
|
lastMessageId TEXT,
|
||||||
|
lastSeq INTEGER DEFAULT 0,
|
||||||
|
unreadCount INTEGER DEFAULT 0,
|
||||||
|
createdAt TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
// 会话缓存表
|
||||||
|
`CREATE TABLE IF NOT EXISTS conversation_cache (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
// 会话列表缓存表
|
||||||
|
`CREATE TABLE IF NOT EXISTS conversation_list_cache (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
// 用户缓存表
|
||||||
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
// 当前登录用户缓存
|
||||||
|
`CREATE TABLE IF NOT EXISTS current_user_cache (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
// 群组缓存表
|
||||||
|
`CREATE TABLE IF NOT EXISTS groups (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
// 群成员缓存表
|
||||||
|
`CREATE TABLE IF NOT EXISTS group_members (
|
||||||
|
groupId TEXT NOT NULL,
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (groupId, userId)
|
||||||
|
)`,
|
||||||
|
// 帖子缓存表
|
||||||
|
`CREATE TABLE IF NOT EXISTS posts_cache (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
updatedAt TEXT NOT NULL
|
||||||
|
)`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sql of tables) {
|
||||||
|
await this.db.execAsync(sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建索引
|
||||||
|
const indexes = [
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_messages_conversationId ON messages(conversationId)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_messages_conversation_seq ON messages(conversationId, seq)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_group_members_groupId ON group_members(groupId)`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const sql of indexes) {
|
||||||
|
await this.db.execAsync(sql);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理错误
|
||||||
|
*/
|
||||||
|
private handleError(error: unknown, operation: string): never {
|
||||||
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
throw new DataSourceError(
|
||||||
|
`Database ${operation} failed: ${message}`,
|
||||||
|
'DB_ERROR',
|
||||||
|
'LocalDataSource',
|
||||||
|
error instanceof Error ? error : undefined
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查数据库连接
|
||||||
|
*/
|
||||||
|
private ensureDb(): SQLite.SQLiteDatabase {
|
||||||
|
if (!this.db) {
|
||||||
|
throw new DataSourceError(
|
||||||
|
'Database not initialized',
|
||||||
|
'DB_NOT_INITIALIZED',
|
||||||
|
'LocalDataSource'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return this.db;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== ILocalDataSource 实现 ====================
|
||||||
|
|
||||||
|
async query<T>(sql: string, params?: any[]): Promise<T[]> {
|
||||||
|
try {
|
||||||
|
const db = this.ensureDb();
|
||||||
|
if (params && params.length > 0) {
|
||||||
|
return await db.getAllAsync<T>(sql, params as any);
|
||||||
|
}
|
||||||
|
return await db.getAllAsync<T>(sql);
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error, 'QUERY');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getFirst<T>(sql: string, params?: any[]): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
const db = this.ensureDb();
|
||||||
|
if (params && params.length > 0) {
|
||||||
|
return await db.getFirstAsync<T>(sql, params as any);
|
||||||
|
}
|
||||||
|
return await db.getFirstAsync<T>(sql);
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error, 'GET_FIRST');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(sql: string, params?: any[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = this.ensureDb();
|
||||||
|
await db.execAsync(sql);
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error, 'EXECUTE');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }> {
|
||||||
|
try {
|
||||||
|
const db = this.ensureDb();
|
||||||
|
if (params && params.length > 0) {
|
||||||
|
return await db.runAsync(sql, params as any);
|
||||||
|
}
|
||||||
|
return await db.runAsync(sql);
|
||||||
|
} catch (error) {
|
||||||
|
if (this.isRecoverableError(error)) {
|
||||||
|
this.db = null;
|
||||||
|
this.initialized = false;
|
||||||
|
dbInstance = null;
|
||||||
|
await this.initialize();
|
||||||
|
const db = this.ensureDb();
|
||||||
|
if (params && params.length > 0) {
|
||||||
|
return await db.runAsync(sql, params as any);
|
||||||
|
}
|
||||||
|
return await db.runAsync(sql);
|
||||||
|
}
|
||||||
|
this.handleError(error, 'RUN');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async transaction(operations: () => Promise<void>): Promise<void> {
|
||||||
|
try {
|
||||||
|
const db = this.ensureDb();
|
||||||
|
await db.withTransactionAsync(async () => {
|
||||||
|
await operations();
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error, 'TRANSACTION');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.transaction(async () => {
|
||||||
|
await operations(this);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.handleError(error, 'BATCH');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 队列写入支持 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使用队列执行写入操作(避免并发写入冲突)
|
||||||
|
*/
|
||||||
|
async enqueueWrite<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
const wrappedOperation = async () => {
|
||||||
|
try {
|
||||||
|
return await operation();
|
||||||
|
} catch (error) {
|
||||||
|
// 尝试重连后重试
|
||||||
|
if (this.isRecoverableError(error)) {
|
||||||
|
console.error('数据库写入异常,尝试重连后重试:', error);
|
||||||
|
this.db = null;
|
||||||
|
dbInstance = null;
|
||||||
|
await this.initialize();
|
||||||
|
return operation();
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const queued = writeQueue.then(wrappedOperation, wrappedOperation);
|
||||||
|
writeQueue = queued.then(() => undefined, () => undefined);
|
||||||
|
return queued;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断错误是否可恢复
|
||||||
|
*/
|
||||||
|
private isRecoverableError(error: unknown): boolean {
|
||||||
|
const message = String(error);
|
||||||
|
return (
|
||||||
|
message.includes('NativeDatabase.prepareAsync') ||
|
||||||
|
message.includes('NullPointerException') ||
|
||||||
|
message.includes('database is locked')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 导出单例实例
|
||||||
|
export const localDataSource = new LocalDataSource();
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* WSClient - WebSocket连接管理
|
* SSEClient - SSE连接管理
|
||||||
* 只负责WebSocket连接管理,提供事件驱动架构
|
* 只负责SSE连接管理,提供事件驱动架构
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
wsService,
|
sseService,
|
||||||
WSChatMessage,
|
WSChatMessage,
|
||||||
WSGroupChatMessage,
|
WSGroupChatMessage,
|
||||||
WSReadMessage,
|
WSReadMessage,
|
||||||
@@ -14,13 +14,13 @@ import {
|
|||||||
WSGroupTypingMessage,
|
WSGroupTypingMessage,
|
||||||
WSGroupNoticeMessage,
|
WSGroupNoticeMessage,
|
||||||
WSMessageType,
|
WSMessageType,
|
||||||
} from '../../services/wsService';
|
} from '../../services/sseService';
|
||||||
|
|
||||||
// 事件处理器类型
|
// 事件处理器类型
|
||||||
type EventHandler<T> = (data: T) => void;
|
type EventHandler<T> = (data: T) => void;
|
||||||
|
|
||||||
// WS事件类型
|
// SSE事件类型
|
||||||
export interface WSEvents {
|
export interface SSEEvents {
|
||||||
'chat': WSChatMessage;
|
'chat': WSChatMessage;
|
||||||
'group_message': WSGroupChatMessage;
|
'group_message': WSGroupChatMessage;
|
||||||
'read': WSReadMessage;
|
'read': WSReadMessage;
|
||||||
@@ -34,65 +34,65 @@ export interface WSEvents {
|
|||||||
'error': Error;
|
'error': Error;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type WSEventType = keyof WSEvents;
|
export type SSEEventType = keyof SSEEvents;
|
||||||
|
|
||||||
class WSClient {
|
class SSEClient {
|
||||||
private handlers: Map<WSEventType, Set<EventHandler<any>>> = new Map();
|
private handlers: Map<SSEEventType, Set<EventHandler<any>>> = new Map();
|
||||||
private unsubscribeFns: Array<() => void> = [];
|
private unsubscribeFns: Array<() => void> = [];
|
||||||
private isInitialized = false;
|
private isInitialized = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化WebSocket监听
|
* 初始化SSE监听
|
||||||
*/
|
*/
|
||||||
initialize(): void {
|
initialize(): void {
|
||||||
if (this.isInitialized) return;
|
if (this.isInitialized) return;
|
||||||
|
|
||||||
// 监听私聊消息
|
// 监听私聊消息
|
||||||
const unsubChat = wsService.on('chat', (message) => {
|
const unsubChat = sseService.on('chat', (message) => {
|
||||||
this.emit('chat', message);
|
this.emit('chat', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊消息
|
// 监听群聊消息
|
||||||
const unsubGroupMessage = wsService.on('group_message', (message) => {
|
const unsubGroupMessage = sseService.on('group_message', (message) => {
|
||||||
this.emit('group_message', message);
|
this.emit('group_message', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听私聊已读回执
|
// 监听私聊已读回执
|
||||||
const unsubRead = wsService.on('read', (message) => {
|
const unsubRead = sseService.on('read', (message) => {
|
||||||
this.emit('read', message);
|
this.emit('read', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊已读回执
|
// 监听群聊已读回执
|
||||||
const unsubGroupRead = wsService.on('group_read', (message) => {
|
const unsubGroupRead = sseService.on('group_read', (message) => {
|
||||||
this.emit('group_read', message);
|
this.emit('group_read', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听私聊消息撤回
|
// 监听私聊消息撤回
|
||||||
const unsubRecall = wsService.on('recall', (message) => {
|
const unsubRecall = sseService.on('recall', (message) => {
|
||||||
this.emit('recall', message);
|
this.emit('recall', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊消息撤回
|
// 监听群聊消息撤回
|
||||||
const unsubGroupRecall = wsService.on('group_recall', (message) => {
|
const unsubGroupRecall = sseService.on('group_recall', (message) => {
|
||||||
this.emit('group_recall', message);
|
this.emit('group_recall', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊输入状态
|
// 监听群聊输入状态
|
||||||
const unsubGroupTyping = wsService.on('group_typing', (message) => {
|
const unsubGroupTyping = sseService.on('group_typing', (message) => {
|
||||||
this.emit('group_typing', message);
|
this.emit('group_typing', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群通知
|
// 监听群通知
|
||||||
const unsubGroupNotice = wsService.on('group_notice', (message) => {
|
const unsubGroupNotice = sseService.on('group_notice', (message) => {
|
||||||
this.emit('group_notice', message);
|
this.emit('group_notice', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听连接状态
|
// 监听连接状态
|
||||||
const unsubConnect = wsService.onConnect(() => {
|
const unsubConnect = sseService.onConnect(() => {
|
||||||
this.emit('connected', undefined);
|
this.emit('connected', undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubDisconnect = wsService.onDisconnect(() => {
|
const unsubDisconnect = sseService.onDisconnect(() => {
|
||||||
this.emit('disconnected', undefined);
|
this.emit('disconnected', undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ class WSClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 销毁WebSocket监听
|
* 销毁SSE监听
|
||||||
*/
|
*/
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.unsubscribeFns.forEach((fn) => fn());
|
this.unsubscribeFns.forEach((fn) => fn());
|
||||||
@@ -125,9 +125,9 @@ class WSClient {
|
|||||||
/**
|
/**
|
||||||
* 订阅事件
|
* 订阅事件
|
||||||
*/
|
*/
|
||||||
on<T extends WSEventType>(
|
on<T extends SSEEventType>(
|
||||||
event: T,
|
event: T,
|
||||||
handler: EventHandler<WSEvents[T]>
|
handler: EventHandler<SSEEvents[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 WSClient {
|
|||||||
/**
|
/**
|
||||||
* 触发事件
|
* 触发事件
|
||||||
*/
|
*/
|
||||||
private emit<T extends WSEventType>(
|
private emit<T extends SSEEventType>(
|
||||||
event: T,
|
event: T,
|
||||||
data: WSEvents[T]
|
data: SSEEvents[T]
|
||||||
): void {
|
): void {
|
||||||
const handlers = this.handlers.get(event);
|
const handlers = this.handlers.get(event);
|
||||||
if (handlers) {
|
if (handlers) {
|
||||||
@@ -152,7 +152,7 @@ class WSClient {
|
|||||||
try {
|
try {
|
||||||
handler(data);
|
handler(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[WSClient] 事件处理器执行失败: ${event}`, error);
|
console.error(`[SSEClient] 事件处理器执行失败: ${event}`, error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -162,23 +162,23 @@ class WSClient {
|
|||||||
* 检查连接状态
|
* 检查连接状态
|
||||||
*/
|
*/
|
||||||
isConnected(): boolean {
|
isConnected(): boolean {
|
||||||
return wsService.isConnected();
|
return sseService.isConnected();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动WebSocket连接
|
* 启动SSE连接
|
||||||
*/
|
*/
|
||||||
async connect(): Promise<boolean> {
|
async connect(): Promise<boolean> {
|
||||||
return wsService.start();
|
return sseService.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 断开WebSocket连接
|
* 断开SSE连接
|
||||||
*/
|
*/
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
wsService.stop();
|
sseService.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const wsClient = new WSClient();
|
export const sseClient = new SSEClient();
|
||||||
export default wsClient;
|
export default sseClient;
|
||||||
@@ -5,5 +5,5 @@
|
|||||||
|
|
||||||
export * from './interfaces';
|
export * from './interfaces';
|
||||||
export * from './ApiDataSource';
|
export * from './ApiDataSource';
|
||||||
export { LocalDataSource, localDataSource } from '@/database';
|
export * from './LocalDataSource';
|
||||||
export * from './CacheDataSource';
|
export * from './CacheDataSource';
|
||||||
|
|||||||
@@ -20,10 +20,6 @@ export interface ILocalDataSource {
|
|||||||
run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }>;
|
run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }>;
|
||||||
transaction(operations: () => Promise<void>): Promise<void>;
|
transaction(operations: () => Promise<void>): Promise<void>;
|
||||||
batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void>;
|
batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void>;
|
||||||
enqueueWrite<T>(operation: () => Promise<T>): Promise<T>;
|
|
||||||
initialize(userId?: string): Promise<void>;
|
|
||||||
switchDatabase(userId?: string | null): Promise<void>;
|
|
||||||
closeDatabase(): Promise<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 缓存数据源接口
|
// 缓存数据源接口
|
||||||
|
|||||||
@@ -1,289 +0,0 @@
|
|||||||
/**
|
|
||||||
* 学习资料 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();
|
|
||||||
220
src/data/repositories/MessageRepository.ts
Normal file
220
src/data/repositories/MessageRepository.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
/**
|
||||||
|
* MessageRepository - 消息仓库实现
|
||||||
|
* 封装所有SQLite数据库操作,不依赖任何UI或状态管理
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
saveMessage as dbSaveMessage,
|
||||||
|
saveMessagesBatch as dbSaveMessagesBatch,
|
||||||
|
getMessagesByConversation as dbGetMessagesByConversation,
|
||||||
|
getMaxSeq as dbGetMaxSeq,
|
||||||
|
getMinSeq as dbGetMinSeq,
|
||||||
|
getMessagesBeforeSeq as dbGetMessagesBeforeSeq,
|
||||||
|
markConversationAsRead as dbMarkConversationAsRead,
|
||||||
|
updateConversationCacheUnreadCount as dbUpdateConversationCacheUnreadCount,
|
||||||
|
getUserCache as dbGetUserCache,
|
||||||
|
saveUserCache as dbSaveUserCache,
|
||||||
|
deleteConversation as dbDeleteConversation,
|
||||||
|
updateMessageStatus as dbUpdateMessageStatus,
|
||||||
|
CachedMessage,
|
||||||
|
} from '../../services/database';
|
||||||
|
import { Message, Conversation, createMessage, createConversation } from '../../core/entities/Message';
|
||||||
|
|
||||||
|
// 数据库消息到领域实体的转换
|
||||||
|
const cachedMessageToMessage = (cached: CachedMessage): Message => ({
|
||||||
|
id: cached.id,
|
||||||
|
conversationId: cached.conversationId,
|
||||||
|
senderId: cached.senderId,
|
||||||
|
seq: cached.seq,
|
||||||
|
segments: cached.segments || [],
|
||||||
|
createdAt: cached.createdAt,
|
||||||
|
status: cached.status as Message['status'],
|
||||||
|
});
|
||||||
|
|
||||||
|
// 领域实体到数据库消息的转换
|
||||||
|
const messageToCachedMessage = (message: Message, isRead: boolean): CachedMessage => ({
|
||||||
|
id: message.id,
|
||||||
|
conversationId: message.conversationId,
|
||||||
|
senderId: message.senderId,
|
||||||
|
content: message.segments
|
||||||
|
.filter((s) => s.type === 'text')
|
||||||
|
.map((s) => s.data?.text || '')
|
||||||
|
.join(''),
|
||||||
|
type: message.segments.find((s) => s.type === 'image') ? 'image' : 'text',
|
||||||
|
isRead,
|
||||||
|
createdAt: message.createdAt,
|
||||||
|
seq: message.seq,
|
||||||
|
status: message.status,
|
||||||
|
segments: message.segments,
|
||||||
|
});
|
||||||
|
|
||||||
|
class MessageRepository {
|
||||||
|
// ==================== 消息操作 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存单条消息
|
||||||
|
*/
|
||||||
|
async saveMessage(message: Message, isRead: boolean): Promise<void> {
|
||||||
|
try {
|
||||||
|
const cachedMessage = messageToCachedMessage(message, isRead);
|
||||||
|
await dbSaveMessage(cachedMessage);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MessageRepository] 保存消息失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量保存消息
|
||||||
|
*/
|
||||||
|
async saveMessages(messages: Message[], isRead: boolean): Promise<void> {
|
||||||
|
if (!messages || messages.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cachedMessages = messages.map((m) => messageToCachedMessage(m, isRead));
|
||||||
|
await dbSaveMessagesBatch(cachedMessages);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MessageRepository] 批量保存消息失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话的消息
|
||||||
|
*/
|
||||||
|
async getMessagesByConversation(
|
||||||
|
conversationId: string,
|
||||||
|
limit: number = 20
|
||||||
|
): Promise<Message[]> {
|
||||||
|
try {
|
||||||
|
const cachedMessages = await dbGetMessagesByConversation(conversationId, limit);
|
||||||
|
return cachedMessages.map(cachedMessageToMessage);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MessageRepository] 获取消息失败:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话的最大消息序号
|
||||||
|
*/
|
||||||
|
async getMaxSeq(conversationId: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
return await dbGetMaxSeq(conversationId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MessageRepository] 获取最大序号失败:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取会话的最小消息序号
|
||||||
|
*/
|
||||||
|
async getMinSeq(conversationId: string): Promise<number> {
|
||||||
|
try {
|
||||||
|
return await dbGetMinSeq(conversationId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MessageRepository] 获取最小序号失败:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定seq之前的历史消息
|
||||||
|
*/
|
||||||
|
async getMessagesBeforeSeq(
|
||||||
|
conversationId: string,
|
||||||
|
beforeSeq: number,
|
||||||
|
limit: number = 20
|
||||||
|
): Promise<Message[]> {
|
||||||
|
try {
|
||||||
|
const cachedMessages = await dbGetMessagesBeforeSeq(conversationId, beforeSeq, limit);
|
||||||
|
return cachedMessages.map(cachedMessageToMessage);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MessageRepository] 获取历史消息失败:', error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记会话的所有消息为已读
|
||||||
|
*/
|
||||||
|
async markConversationAsRead(conversationId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await dbMarkConversationAsRead(conversationId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MessageRepository] 标记会话已读失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新会话缓存的未读数
|
||||||
|
*/
|
||||||
|
async updateConversationUnreadCount(
|
||||||
|
conversationId: string,
|
||||||
|
count: number
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await dbUpdateConversationCacheUnreadCount(conversationId, count);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MessageRepository] 更新会话未读数失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新消息状态(如撤回)
|
||||||
|
*/
|
||||||
|
async updateMessageStatus(
|
||||||
|
messageId: string,
|
||||||
|
status: string,
|
||||||
|
clearContent: boolean = false
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
await dbUpdateMessageStatus(messageId, status, clearContent);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MessageRepository] 更新消息状态失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除会话及其所有消息
|
||||||
|
*/
|
||||||
|
async deleteConversation(conversationId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await dbDeleteConversation(conversationId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MessageRepository] 删除会话失败:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 用户缓存操作 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户缓存
|
||||||
|
*/
|
||||||
|
async getUserCache(userId: string): Promise<any | null> {
|
||||||
|
try {
|
||||||
|
return await dbGetUserCache(userId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MessageRepository] 获取用户缓存失败:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 保存用户缓存
|
||||||
|
*/
|
||||||
|
async saveUserCache(user: any): Promise<void> {
|
||||||
|
try {
|
||||||
|
await dbSaveUserCache(user);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[MessageRepository] 保存用户缓存失败:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const messageRepository = new MessageRepository();
|
||||||
|
export default messageRepository;
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ApiDataSource, apiDataSource } from '../datasources/ApiDataSource';
|
import { ApiDataSource, apiDataSource } from '../datasources/ApiDataSource';
|
||||||
import { localDataSource } from '@/database';
|
import { LocalDataSource, localDataSource } from '../datasources/LocalDataSource';
|
||||||
import { PostMapper } from '../mappers/PostMapper';
|
import { PostMapper } from '../mappers/PostMapper';
|
||||||
import type { Post, PostImage, PostStatus } from '../../core/entities/Post';
|
import type { Post, PostImage, PostStatus } from '../../core/entities/Post';
|
||||||
import { createPost } from '../../core/entities/Post';
|
import { createPost } from '../../core/entities/Post';
|
||||||
@@ -23,8 +23,8 @@ import type {
|
|||||||
* API帖子响应类型
|
* API帖子响应类型
|
||||||
*/
|
*/
|
||||||
interface PostApiResponse {
|
interface PostApiResponse {
|
||||||
id: string;
|
id: number;
|
||||||
user_id: string;
|
user_id: number;
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
images?: Array<{ url: string; width?: number; height?: number; thumbnail_url?: string }>;
|
images?: Array<{ url: string; width?: number; height?: number; thumbnail_url?: string }>;
|
||||||
@@ -44,7 +44,7 @@ interface PostApiResponse {
|
|||||||
content_edited_at?: string;
|
content_edited_at?: string;
|
||||||
channel?: { id: string; name: string } | null;
|
channel?: { id: string; name: string } | null;
|
||||||
author?: {
|
author?: {
|
||||||
id: string;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
@@ -79,12 +79,13 @@ interface CachedPost {
|
|||||||
|
|
||||||
export class PostRepository implements IPostRepository {
|
export class PostRepository implements IPostRepository {
|
||||||
private api: ApiDataSource;
|
private api: ApiDataSource;
|
||||||
private localDb = localDataSource;
|
private localDb: LocalDataSource;
|
||||||
private memoryCache: Map<string, { post: Post; timestamp: number }> = new Map();
|
private memoryCache: Map<string, { post: Post; timestamp: number }> = new Map();
|
||||||
private readonly CACHE_TTL = 5 * 60 * 1000;
|
private readonly CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存过期时间
|
||||||
|
|
||||||
constructor(api: ApiDataSource) {
|
constructor(api: ApiDataSource, localDb: LocalDataSource) {
|
||||||
this.api = api;
|
this.api = api;
|
||||||
|
this.localDb = localDb;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 私有辅助方法 ====================
|
// ==================== 私有辅助方法 ====================
|
||||||
@@ -179,6 +180,7 @@ export class PostRepository implements IPostRepository {
|
|||||||
*/
|
*/
|
||||||
private async getFromLocalCache(id: string): Promise<Post | null> {
|
private async getFromLocalCache(id: string): Promise<Post | null> {
|
||||||
try {
|
try {
|
||||||
|
await this.localDb.initialize();
|
||||||
const result = await this.localDb.getFirst<CachedPost>(
|
const result = await this.localDb.getFirst<CachedPost>(
|
||||||
'SELECT * FROM posts_cache WHERE id = ?',
|
'SELECT * FROM posts_cache WHERE id = ?',
|
||||||
[id]
|
[id]
|
||||||
@@ -199,6 +201,7 @@ export class PostRepository implements IPostRepository {
|
|||||||
*/
|
*/
|
||||||
private async saveToLocalCache(post: Post): Promise<void> {
|
private async saveToLocalCache(post: Post): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
await this.localDb.initialize();
|
||||||
await this.localDb.enqueueWrite(async () => {
|
await this.localDb.enqueueWrite(async () => {
|
||||||
await this.localDb.run(
|
await this.localDb.run(
|
||||||
`INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
|
`INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
|
||||||
@@ -215,6 +218,7 @@ export class PostRepository implements IPostRepository {
|
|||||||
*/
|
*/
|
||||||
private async clearLocalCache(id: string): Promise<void> {
|
private async clearLocalCache(id: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
await this.localDb.initialize();
|
||||||
await this.localDb.enqueueWrite(async () => {
|
await this.localDb.enqueueWrite(async () => {
|
||||||
await this.localDb.run('DELETE FROM posts_cache WHERE id = ?', [id]);
|
await this.localDb.run('DELETE FROM posts_cache WHERE id = ?', [id]);
|
||||||
});
|
});
|
||||||
@@ -551,6 +555,7 @@ export class PostRepository implements IPostRepository {
|
|||||||
async clearAllCache(): Promise<void> {
|
async clearAllCache(): Promise<void> {
|
||||||
this.memoryCache.clear();
|
this.memoryCache.clear();
|
||||||
try {
|
try {
|
||||||
|
await this.localDb.initialize();
|
||||||
await this.localDb.enqueueWrite(async () => {
|
await this.localDb.enqueueWrite(async () => {
|
||||||
await this.localDb.run('DELETE FROM posts_cache');
|
await this.localDb.run('DELETE FROM posts_cache');
|
||||||
});
|
});
|
||||||
@@ -570,5 +575,5 @@ export class PostRepository implements IPostRepository {
|
|||||||
|
|
||||||
// ==================== 单例导出 ====================
|
// ==================== 单例导出 ====================
|
||||||
|
|
||||||
export const postRepository = new PostRepository(apiDataSource);
|
export const postRepository = new PostRepository(apiDataSource, localDataSource);
|
||||||
export default postRepository;
|
export default postRepository;
|
||||||
|
|||||||
@@ -1,88 +0,0 @@
|
|||||||
import * as SQLite from 'expo-sqlite';
|
|
||||||
import type { ILocalDataSource } from '@/data/datasources/interfaces';
|
|
||||||
import { databaseManager } from './core/DatabaseManager';
|
|
||||||
|
|
||||||
class OperationQueue {
|
|
||||||
private queue: Promise<unknown> = Promise.resolve();
|
|
||||||
|
|
||||||
enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
|
||||||
const result = this.queue.then(() => operation(), () => operation());
|
|
||||||
this.queue = result.catch(() => {});
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
async drain(): Promise<void> {
|
|
||||||
await this.queue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const operationQueue = new OperationQueue();
|
|
||||||
|
|
||||||
export class LocalDataSource implements ILocalDataSource {
|
|
||||||
private async getDb(): Promise<SQLite.SQLiteDatabase> {
|
|
||||||
return databaseManager.getDb();
|
|
||||||
}
|
|
||||||
|
|
||||||
async initialize(userId?: string): Promise<void> {
|
|
||||||
if (databaseManager.isReady()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await operationQueue.enqueue(() => databaseManager.initialize(userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async query<T>(sql: string, params?: any[]): Promise<T[]> {
|
|
||||||
const db = await this.getDb();
|
|
||||||
if (params && params.length > 0) {
|
|
||||||
return db.getAllAsync<T>(sql, params as any);
|
|
||||||
}
|
|
||||||
return db.getAllAsync<T>(sql);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getFirst<T>(sql: string, params?: any[]): Promise<T | null> {
|
|
||||||
const db = await this.getDb();
|
|
||||||
if (params && params.length > 0) {
|
|
||||||
return db.getFirstAsync<T>(sql, params as any);
|
|
||||||
}
|
|
||||||
return db.getFirstAsync<T>(sql);
|
|
||||||
}
|
|
||||||
|
|
||||||
async execute(sql: string, params?: any[]): Promise<void> {
|
|
||||||
const db = await this.getDb();
|
|
||||||
await db.execAsync(sql);
|
|
||||||
}
|
|
||||||
|
|
||||||
async run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }> {
|
|
||||||
const db = await this.getDb();
|
|
||||||
if (params && params.length > 0) {
|
|
||||||
return db.runAsync(sql, params as any);
|
|
||||||
}
|
|
||||||
return db.runAsync(sql);
|
|
||||||
}
|
|
||||||
|
|
||||||
async transaction(operations: () => Promise<void>): Promise<void> {
|
|
||||||
const db = await this.getDb();
|
|
||||||
await db.withTransactionAsync(operations);
|
|
||||||
}
|
|
||||||
|
|
||||||
async batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void> {
|
|
||||||
await this.transaction(async () => {
|
|
||||||
await operations(this);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async enqueueWrite<T>(operation: () => Promise<T>): Promise<T> {
|
|
||||||
return operationQueue.enqueue(operation);
|
|
||||||
}
|
|
||||||
|
|
||||||
async switchDatabase(userId?: string | null): Promise<void> {
|
|
||||||
await operationQueue.drain();
|
|
||||||
await operationQueue.enqueue(() => databaseManager.initialize(userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
async closeDatabase(): Promise<void> {
|
|
||||||
await operationQueue.drain();
|
|
||||||
await operationQueue.enqueue(() => databaseManager.close());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const localDataSource = new LocalDataSource();
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
const DEFAULT_DB_NAME = 'carrot_bbs.db';
|
|
||||||
const DB_NAME_PREFIX = 'carrot_bbs_';
|
|
||||||
|
|
||||||
export function getDbName(userId: string | null): string {
|
|
||||||
return userId ? `${DB_NAME_PREFIX}${userId}.db` : DEFAULT_DB_NAME;
|
|
||||||
}
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import * as SQLite from 'expo-sqlite';
|
|
||||||
import { Platform } from 'react-native';
|
|
||||||
import { getDbName } from './DatabaseConfig';
|
|
||||||
import { runMigrations } from '../schema/MigrationManager';
|
|
||||||
|
|
||||||
type SQLiteDatabase = SQLite.SQLiteDatabase;
|
|
||||||
|
|
||||||
class DatabaseManager {
|
|
||||||
private static instance: DatabaseManager;
|
|
||||||
private db: SQLiteDatabase | null = null;
|
|
||||||
private currentUserId: string | null = null;
|
|
||||||
private initPromise: Promise<void> | null = null;
|
|
||||||
|
|
||||||
static getInstance(): DatabaseManager {
|
|
||||||
if (!DatabaseManager.instance) {
|
|
||||||
DatabaseManager.instance = new DatabaseManager();
|
|
||||||
}
|
|
||||||
return DatabaseManager.instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
async initialize(userId?: string | null): Promise<void> {
|
|
||||||
const targetUserId = userId || null;
|
|
||||||
|
|
||||||
if (this.db && this.currentUserId === targetUserId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.initPromise) {
|
|
||||||
await this.initPromise;
|
|
||||||
if (this.db && this.currentUserId === targetUserId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.initPromise = this.doInitialize(targetUserId);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.initPromise;
|
|
||||||
} finally {
|
|
||||||
this.initPromise = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async doInitialize(targetUserId: string | null): Promise<void> {
|
|
||||||
if (this.db) {
|
|
||||||
this.forceClose(this.db);
|
|
||||||
this.db = null;
|
|
||||||
this.currentUserId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dbName = getDbName(targetUserId);
|
|
||||||
const isWeb = Platform.OS === 'web';
|
|
||||||
|
|
||||||
if (isWeb) {
|
|
||||||
try {
|
|
||||||
const db = await this.tryOpenDatabase(dbName);
|
|
||||||
await runMigrations(db);
|
|
||||||
this.db = db;
|
|
||||||
this.currentUserId = targetUserId;
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[DatabaseManager] Web数据库打开失败,使用内存数据库:', error);
|
|
||||||
const db = await SQLite.openDatabaseAsync(':memory:');
|
|
||||||
await runMigrations(db);
|
|
||||||
this.db = db;
|
|
||||||
this.currentUserId = targetUserId;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = await this.tryOpenDatabase(dbName);
|
|
||||||
await runMigrations(db);
|
|
||||||
this.db = db;
|
|
||||||
this.currentUserId = targetUserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async tryOpenDatabase(dbName: string): Promise<SQLiteDatabase> {
|
|
||||||
try {
|
|
||||||
return await SQLite.openDatabaseAsync(dbName, {
|
|
||||||
useNewConnection: true,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
const msg = String(error);
|
|
||||||
if (msg.includes('cannot create file') || msg.includes('SQLITE_CANTOPEN') || msg.includes('database is locked')) {
|
|
||||||
return await SQLite.openDatabaseAsync(':memory:');
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private forceClose(db: SQLiteDatabase): void {
|
|
||||||
try {
|
|
||||||
db.closeSync();
|
|
||||||
} catch {
|
|
||||||
try {
|
|
||||||
db.closeAsync().catch(() => {});
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async close(): Promise<void> {
|
|
||||||
if (this.initPromise) {
|
|
||||||
await this.initPromise.catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.db) {
|
|
||||||
this.forceClose(this.db);
|
|
||||||
this.db = null;
|
|
||||||
this.currentUserId = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getDb(): Promise<SQLiteDatabase> {
|
|
||||||
if (this.db) return this.db;
|
|
||||||
|
|
||||||
if (this.initPromise) {
|
|
||||||
await this.initPromise;
|
|
||||||
if (this.db) return this.db;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error('数据库未初始化');
|
|
||||||
}
|
|
||||||
|
|
||||||
getDbSync(): SQLiteDatabase | null {
|
|
||||||
return this.db;
|
|
||||||
}
|
|
||||||
|
|
||||||
getCurrentUserId(): string | null {
|
|
||||||
return this.currentUserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
isReady(): boolean {
|
|
||||||
return this.db !== null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const databaseManager = DatabaseManager.getInstance();
|
|
||||||
export { DatabaseManager };
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
export class LockManager {
|
|
||||||
private static mutex: Promise<void> = Promise.resolve();
|
|
||||||
|
|
||||||
static async withMutex<T>(fn: () => Promise<T>): Promise<T> {
|
|
||||||
const prev = LockManager.mutex;
|
|
||||||
let release: () => void = () => {};
|
|
||||||
LockManager.mutex = new Promise<void>((r) => { release = r; });
|
|
||||||
try {
|
|
||||||
await prev;
|
|
||||||
return await fn();
|
|
||||||
} finally {
|
|
||||||
release();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static async withWebLock<T>(lockName: string, fn: () => Promise<T>): Promise<T> {
|
|
||||||
if (typeof navigator !== 'undefined' && navigator.locks) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
navigator.locks.request(lockName, { mode: 'exclusive' }, async (lock) => {
|
|
||||||
if (!lock) {
|
|
||||||
return reject(new Error('无法获取跨标签页锁'));
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const result = await fn();
|
|
||||||
resolve(result);
|
|
||||||
} catch (e) {
|
|
||||||
reject(e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return fn();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { localDataSource } from './LocalDataSource';
|
|
||||||
import { databaseManager } from './core/DatabaseManager';
|
|
||||||
|
|
||||||
export { databaseManager } from './core/DatabaseManager';
|
|
||||||
export { DatabaseManager } from './core/DatabaseManager';
|
|
||||||
export { localDataSource, LocalDataSource } from './LocalDataSource';
|
|
||||||
|
|
||||||
export {
|
|
||||||
messageRepository, MessageRepository,
|
|
||||||
conversationRepository, ConversationRepository,
|
|
||||||
userCacheRepository, UserCacheRepository,
|
|
||||||
groupCacheRepository, GroupCacheRepository,
|
|
||||||
postCacheRepository, PostCacheRepository,
|
|
||||||
} from './repositories';
|
|
||||||
|
|
||||||
export { CachedMessage } from './types';
|
|
||||||
|
|
||||||
export async function clearAllData(): Promise<void> {
|
|
||||||
await localDataSource.enqueueWrite(async () => {
|
|
||||||
await localDataSource.execute(`DELETE FROM messages`);
|
|
||||||
await localDataSource.execute(`DELETE FROM conversations`);
|
|
||||||
await localDataSource.execute(`DELETE FROM conversation_cache`);
|
|
||||||
await localDataSource.execute(`DELETE FROM conversation_list_cache`);
|
|
||||||
await localDataSource.execute(`DELETE FROM users`);
|
|
||||||
await localDataSource.execute(`DELETE FROM current_user_cache`);
|
|
||||||
await localDataSource.execute(`DELETE FROM groups`);
|
|
||||||
await localDataSource.execute(`DELETE FROM group_members`);
|
|
||||||
await localDataSource.execute(`DELETE FROM posts_cache`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const initDatabase = async (userId?: string | null): Promise<void> => {
|
|
||||||
await databaseManager.initialize(userId);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const switchDatabase = async (userId?: string | null): Promise<void> => {
|
|
||||||
await localDataSource.switchDatabase(userId);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const closeDatabase = async (): Promise<void> => {
|
|
||||||
await localDataSource.closeDatabase();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getCurrentDbUserId = (): string | null => {
|
|
||||||
return databaseManager.getCurrentUserId();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isDbInitialized = (): boolean => {
|
|
||||||
return databaseManager.isReady();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDbInstance = () => {
|
|
||||||
return databaseManager.getDbSync();
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getDb = async () => {
|
|
||||||
return databaseManager.getDb();
|
|
||||||
};
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
import { localDataSource } from '../LocalDataSource';
|
|
||||||
import type { ILocalDataSource } from '@/data/datasources/interfaces';
|
|
||||||
import type { ConversationResponse, ConversationDetailResponse } from '@/types/dto';
|
|
||||||
|
|
||||||
type ConversationCacheData = ConversationResponse | ConversationDetailResponse;
|
|
||||||
|
|
||||||
const safeParseJson = <T>(value: string): T | null => {
|
|
||||||
try { return JSON.parse(value) as T; } catch { return null; }
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface IConversationRepository {
|
|
||||||
save(conv: { id: string; participantId: string; lastMessageId?: string; lastSeq?: number; unreadCount?: number; createdAt: string; updatedAt: string }): Promise<void>;
|
|
||||||
getAll(): Promise<any[]>;
|
|
||||||
updateUnreadCount(id: string, count: number): Promise<void>;
|
|
||||||
updateLastMessage(id: string, lastMessageId: string, lastSeq: number, updatedAt: string): Promise<void>;
|
|
||||||
delete(conversationId: string): Promise<void>;
|
|
||||||
getCache(id: string): Promise<ConversationCacheData | null>;
|
|
||||||
getListCache(): Promise<ConversationResponse[]>;
|
|
||||||
saveCache(conv: ConversationCacheData): Promise<void>;
|
|
||||||
saveListCache(convs: ConversationResponse[]): Promise<void>;
|
|
||||||
updateListEntry(id: string, updates: Partial<ConversationResponse>): Promise<void>;
|
|
||||||
updateCacheUnreadCount(id: string, count: number, myLastReadSeq?: number): Promise<void>;
|
|
||||||
saveWithRelated(convs: ConversationResponse[], users?: any[], groups?: any[]): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ConversationRepository implements IConversationRepository {
|
|
||||||
constructor(private dataSource: ILocalDataSource = localDataSource) {}
|
|
||||||
|
|
||||||
async save(conv: { id: string; participantId: string; lastMessageId?: string; lastSeq?: number; unreadCount?: number; createdAt: string; updatedAt: string }): Promise<void> {
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
await this.dataSource.run(
|
|
||||||
`INSERT OR REPLACE INTO conversations (id, participantId, lastMessageId, lastSeq, unreadCount, createdAt, updatedAt)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
[conv.id, conv.participantId, conv.lastMessageId || null, conv.lastSeq || 0, conv.unreadCount || 0, conv.createdAt, conv.updatedAt]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAll(): Promise<any[]> {
|
|
||||||
return this.dataSource.query<any>(`SELECT * FROM conversations ORDER BY updatedAt DESC`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateUnreadCount(id: string, count: number): Promise<void> {
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
await this.dataSource.run(`UPDATE conversations SET unreadCount = ? WHERE id = ?`, [count, id]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateLastMessage(id: string, lastMessageId: string, lastSeq: number, updatedAt: string): Promise<void> {
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
await this.dataSource.run(
|
|
||||||
`UPDATE conversations SET lastMessageId = ?, lastSeq = ?, updatedAt = ? WHERE id = ?`,
|
|
||||||
[lastMessageId, lastSeq, updatedAt, id]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(conversationId: string): Promise<void> {
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
await this.dataSource.run(`DELETE FROM messages WHERE conversationId = ?`, [conversationId]);
|
|
||||||
await this.dataSource.run(`DELETE FROM conversations WHERE id = ?`, [conversationId]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCache(id: string): Promise<ConversationCacheData | null> {
|
|
||||||
const r = await this.dataSource.getFirst<{ data: string }>(
|
|
||||||
`SELECT data FROM conversation_cache WHERE id = ?`, [String(id)]
|
|
||||||
);
|
|
||||||
return r?.data ? safeParseJson<ConversationCacheData>(r.data) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getListCache(): Promise<ConversationResponse[]> {
|
|
||||||
let rows = await this.dataSource.query<{ data: string }>(
|
|
||||||
`SELECT data FROM conversation_list_cache ORDER BY updatedAt DESC`
|
|
||||||
);
|
|
||||||
if (!rows.length) {
|
|
||||||
rows = await this.dataSource.query<{ data: string }>(
|
|
||||||
`SELECT data FROM conversation_cache ORDER BY updatedAt DESC`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const list = rows.map(r => safeParseJson<ConversationResponse>(r.data))
|
|
||||||
.filter((c): c is ConversationResponse => Boolean(c))
|
|
||||||
.filter(c => typeof c.id !== 'undefined' && typeof c.type !== 'undefined');
|
|
||||||
return list.sort((a, b) => {
|
|
||||||
const aPinned = a.is_pinned ? 1 : 0;
|
|
||||||
const bPinned = b.is_pinned ? 1 : 0;
|
|
||||||
if (aPinned !== bPinned) return bPinned - aPinned;
|
|
||||||
const aTime = new Date(a.last_message_at || a.updated_at || 0).getTime();
|
|
||||||
const bTime = new Date(b.last_message_at || b.updated_at || 0).getTime();
|
|
||||||
return bTime - aTime;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveCache(conv: ConversationCacheData): Promise<void> {
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
const updatedAt = ('updated_at' in conv && conv.updated_at) ? String(conv.updated_at) : new Date().toISOString();
|
|
||||||
await this.dataSource.run(
|
|
||||||
`INSERT OR REPLACE INTO conversation_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
|
|
||||||
[String(conv.id), JSON.stringify(conv), updatedAt]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveListCache(convs: ConversationResponse[]): Promise<void> {
|
|
||||||
if (!convs?.length) return;
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
for (const c of convs) {
|
|
||||||
await this.dataSource.run(
|
|
||||||
`INSERT OR REPLACE INTO conversation_list_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
|
|
||||||
[String(c.id), JSON.stringify(c), c.updated_at || new Date().toISOString()]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateListEntry(id: string, updates: Partial<ConversationResponse>): Promise<void> {
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
const r = await this.dataSource.getFirst<{ data: string }>(
|
|
||||||
`SELECT data FROM conversation_list_cache WHERE id = ?`, [String(id)]
|
|
||||||
);
|
|
||||||
if (!r?.data) return;
|
|
||||||
const conv = safeParseJson<ConversationResponse>(r.data);
|
|
||||||
if (!conv) return;
|
|
||||||
const updated = { ...conv, ...updates };
|
|
||||||
const updatedAt = updates.last_message_at || updated.last_message_at || new Date().toISOString();
|
|
||||||
await this.dataSource.run(
|
|
||||||
`UPDATE conversation_list_cache SET data = ?, updatedAt = ? WHERE id = ?`,
|
|
||||||
[JSON.stringify(updated), updatedAt, String(id)]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateCacheUnreadCount(id: string, count: number, myLastReadSeq?: number): Promise<void> {
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
const listRow = await this.dataSource.getFirst<{ data: string }>(
|
|
||||||
`SELECT data FROM conversation_list_cache WHERE id = ?`, [String(id)]
|
|
||||||
);
|
|
||||||
if (listRow?.data) {
|
|
||||||
const conv = safeParseJson<ConversationResponse>(listRow.data);
|
|
||||||
if (conv) {
|
|
||||||
conv.unread_count = count;
|
|
||||||
if (typeof myLastReadSeq === 'number' && !Number.isNaN(myLastReadSeq)) conv.my_last_read_seq = myLastReadSeq;
|
|
||||||
await this.dataSource.run(
|
|
||||||
`UPDATE conversation_list_cache SET data = ? WHERE id = ?`,
|
|
||||||
[JSON.stringify(conv), String(id)]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheRow = await this.dataSource.getFirst<{ data: string }>(
|
|
||||||
`SELECT data FROM conversation_cache WHERE id = ?`, [String(id)]
|
|
||||||
);
|
|
||||||
if (cacheRow?.data) {
|
|
||||||
const conv = safeParseJson<Record<string, unknown>>(cacheRow.data);
|
|
||||||
if (conv) {
|
|
||||||
conv.unread_count = count;
|
|
||||||
if (typeof myLastReadSeq === 'number' && !Number.isNaN(myLastReadSeq)) conv.my_last_read_seq = myLastReadSeq;
|
|
||||||
await this.dataSource.run(
|
|
||||||
`UPDATE conversation_cache SET data = ? WHERE id = ?`,
|
|
||||||
[JSON.stringify(conv), String(id)]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveWithRelated(convs: ConversationResponse[], users?: any[], groups?: any[]): Promise<void> {
|
|
||||||
if (!convs?.length) return;
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
for (const c of convs) {
|
|
||||||
await this.dataSource.run(
|
|
||||||
`INSERT OR REPLACE INTO conversation_list_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
|
|
||||||
[String(c.id), JSON.stringify(c), c.updated_at || now]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (users?.length) {
|
|
||||||
for (const u of users) {
|
|
||||||
await this.dataSource.run(
|
|
||||||
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
|
|
||||||
[String(u.id), JSON.stringify(u), now]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (groups?.length) {
|
|
||||||
for (const g of groups) {
|
|
||||||
await this.dataSource.run(
|
|
||||||
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
|
|
||||||
[String(g.id), JSON.stringify(g), now]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const conversationRepository = new ConversationRepository();
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import { localDataSource } from '../LocalDataSource';
|
|
||||||
import type { ILocalDataSource } from '@/data/datasources/interfaces';
|
|
||||||
import type { GroupResponse, GroupMemberResponse } from '@/types/dto';
|
|
||||||
|
|
||||||
const safeParseJson = <T>(value: string): T | null => {
|
|
||||||
try { return JSON.parse(value) as T; } catch { return null; }
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface IGroupCacheRepository {
|
|
||||||
save(group: GroupResponse): Promise<void>;
|
|
||||||
saveBatch(groups: GroupResponse[]): Promise<void>;
|
|
||||||
get(groupId: string): Promise<GroupResponse | null>;
|
|
||||||
getAll(): Promise<GroupResponse[]>;
|
|
||||||
saveMembers(groupId: string, members: GroupMemberResponse[]): Promise<void>;
|
|
||||||
getMembers(groupId: string): Promise<GroupMemberResponse[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GroupCacheRepository implements IGroupCacheRepository {
|
|
||||||
constructor(private dataSource: ILocalDataSource = localDataSource) {}
|
|
||||||
|
|
||||||
async save(group: GroupResponse): Promise<void> {
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
await this.dataSource.run(
|
|
||||||
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
|
|
||||||
[String(group.id), JSON.stringify(group), new Date().toISOString()]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveBatch(groups: GroupResponse[]): Promise<void> {
|
|
||||||
if (!groups?.length) return;
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
for (const g of groups) {
|
|
||||||
await this.dataSource.run(
|
|
||||||
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
|
|
||||||
[String(g.id), JSON.stringify(g), now]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async get(groupId: string): Promise<GroupResponse | null> {
|
|
||||||
const r = await this.dataSource.getFirst<{ data: string }>(
|
|
||||||
`SELECT data FROM groups WHERE id = ?`, [String(groupId)]
|
|
||||||
);
|
|
||||||
return r?.data ? safeParseJson<GroupResponse>(r.data) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAll(): Promise<GroupResponse[]> {
|
|
||||||
const rows = await this.dataSource.query<{ data: string }>(
|
|
||||||
`SELECT data FROM groups ORDER BY updatedAt DESC`
|
|
||||||
);
|
|
||||||
return rows.map(r => safeParseJson<GroupResponse>(r.data)).filter((g): g is GroupResponse => Boolean(g));
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveMembers(groupId: string, members: GroupMemberResponse[]): Promise<void> {
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
await this.dataSource.run(`DELETE FROM group_members WHERE groupId = ?`, [String(groupId)]);
|
|
||||||
for (const m of members) {
|
|
||||||
await this.dataSource.run(
|
|
||||||
`INSERT OR REPLACE INTO group_members (groupId, userId, data, updatedAt) VALUES (?, ?, ?, ?)`,
|
|
||||||
[String(groupId), String(m.user_id), JSON.stringify(m), now]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getMembers(groupId: string): Promise<GroupMemberResponse[]> {
|
|
||||||
const rows = await this.dataSource.query<{ data: string }>(
|
|
||||||
`SELECT data FROM group_members WHERE groupId = ? ORDER BY updatedAt DESC`, [String(groupId)]
|
|
||||||
);
|
|
||||||
return rows.map(r => safeParseJson<GroupMemberResponse>(r.data)).filter((m): m is GroupMemberResponse => Boolean(m));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const groupCacheRepository = new GroupCacheRepository();
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
import { localDataSource } from '../LocalDataSource';
|
|
||||||
import { CachedMessage } from '../types';
|
|
||||||
import type { ILocalDataSource } from '@/data/datasources/interfaces';
|
|
||||||
|
|
||||||
export interface IMessageRepository {
|
|
||||||
saveMessage(message: CachedMessage): Promise<void>;
|
|
||||||
saveMessagesBatch(messages: CachedMessage[]): Promise<void>;
|
|
||||||
getByConversation(conversationId: string, limit?: number): Promise<CachedMessage[]>;
|
|
||||||
getMaxSeq(conversationId: string): Promise<number>;
|
|
||||||
getMinSeq(conversationId: string): Promise<number>;
|
|
||||||
getBeforeSeq(conversationId: string, beforeSeq: number, limit?: number): Promise<CachedMessage[]>;
|
|
||||||
getCount(conversationId: string): Promise<number>;
|
|
||||||
getCountBeforeSeq(conversationId: string, beforeSeq: number): Promise<number>;
|
|
||||||
markAsRead(messageId: string): Promise<void>;
|
|
||||||
markConversationAsRead(conversationId: string): Promise<void>;
|
|
||||||
delete(messageId: string): Promise<void>;
|
|
||||||
updateStatus(messageId: string, status: string, clearContent?: boolean): Promise<void>;
|
|
||||||
clearConversation(conversationId: string): Promise<void>;
|
|
||||||
search(keyword: string): Promise<CachedMessage[]>;
|
|
||||||
getStats(): Promise<{ totalMessages: number; totalConversations: number }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MessageRepository implements IMessageRepository {
|
|
||||||
constructor(private dataSource: ILocalDataSource = localDataSource) {}
|
|
||||||
|
|
||||||
async saveMessage(message: CachedMessage): Promise<void> {
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
await this.dataSource.run(
|
|
||||||
`INSERT OR REPLACE INTO messages (id, conversationId, senderId, content, type, isRead, createdAt, seq, status, segments)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
[message.id, message.conversationId, message.senderId, message.content || '',
|
|
||||||
message.type || 'text', message.isRead ? 1 : 0, message.createdAt,
|
|
||||||
message.seq || 0, message.status || 'normal',
|
|
||||||
message.segments ? JSON.stringify(message.segments) : null]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveMessagesBatch(messages: CachedMessage[]): Promise<void> {
|
|
||||||
if (!messages?.length) return;
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
for (const msg of messages) {
|
|
||||||
await this.dataSource.run(
|
|
||||||
`INSERT OR REPLACE INTO messages (id, conversationId, senderId, content, type, isRead, createdAt, seq, status, segments)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
[msg.id, msg.conversationId, msg.senderId, msg.content || '',
|
|
||||||
msg.type || 'text', msg.isRead ? 1 : 0, msg.createdAt,
|
|
||||||
msg.seq || 0, msg.status || 'normal',
|
|
||||||
msg.segments ? JSON.stringify(msg.segments) : null]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getByConversation(conversationId: string, limit: number = 20): Promise<CachedMessage[]> {
|
|
||||||
const rows = await this.dataSource.query<any>(
|
|
||||||
`SELECT * FROM messages WHERE conversationId = ? ORDER BY seq DESC LIMIT ?`,
|
|
||||||
[conversationId, limit]
|
|
||||||
);
|
|
||||||
return rows.reverse().map(r => ({
|
|
||||||
...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
async getMaxSeq(conversationId: string): Promise<number> {
|
|
||||||
const r = await this.dataSource.getFirst<{ maxSeq: number }>(
|
|
||||||
`SELECT MAX(seq) as maxSeq FROM messages WHERE conversationId = ?`, [conversationId]
|
|
||||||
);
|
|
||||||
return r?.maxSeq || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getMinSeq(conversationId: string): Promise<number> {
|
|
||||||
const r = await this.dataSource.getFirst<{ minSeq: number }>(
|
|
||||||
`SELECT MIN(seq) as minSeq FROM messages WHERE conversationId = ?`, [conversationId]
|
|
||||||
);
|
|
||||||
return r?.minSeq || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getBeforeSeq(conversationId: string, beforeSeq: number, limit: number = 20): Promise<CachedMessage[]> {
|
|
||||||
const rows = await this.dataSource.query<any>(
|
|
||||||
`SELECT * FROM messages WHERE conversationId = ? AND seq < ? ORDER BY seq DESC LIMIT ?`,
|
|
||||||
[conversationId, beforeSeq, limit]
|
|
||||||
);
|
|
||||||
return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined })).reverse();
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCount(conversationId: string): Promise<number> {
|
|
||||||
const r = await this.dataSource.getFirst<{ count: number }>(
|
|
||||||
`SELECT COUNT(*) as count FROM messages WHERE conversationId = ?`, [conversationId]
|
|
||||||
);
|
|
||||||
return r?.count || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCountBeforeSeq(conversationId: string, beforeSeq: number): Promise<number> {
|
|
||||||
const r = await this.dataSource.getFirst<{ count: number }>(
|
|
||||||
`SELECT COUNT(*) as count FROM messages WHERE conversationId = ? AND seq < ?`, [conversationId, beforeSeq]
|
|
||||||
);
|
|
||||||
return r?.count || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async markAsRead(messageId: string): Promise<void> {
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
await this.dataSource.run(`UPDATE messages SET isRead = 1 WHERE id = ?`, [messageId]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async markConversationAsRead(conversationId: string): Promise<void> {
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
await this.dataSource.run(`UPDATE messages SET isRead = 1 WHERE conversationId = ?`, [conversationId]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(messageId: string): Promise<void> {
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
await this.dataSource.run(`DELETE FROM messages WHERE id = ?`, [messageId]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async updateStatus(messageId: string, status: string, clearContent: boolean = false): Promise<void> {
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
if (clearContent) {
|
|
||||||
await this.dataSource.run(
|
|
||||||
`UPDATE messages SET status = ?, content = '', segments = '[]' WHERE id = ?`,
|
|
||||||
[status, messageId]
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await this.dataSource.run(`UPDATE messages SET status = ? WHERE id = ?`, [status, messageId]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async clearConversation(conversationId: string): Promise<void> {
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
await this.dataSource.run(`DELETE FROM messages WHERE conversationId = ?`, [conversationId]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async search(keyword: string): Promise<CachedMessage[]> {
|
|
||||||
const rows = await this.dataSource.query<any>(
|
|
||||||
`SELECT * FROM messages WHERE content LIKE ? ORDER BY createdAt DESC LIMIT 50`, [`%${keyword}%`]
|
|
||||||
);
|
|
||||||
return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async getStats(): Promise<{ totalMessages: number; totalConversations: number }> {
|
|
||||||
const msgCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM messages`);
|
|
||||||
const convCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM conversations`);
|
|
||||||
return { totalMessages: msgCount?.count || 0, totalConversations: convCount?.count || 0 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const messageRepository = new MessageRepository();
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import { localDataSource } from '../LocalDataSource';
|
|
||||||
import type { ILocalDataSource } from '@/data/datasources/interfaces';
|
|
||||||
|
|
||||||
export interface IPostCacheRepository {
|
|
||||||
save(id: string, data: any): Promise<void>;
|
|
||||||
get(id: string): Promise<any | null>;
|
|
||||||
getAll(): Promise<any[]>;
|
|
||||||
delete(id: string): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PostCacheRepository implements IPostCacheRepository {
|
|
||||||
constructor(private dataSource: ILocalDataSource = localDataSource) {}
|
|
||||||
|
|
||||||
async save(id: string, data: any): Promise<void> {
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
await this.dataSource.run(
|
|
||||||
`INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
|
|
||||||
[String(id), JSON.stringify(data), new Date().toISOString()]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async get(id: string): Promise<any | null> {
|
|
||||||
const r = await this.dataSource.getFirst<{ data: string }>(
|
|
||||||
`SELECT data FROM posts_cache WHERE id = ?`, [String(id)]
|
|
||||||
);
|
|
||||||
return r?.data ? JSON.parse(r.data) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getAll(): Promise<any[]> {
|
|
||||||
const rows = await this.dataSource.query<{ data: string }>(
|
|
||||||
`SELECT data FROM posts_cache ORDER BY updatedAt DESC`
|
|
||||||
);
|
|
||||||
return rows.map(r => JSON.parse(r.data));
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
await this.dataSource.run(`DELETE FROM posts_cache WHERE id = ?`, [String(id)]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const postCacheRepository = new PostCacheRepository();
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
import { localDataSource } from '../LocalDataSource';
|
|
||||||
import type { ILocalDataSource } from '@/data/datasources/interfaces';
|
|
||||||
import type { UserDTO } from '@/types/dto';
|
|
||||||
|
|
||||||
const safeParseJson = <T>(value: string): T | null => {
|
|
||||||
try { return JSON.parse(value) as T; } catch { return null; }
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface IUserCacheRepository {
|
|
||||||
save(user: UserDTO): Promise<void>;
|
|
||||||
saveBatch(users: UserDTO[]): Promise<void>;
|
|
||||||
get(userId: string): Promise<UserDTO | null>;
|
|
||||||
getLatest(): Promise<UserDTO | null>;
|
|
||||||
saveCurrent(user: UserDTO): Promise<void>;
|
|
||||||
getCurrent(): Promise<UserDTO | null>;
|
|
||||||
clearCurrent(): Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserCacheRepository implements IUserCacheRepository {
|
|
||||||
constructor(private dataSource: ILocalDataSource = localDataSource) {}
|
|
||||||
|
|
||||||
async save(user: UserDTO): Promise<void> {
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
await this.dataSource.run(
|
|
||||||
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
|
|
||||||
[String(user.id), JSON.stringify(user), new Date().toISOString()]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveBatch(users: UserDTO[]): Promise<void> {
|
|
||||||
if (!users?.length) return;
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
for (const u of users) {
|
|
||||||
await this.dataSource.run(
|
|
||||||
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
|
|
||||||
[String(u.id), JSON.stringify(u), now]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async get(userId: string): Promise<UserDTO | null> {
|
|
||||||
const r = await this.dataSource.getFirst<{ data: string }>(
|
|
||||||
`SELECT data FROM users WHERE id = ?`, [String(userId)]
|
|
||||||
);
|
|
||||||
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getLatest(): Promise<UserDTO | null> {
|
|
||||||
const r = await this.dataSource.getFirst<{ data: string }>(
|
|
||||||
`SELECT data FROM users ORDER BY updatedAt DESC LIMIT 1`
|
|
||||||
);
|
|
||||||
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveCurrent(user: UserDTO): Promise<void> {
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
await this.dataSource.run(
|
|
||||||
`INSERT OR REPLACE INTO current_user_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
|
|
||||||
['me', JSON.stringify(user), new Date().toISOString()]
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async getCurrent(): Promise<UserDTO | null> {
|
|
||||||
const r = await this.dataSource.getFirst<{ data: string }>(
|
|
||||||
`SELECT data FROM current_user_cache WHERE id = 'me'`
|
|
||||||
);
|
|
||||||
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async clearCurrent(): Promise<void> {
|
|
||||||
await this.dataSource.enqueueWrite(async () => {
|
|
||||||
await this.dataSource.run(`DELETE FROM current_user_cache WHERE id = 'me'`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const userCacheRepository = new UserCacheRepository();
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export { IMessageRepository, MessageRepository, messageRepository } from './MessageRepository';
|
|
||||||
export { IConversationRepository, ConversationRepository, conversationRepository } from './ConversationRepository';
|
|
||||||
export { IUserCacheRepository, UserCacheRepository, userCacheRepository } from './UserCacheRepository';
|
|
||||||
export { IGroupCacheRepository, GroupCacheRepository, groupCacheRepository } from './GroupCacheRepository';
|
|
||||||
export { IPostCacheRepository, PostCacheRepository, postCacheRepository } from './PostCacheRepository';
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import type { SQLiteDatabase } from 'expo-sqlite';
|
|
||||||
|
|
||||||
export const CONVERSATIONS_TABLE = 'conversations';
|
|
||||||
export const CONVERSATION_CACHE_TABLE = 'conversation_cache';
|
|
||||||
export const CONVERSATION_LIST_CACHE_TABLE = 'conversation_list_cache';
|
|
||||||
|
|
||||||
export const CREATE_CONVERSATIONS_TABLE = `CREATE TABLE IF NOT EXISTS conversations (
|
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
|
||||||
participantId TEXT NOT NULL,
|
|
||||||
lastMessageId TEXT,
|
|
||||||
lastSeq INTEGER DEFAULT 0,
|
|
||||||
unreadCount INTEGER DEFAULT 0,
|
|
||||||
createdAt TEXT NOT NULL,
|
|
||||||
updatedAt TEXT NOT NULL
|
|
||||||
)`;
|
|
||||||
|
|
||||||
export const CREATE_CONVERSATION_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS conversation_cache (
|
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
updatedAt TEXT NOT NULL
|
|
||||||
)`;
|
|
||||||
|
|
||||||
export const CREATE_CONVERSATION_LIST_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS conversation_list_cache (
|
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
updatedAt TEXT NOT NULL
|
|
||||||
)`;
|
|
||||||
|
|
||||||
export const MIGRATE_CONVERSATIONS_TABLE = async (db: SQLiteDatabase): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const tableInfo = await db.getAllAsync<{ name: string }>(`PRAGMA table_info(conversations)`);
|
|
||||||
const columns = tableInfo.map(col => col.name);
|
|
||||||
|
|
||||||
if (!columns.includes('lastSeq')) {
|
|
||||||
await db.execAsync(`ALTER TABLE conversations ADD COLUMN lastSeq INTEGER DEFAULT 0`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[ConversationSchema] 迁移失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
export const GROUPS_TABLE = 'groups';
|
|
||||||
export const GROUP_MEMBERS_TABLE = 'group_members';
|
|
||||||
|
|
||||||
export const CREATE_GROUPS_TABLE = `CREATE TABLE IF NOT EXISTS groups (
|
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
updatedAt TEXT NOT NULL
|
|
||||||
)`;
|
|
||||||
|
|
||||||
export const CREATE_GROUP_MEMBERS_TABLE = `CREATE TABLE IF NOT EXISTS group_members (
|
|
||||||
groupId TEXT NOT NULL,
|
|
||||||
userId TEXT NOT NULL,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
updatedAt TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (groupId, userId)
|
|
||||||
)`;
|
|
||||||
|
|
||||||
export const CREATE_GROUP_INDEXES = [
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_group_members_groupId ON group_members(groupId)`,
|
|
||||||
];
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import type { SQLiteDatabase } from 'expo-sqlite';
|
|
||||||
|
|
||||||
export const MESSAGES_TABLE = 'messages';
|
|
||||||
|
|
||||||
export const CREATE_MESSAGES_TABLE = `CREATE TABLE IF NOT EXISTS messages (
|
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
|
||||||
conversationId TEXT NOT NULL,
|
|
||||||
senderId TEXT NOT NULL,
|
|
||||||
content TEXT,
|
|
||||||
type TEXT DEFAULT 'text',
|
|
||||||
isRead INTEGER DEFAULT 0,
|
|
||||||
createdAt TEXT NOT NULL,
|
|
||||||
seq INTEGER DEFAULT 0,
|
|
||||||
status TEXT DEFAULT 'normal',
|
|
||||||
segments TEXT
|
|
||||||
)`;
|
|
||||||
|
|
||||||
export const CREATE_MESSAGES_INDEXES = [
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_messages_conversationId ON messages(conversationId)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_messages_conversation_seq ON messages(conversationId, seq)`,
|
|
||||||
];
|
|
||||||
|
|
||||||
export const MIGRATE_MESSAGES_TABLE = async (db: SQLiteDatabase): Promise<void> => {
|
|
||||||
try {
|
|
||||||
const tableInfo = await db.getAllAsync<{ name: string }>(`PRAGMA table_info(messages)`);
|
|
||||||
const columns = tableInfo.map(col => col.name);
|
|
||||||
|
|
||||||
if (!columns.includes('seq')) {
|
|
||||||
await db.execAsync(`ALTER TABLE messages ADD COLUMN seq INTEGER DEFAULT 0`);
|
|
||||||
}
|
|
||||||
if (!columns.includes('status')) {
|
|
||||||
await db.execAsync(`ALTER TABLE messages ADD COLUMN status TEXT DEFAULT 'normal'`);
|
|
||||||
}
|
|
||||||
if (!columns.includes('segments')) {
|
|
||||||
await db.execAsync(`ALTER TABLE messages ADD COLUMN segments TEXT`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[MessageSchema] 迁移失败:', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
import type { SQLiteDatabase } from 'expo-sqlite';
|
|
||||||
import {
|
|
||||||
CREATE_MESSAGES_TABLE,
|
|
||||||
CREATE_MESSAGES_INDEXES,
|
|
||||||
MIGRATE_MESSAGES_TABLE,
|
|
||||||
} from './MessageSchema';
|
|
||||||
import {
|
|
||||||
CREATE_CONVERSATIONS_TABLE,
|
|
||||||
CREATE_CONVERSATION_CACHE_TABLE,
|
|
||||||
CREATE_CONVERSATION_LIST_CACHE_TABLE,
|
|
||||||
MIGRATE_CONVERSATIONS_TABLE,
|
|
||||||
} from './ConversationSchema';
|
|
||||||
import {
|
|
||||||
CREATE_USERS_TABLE,
|
|
||||||
CREATE_CURRENT_USER_CACHE_TABLE,
|
|
||||||
} from './UserSchema';
|
|
||||||
import {
|
|
||||||
CREATE_GROUPS_TABLE,
|
|
||||||
CREATE_GROUP_MEMBERS_TABLE,
|
|
||||||
CREATE_GROUP_INDEXES,
|
|
||||||
} from './GroupSchema';
|
|
||||||
import {
|
|
||||||
CREATE_POSTS_CACHE_TABLE,
|
|
||||||
} from './PostSchema';
|
|
||||||
|
|
||||||
async function execWithRetry(db: SQLiteDatabase, sql: string, maxRetries = 5): Promise<void> {
|
|
||||||
for (let i = 0; i < maxRetries; i++) {
|
|
||||||
try {
|
|
||||||
await db.execAsync(sql);
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
const msg = String(error);
|
|
||||||
if (msg.includes('database is locked') && i < maxRetries - 1) {
|
|
||||||
const delay = 200 * (i + 1);
|
|
||||||
console.warn(`[MigrationManager] 数据库锁定,${delay}ms 后重试 (${i + 1}/${maxRetries})`);
|
|
||||||
await new Promise(r => setTimeout(r, delay));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function runMigrations(db: SQLiteDatabase): Promise<void> {
|
|
||||||
await execWithRetry(db, 'PRAGMA busy_timeout = 10000;');
|
|
||||||
|
|
||||||
await execWithRetry(db, CREATE_MESSAGES_TABLE);
|
|
||||||
await execWithRetry(db, CREATE_CONVERSATIONS_TABLE);
|
|
||||||
await execWithRetry(db, CREATE_CONVERSATION_CACHE_TABLE);
|
|
||||||
await execWithRetry(db, CREATE_CONVERSATION_LIST_CACHE_TABLE);
|
|
||||||
await execWithRetry(db, CREATE_USERS_TABLE);
|
|
||||||
await execWithRetry(db, CREATE_CURRENT_USER_CACHE_TABLE);
|
|
||||||
await execWithRetry(db, CREATE_GROUPS_TABLE);
|
|
||||||
await execWithRetry(db, CREATE_GROUP_MEMBERS_TABLE);
|
|
||||||
await execWithRetry(db, CREATE_POSTS_CACHE_TABLE);
|
|
||||||
|
|
||||||
for (const indexSql of CREATE_MESSAGES_INDEXES) {
|
|
||||||
await execWithRetry(db, indexSql);
|
|
||||||
}
|
|
||||||
for (const indexSql of CREATE_GROUP_INDEXES) {
|
|
||||||
await execWithRetry(db, indexSql);
|
|
||||||
}
|
|
||||||
|
|
||||||
await MIGRATE_MESSAGES_TABLE(db);
|
|
||||||
await MIGRATE_CONVERSATIONS_TABLE(db);
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
export const POSTS_CACHE_TABLE = 'posts_cache';
|
|
||||||
|
|
||||||
export const CREATE_POSTS_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS posts_cache (
|
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
updatedAt TEXT NOT NULL
|
|
||||||
)`;
|
|
||||||
|
|
||||||
export const CREATE_POSTS_INDEXES: string[] = [];
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
export const USERS_TABLE = 'users';
|
|
||||||
export const CURRENT_USER_CACHE_TABLE = 'current_user_cache';
|
|
||||||
|
|
||||||
export const CREATE_USERS_TABLE = `CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
updatedAt TEXT NOT NULL
|
|
||||||
)`;
|
|
||||||
|
|
||||||
export const CREATE_CURRENT_USER_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS current_user_cache (
|
|
||||||
id TEXT PRIMARY KEY NOT NULL,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
updatedAt TEXT NOT NULL
|
|
||||||
)`;
|
|
||||||
|
|
||||||
export const CREATE_USER_INDEXES: string[] = [];
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
export * from './MessageSchema';
|
|
||||||
export * from './ConversationSchema';
|
|
||||||
export * from './UserSchema';
|
|
||||||
export * from './GroupSchema';
|
|
||||||
export * from './PostSchema';
|
|
||||||
export { runMigrations } from './MigrationManager';
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
export interface CachedMessage {
|
|
||||||
id: string;
|
|
||||||
conversationId: string;
|
|
||||||
senderId: string;
|
|
||||||
content?: string;
|
|
||||||
type?: string;
|
|
||||||
isRead: boolean;
|
|
||||||
createdAt: string;
|
|
||||||
seq: number;
|
|
||||||
status: string;
|
|
||||||
segments?: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export { CachedMessage as default };
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { CachedMessage } from './CachedMessage';
|
|
||||||
@@ -111,16 +111,3 @@ export type {
|
|||||||
UseDifferentialPostsResult,
|
UseDifferentialPostsResult,
|
||||||
DiffUpdatesInfo,
|
DiffUpdatesInfo,
|
||||||
} from './useDifferentialPosts';
|
} from './useDifferentialPosts';
|
||||||
|
|
||||||
// ==================== 平台键盘 Hooks ====================
|
|
||||||
export {
|
|
||||||
usePlatformKeyboard,
|
|
||||||
useKeyboardAvoidingProps,
|
|
||||||
useKeyboardListener,
|
|
||||||
} from './usePlatformKeyboard';
|
|
||||||
|
|
||||||
export type {
|
|
||||||
KeyboardEventType,
|
|
||||||
PlatformKeyboardOptions,
|
|
||||||
PlatformKeyboardResult,
|
|
||||||
} from './usePlatformKeyboard';
|
|
||||||
|
|||||||
@@ -1,179 +0,0 @@
|
|||||||
/**
|
|
||||||
* usePlatformKeyboard - 统一的跨平台键盘行为 Hook
|
|
||||||
*
|
|
||||||
* 消除散落在多个文件中的 Platform.OS 键盘事件判断
|
|
||||||
*
|
|
||||||
* 使用前(重复代码):
|
|
||||||
* ```tsx
|
|
||||||
* const showSub = Keyboard.addListener(
|
|
||||||
* Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
|
|
||||||
* handler
|
|
||||||
* );
|
|
||||||
* ```
|
|
||||||
*
|
|
||||||
* 使用后:
|
|
||||||
* ```tsx
|
|
||||||
* const { addListener } = usePlatformKeyboard();
|
|
||||||
* const showSub = addListener('show', handler);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback } from 'react';
|
|
||||||
import { Keyboard, Platform, KeyboardEvent, EmitterSubscription } from 'react-native';
|
|
||||||
|
|
||||||
export type KeyboardEventType = 'show' | 'hide' | 'change';
|
|
||||||
|
|
||||||
export interface PlatformKeyboardOptions {
|
|
||||||
onShow?: (event: KeyboardEvent) => void;
|
|
||||||
onHide?: (event: KeyboardEvent) => void;
|
|
||||||
onChange?: (height: number) => void;
|
|
||||||
enabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlatformKeyboardResult {
|
|
||||||
/** 键盘高度 */
|
|
||||||
keyboardHeight: number;
|
|
||||||
/** 键盘是否可见 */
|
|
||||||
isKeyboardVisible: boolean;
|
|
||||||
/** 平台最佳的 KeyboardAvoidingView behavior */
|
|
||||||
keyboardBehavior: 'padding' | 'height' | undefined;
|
|
||||||
/** 平台最佳的 keyboardVerticalOffset */
|
|
||||||
keyboardVerticalOffset: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取平台特定的键盘事件名称
|
|
||||||
*/
|
|
||||||
function getKeyboardEventName(type: KeyboardEventType): string {
|
|
||||||
switch (type) {
|
|
||||||
case 'show':
|
|
||||||
return Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
|
|
||||||
case 'hide':
|
|
||||||
return Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
|
|
||||||
case 'change':
|
|
||||||
return Platform.OS === 'ios' ? 'keyboardWillChangeFrame' : 'keyboardDidShow';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 平台键盘 Hook
|
|
||||||
*
|
|
||||||
* 自动处理跨平台键盘事件差异
|
|
||||||
*/
|
|
||||||
export function usePlatformKeyboard(
|
|
||||||
options: PlatformKeyboardOptions = {}
|
|
||||||
): PlatformKeyboardResult {
|
|
||||||
const { onShow, onHide, onChange, enabled = true } = options;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
|
|
||||||
const subscriptions: EmitterSubscription[] = [];
|
|
||||||
|
|
||||||
if (onShow) {
|
|
||||||
subscriptions.push(
|
|
||||||
Keyboard.addListener(
|
|
||||||
getKeyboardEventName('show') as any,
|
|
||||||
onShow
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onHide) {
|
|
||||||
subscriptions.push(
|
|
||||||
Keyboard.addListener(
|
|
||||||
getKeyboardEventName('hide') as any,
|
|
||||||
onHide
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onChange) {
|
|
||||||
const handleChange = (e: KeyboardEvent) => {
|
|
||||||
onChange(e.endCoordinates.height);
|
|
||||||
};
|
|
||||||
subscriptions.push(
|
|
||||||
Keyboard.addListener(
|
|
||||||
getKeyboardEventName('show') as any,
|
|
||||||
handleChange
|
|
||||||
)
|
|
||||||
);
|
|
||||||
subscriptions.push(
|
|
||||||
Keyboard.addListener(
|
|
||||||
getKeyboardEventName('hide') as any,
|
|
||||||
() => onChange(0)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
subscriptions.forEach(sub => sub.remove());
|
|
||||||
};
|
|
||||||
}, [enabled, onShow, onHide, onChange]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
keyboardHeight: 0,
|
|
||||||
isKeyboardVisible: false,
|
|
||||||
keyboardBehavior: Platform.OS === 'ios' ? 'padding' : undefined,
|
|
||||||
keyboardVerticalOffset: Platform.OS === 'ios' ? 0 : 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取平台最佳的 KeyboardAvoidingView props
|
|
||||||
*
|
|
||||||
* 用于替代重复的 Platform.OS 三元判断:
|
|
||||||
* ```tsx
|
|
||||||
* // 之前
|
|
||||||
* behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
||||||
*
|
|
||||||
* // 之后
|
|
||||||
* const { keyboardProps } = useKeyboardAvoidingProps();
|
|
||||||
* <KeyboardAvoidingView {...keyboardProps}>
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export function useKeyboardAvoidingProps(
|
|
||||||
options: { offset?: number; defaultBehavior?: 'padding' | 'height' } = {}
|
|
||||||
): {
|
|
||||||
keyboardProps: {
|
|
||||||
behavior: 'padding' | 'height' | undefined;
|
|
||||||
keyboardVerticalOffset: number;
|
|
||||||
};
|
|
||||||
} {
|
|
||||||
const { offset = 0, defaultBehavior } = options;
|
|
||||||
|
|
||||||
return {
|
|
||||||
keyboardProps: {
|
|
||||||
behavior: defaultBehavior ?? (Platform.OS === 'ios' ? 'padding' : undefined),
|
|
||||||
keyboardVerticalOffset: Platform.OS === 'ios' ? offset : 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 键盘事件监听 Hook(简化版)
|
|
||||||
*
|
|
||||||
* 返回一个平台无关的 addListener 函数
|
|
||||||
*/
|
|
||||||
export function useKeyboardListener() {
|
|
||||||
const subscriptionsRef = useRef<EmitterSubscription[]>([]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
return () => {
|
|
||||||
subscriptionsRef.current.forEach(sub => sub.remove());
|
|
||||||
subscriptionsRef.current = [];
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const addListener = useCallback(
|
|
||||||
(type: KeyboardEventType, handler: (event: KeyboardEvent) => void) => {
|
|
||||||
const eventName = getKeyboardEventName(type);
|
|
||||||
const sub = Keyboard.addListener(eventName as any, handler);
|
|
||||||
subscriptionsRef.current.push(sub);
|
|
||||||
return sub;
|
|
||||||
},
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
return { addListener };
|
|
||||||
}
|
|
||||||
@@ -14,7 +14,6 @@ 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';
|
|
||||||
|
|
||||||
// ==================== 预取配置 ====================
|
// ==================== 预取配置 ====================
|
||||||
|
|
||||||
@@ -135,7 +134,7 @@ const prefetchService = new PrefetchService();
|
|||||||
* 预取帖子数据
|
* 预取帖子数据
|
||||||
* @param types 帖子类型数组
|
* @param types 帖子类型数组
|
||||||
*/
|
*/
|
||||||
function prefetchPosts(types: PostListTab[] = ['latest', 'hot']): void {
|
function prefetchPosts(types: string[] = ['latest', 'hot']): void {
|
||||||
types.forEach((type, index) => {
|
types.forEach((type, index) => {
|
||||||
prefetchService.schedule({
|
prefetchService.schedule({
|
||||||
key: `posts:${type}:1`,
|
key: `posts:${type}:1`,
|
||||||
@@ -153,7 +152,10 @@ function prefetchConversations(): void {
|
|||||||
key: 'conversations:list',
|
key: 'conversations:list',
|
||||||
executor: async () => {
|
executor: async () => {
|
||||||
await messageManager.initialize();
|
await messageManager.initialize();
|
||||||
await messageManager.refreshConversations(true, 'prefetch');
|
await messageManager.requestConversationListRefresh('prefetch', {
|
||||||
|
force: true,
|
||||||
|
allowDefer: true,
|
||||||
|
});
|
||||||
return messageManager.getConversations();
|
return messageManager.getConversations();
|
||||||
},
|
},
|
||||||
priority: Priority.HIGH,
|
priority: Priority.HIGH,
|
||||||
@@ -277,7 +279,7 @@ export function usePrefetch() {
|
|||||||
const hasInitialPrefetched = useRef(false);
|
const hasInitialPrefetched = useRef(false);
|
||||||
|
|
||||||
/** 预取帖子 */
|
/** 预取帖子 */
|
||||||
const prefetchPostsData = useCallback((types?: PostListTab[]) => {
|
const prefetchPostsData = useCallback((types?: string[]) => {
|
||||||
prefetchPosts(types);
|
prefetchPosts(types);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
36
src/infrastructure/cache/MediaCacheManager.ts
vendored
36
src/infrastructure/cache/MediaCacheManager.ts
vendored
@@ -4,9 +4,8 @@
|
|||||||
* @description 管理图片、视频、音频缓存,支持多种清理策略
|
* @description 管理图片、视频、音频缓存,支持多种清理策略
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { File, Directory, Paths } from 'expo-file-system';
|
import { File, Paths } from 'expo-file-system';
|
||||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
import { Platform } from 'react-native';
|
|
||||||
import {
|
import {
|
||||||
MediaType,
|
MediaType,
|
||||||
CleanupTrigger,
|
CleanupTrigger,
|
||||||
@@ -28,7 +27,7 @@ const CACHE_RECORD_PREFIX = 'media_cache_record_';
|
|||||||
* 获取缓存目录路径
|
* 获取缓存目录路径
|
||||||
*/
|
*/
|
||||||
function getCacheDir(): string {
|
function getCacheDir(): string {
|
||||||
const cachePath = Paths.cache.uri;
|
const cachePath = Paths.cache;
|
||||||
return `${cachePath}media_cache/`;
|
return `${cachePath}media_cache/`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,15 +102,13 @@ export class MediaCacheManager {
|
|||||||
// 加载缓存记录
|
// 加载缓存记录
|
||||||
await this.loadCacheRecords();
|
await this.loadCacheRecords();
|
||||||
|
|
||||||
// 启动时清理 (非web平台)
|
// 启动时清理
|
||||||
if (this.policy.cleanupOnStart && Platform.OS !== 'web') {
|
if (this.policy.cleanupOnStart) {
|
||||||
await this.cleanup(CleanupTrigger.STARTUP);
|
await this.cleanup(CleanupTrigger.STARTUP);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动定期清理 (非web平台)
|
// 启动定期清理
|
||||||
if (Platform.OS !== 'web') {
|
|
||||||
this.startPeriodicCleanup();
|
this.startPeriodicCleanup();
|
||||||
}
|
|
||||||
|
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -124,19 +121,12 @@ export class MediaCacheManager {
|
|||||||
* 确保缓存目录存在
|
* 确保缓存目录存在
|
||||||
*/
|
*/
|
||||||
private async ensureDirectories(): Promise<void> {
|
private async ensureDirectories(): Promise<void> {
|
||||||
if (Platform.OS === 'web') return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const cacheDir = new Directory(Paths.cache, 'media_cache');
|
const dirs = [getImageDir(), getVideoDir(), getAudioDir()];
|
||||||
if (!await cacheDir.exists) {
|
for (const dir of dirs) {
|
||||||
await cacheDir.create();
|
const dirFile = new File(dir);
|
||||||
}
|
if (!await dirFile.exists) {
|
||||||
|
await dirFile.create();
|
||||||
const subDirs = ['images', 'videos', 'audios'];
|
|
||||||
for (const subDir of subDirs) {
|
|
||||||
const dir = new Directory(cacheDir, subDir);
|
|
||||||
if (!await dir.exists) {
|
|
||||||
await dir.create();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -178,8 +168,6 @@ export class MediaCacheManager {
|
|||||||
* 检查文件是否存在
|
* 检查文件是否存在
|
||||||
*/
|
*/
|
||||||
private async checkFileExists(path: string): Promise<boolean> {
|
private async checkFileExists(path: string): Promise<boolean> {
|
||||||
if (Platform.OS === 'web') return false;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const file = new File(path);
|
const file = new File(path);
|
||||||
return await file.exists;
|
return await file.exists;
|
||||||
@@ -211,10 +199,6 @@ export class MediaCacheManager {
|
|||||||
size?: number;
|
size?: number;
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
if (Platform.OS === 'web') {
|
|
||||||
return uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { conversationId, messageId, size = 0 } = options;
|
const { conversationId, messageId, size = 0 } = options;
|
||||||
|
|
||||||
// 生成唯一键
|
// 生成唯一键
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
/**
|
|
||||||
* Web Platform DOM Utilities
|
|
||||||
*
|
|
||||||
* 提供Web平台特定的DOM操作工具函数
|
|
||||||
* 解决跨平台代码中需要访问Web DOM的补丁问题
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Platform, Keyboard } from 'react-native';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Blur当前激活的DOM元素(Web)并收起键盘(移动端)
|
|
||||||
*
|
|
||||||
* - Web: blur DOM active element
|
|
||||||
* - iOS/Android: Keyboard.dismiss()
|
|
||||||
*
|
|
||||||
* 替代散落在多个文件中的重复代码
|
|
||||||
*/
|
|
||||||
export function blurActiveElement(): void {
|
|
||||||
if (Platform.OS === 'web') {
|
|
||||||
try {
|
|
||||||
const activeElement = (globalThis as any)?.document?.activeElement as
|
|
||||||
{ blur?: () => void } | undefined;
|
|
||||||
|
|
||||||
if (activeElement?.blur) {
|
|
||||||
activeElement.blur();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 安全忽略:某些环境下document可能不可访问
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Keyboard.dismiss();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查当前是否有激活的DOM元素(仅Web平台)
|
|
||||||
*/
|
|
||||||
export function hasActiveElement(): boolean {
|
|
||||||
if (Platform.OS !== 'web') return false;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const doc = (globalThis as any)?.document;
|
|
||||||
return !!doc?.activeElement && doc.activeElement !== doc.body;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取当前激活元素的类型(仅Web平台)
|
|
||||||
* 用于判断是否需要特殊处理(如输入框)
|
|
||||||
*/
|
|
||||||
export function getActiveElementType(): string | null {
|
|
||||||
if (Platform.OS !== 'web') return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const activeElement = (globalThis as any)?.document?.activeElement as
|
|
||||||
{ tagName?: string; type?: string } | undefined;
|
|
||||||
|
|
||||||
if (!activeElement) return null;
|
|
||||||
|
|
||||||
const tagName = activeElement.tagName?.toLowerCase();
|
|
||||||
const type = activeElement.type?.toLowerCase();
|
|
||||||
|
|
||||||
return tagName === 'input' ? `input:${type || 'text'}` : tagName || null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 判断当前激活元素是否为输入类型
|
|
||||||
*/
|
|
||||||
export function isInputFocused(): boolean {
|
|
||||||
const elementType = getActiveElementType();
|
|
||||||
if (!elementType) return false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
elementType.startsWith('input:') ||
|
|
||||||
elementType === 'textarea' ||
|
|
||||||
elementType === 'select'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* Platform Infrastructure
|
|
||||||
*
|
|
||||||
* 平台相关的基础设施代码
|
|
||||||
*/
|
|
||||||
|
|
||||||
export {
|
|
||||||
blurActiveElement,
|
|
||||||
hasActiveElement,
|
|
||||||
getActiveElementType,
|
|
||||||
isInputFocused,
|
|
||||||
} from './domUtils';
|
|
||||||
@@ -61,10 +61,6 @@ export function hrefProfileBlocked(): string {
|
|||||||
return '/profile/blocked-users';
|
return '/profile/blocked-users';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hrefProfileChatSettings(): string {
|
|
||||||
return '/profile/chat-settings';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hrefProfileMyPosts(): string {
|
export function hrefProfileMyPosts(): string {
|
||||||
return '/profile/my-posts';
|
return '/profile/my-posts';
|
||||||
}
|
}
|
||||||
@@ -159,62 +155,3 @@ export function hrefAuthRegister(): string {
|
|||||||
export function hrefAuthForgot(): string {
|
export function hrefAuthForgot(): string {
|
||||||
return '/forgot-password';
|
return '/forgot-password';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hrefAuthWelcome(): string {
|
|
||||||
return '/welcome';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== 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)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Profile Settings (设置相关) ====================
|
|
||||||
|
|
||||||
export function hrefProfileAbout(): string {
|
|
||||||
return '/profile/about';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hrefProfileTerms(): string {
|
|
||||||
return '/terms';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hrefProfilePrivacy(): string {
|
|
||||||
return '/privacy';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hrefProfileVerification(): string {
|
|
||||||
return '/profile/verification';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hrefVerificationGuide(): string {
|
|
||||||
return '/verification-guide';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hrefVerificationForm(identity?: string): string {
|
|
||||||
if (identity) {
|
|
||||||
return `/verification-form?identity=${encodeURIComponent(identity)}`;
|
|
||||||
}
|
|
||||||
return '/verification-form';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hrefProfileDataStorage(): string {
|
|
||||||
return '/profile/data-storage';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hrefProfilePrivacySettings(): string {
|
|
||||||
return '/profile/privacy-settings';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function hrefProfileDeletion(): string {
|
|
||||||
return '/profile/account-deletion';
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 } from '../../components/common';
|
import { Text, ResponsiveContainer } from '../../components/common';
|
||||||
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
|
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
|
||||||
|
|
||||||
type AppItem = {
|
type AppItem = {
|
||||||
@@ -37,18 +37,8 @@ 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();
|
||||||
@@ -66,9 +56,6 @@ 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]
|
||||||
);
|
);
|
||||||
@@ -142,6 +129,17 @@ export const AppsScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{isWideScreen ? (
|
||||||
|
<ResponsiveContainer maxWidth={720}>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scroll}
|
||||||
|
contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{renderTiles()}
|
||||||
|
</ScrollView>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scroll}
|
style={styles.scroll}
|
||||||
contentContainerStyle={[
|
contentContainerStyle={[
|
||||||
@@ -152,6 +150,7 @@ export const AppsScreen: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{renderTiles()}
|
{renderTiles()}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
)}
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,15 +1,4 @@
|
|||||||
/**
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
* 忘记密码页 ForgotPasswordScreen(简洁表单样式)
|
|
||||||
* 胡萝卜BBS - 找回密码
|
|
||||||
*
|
|
||||||
* 设计风格:
|
|
||||||
* - 纯白背景,扁平化设计
|
|
||||||
* - 增大间距
|
|
||||||
* - 输入框带灰色背景填充
|
|
||||||
* - 橙色圆角按钮
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useMemo, useState, useRef } from 'react';
|
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -20,22 +9,123 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
Animated,
|
|
||||||
StatusBar,
|
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { useAppColors, useResolvedColorScheme, type AppColors } from '../../theme';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||||
import { authService, resolveAuthApiError } from '../../services/authService';
|
import { authService, resolveAuthApiError } from '../../services/authService';
|
||||||
import { showPrompt } from '../../services/promptService';
|
import { showPrompt } from '../../services/promptService';
|
||||||
|
|
||||||
|
function createForgotPasswordStyles(colors: AppColors) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
container: { flex: 1 },
|
||||||
|
gradient: { flex: 1 },
|
||||||
|
keyboardView: { flex: 1 },
|
||||||
|
scrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: spacing.xl,
|
||||||
|
paddingVertical: spacing['2xl'],
|
||||||
|
},
|
||||||
|
formCard: {
|
||||||
|
backgroundColor: colors.background.paper,
|
||||||
|
borderRadius: borderRadius['2xl'],
|
||||||
|
padding: spacing.xl,
|
||||||
|
...shadows.md,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: fontSizes['2xl'],
|
||||||
|
fontWeight: '700',
|
||||||
|
color: colors.text.primary,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: fontSizes.sm,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
inputWrapper: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.background.default,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
borderWidth: 1.5,
|
||||||
|
borderColor: colors.divider,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
height: 52,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
inputIcon: {
|
||||||
|
marginRight: spacing.sm,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
color: colors.text.primary,
|
||||||
|
},
|
||||||
|
codeRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
codeInput: {
|
||||||
|
flex: 1,
|
||||||
|
marginBottom: 0,
|
||||||
|
},
|
||||||
|
sendCodeButton: {
|
||||||
|
height: 52,
|
||||||
|
minWidth: 110,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
backgroundColor: colors.primary.main,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
},
|
||||||
|
sendCodeButtonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
sendCodeButtonText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: fontSizes.sm,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
submitButton: {
|
||||||
|
height: 50,
|
||||||
|
borderRadius: borderRadius.lg,
|
||||||
|
backgroundColor: colors.primary.main,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
},
|
||||||
|
submitButtonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
submitButtonText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: fontSizes.lg,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
backButtonText: {
|
||||||
|
color: colors.primary.main,
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export const ForgotPasswordScreen: React.FC = () => {
|
export const ForgotPasswordScreen: React.FC = () => {
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const resolved = useResolvedColorScheme();
|
|
||||||
const styles = useMemo(() => createForgotPasswordStyles(colors), [colors]);
|
const styles = useMemo(() => createForgotPasswordStyles(colors), [colors]);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [verificationCode, setVerificationCode] = useState('');
|
const [verificationCode, setVerificationCode] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
@@ -46,26 +136,6 @@ export const ForgotPasswordScreen: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [countdown, setCountdown] = useState(0);
|
const [countdown, setCountdown] = useState(0);
|
||||||
|
|
||||||
// 动画值
|
|
||||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
|
||||||
const slideAnim = useRef(new Animated.Value(20)).current;
|
|
||||||
|
|
||||||
// 启动入场动画
|
|
||||||
useEffect(() => {
|
|
||||||
Animated.parallel([
|
|
||||||
Animated.timing(fadeAnim, {
|
|
||||||
toValue: 1,
|
|
||||||
duration: 400,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
Animated.timing(slideAnim, {
|
|
||||||
toValue: 0,
|
|
||||||
duration: 400,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
]).start();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (countdown <= 0) {
|
if (countdown <= 0) {
|
||||||
return;
|
return;
|
||||||
@@ -109,20 +179,8 @@ export const ForgotPasswordScreen: React.FC = () => {
|
|||||||
showPrompt({ title: '提示', message: '请输入验证码', type: 'warning' });
|
showPrompt({ title: '提示', message: '请输入验证码', type: 'warning' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newPassword.length < 8) {
|
if (newPassword.length < 6) {
|
||||||
showPrompt({ title: '提示', message: '新密码至少 8 位', type: 'warning' });
|
showPrompt({ title: '提示', message: '新密码至少 6 位', type: 'warning' });
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!/[A-Z]/.test(newPassword)) {
|
|
||||||
showPrompt({ title: '提示', message: '新密码必须包含大写字母', type: 'warning' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!/[a-z]/.test(newPassword)) {
|
|
||||||
showPrompt({ title: '提示', message: '新密码必须包含小写字母', type: 'warning' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!/[0-9]/.test(newPassword)) {
|
|
||||||
showPrompt({ title: '提示', message: '新密码必须包含数字', type: 'warning' });
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
@@ -150,61 +208,32 @@ export const ForgotPasswordScreen: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container}>
|
<SafeAreaView style={styles.container}>
|
||||||
<StatusBar barStyle={resolved === 'dark' ? 'light-content' : 'dark-content'} backgroundColor={colors.background.paper} />
|
<LinearGradient colors={['#FF6B35', '#FF8F66', '#FFB088']} style={styles.gradient}>
|
||||||
<KeyboardAvoidingView
|
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.keyboardView}>
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
||||||
style={styles.keyboardView}
|
<View style={styles.formCard}>
|
||||||
>
|
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={styles.scrollContent}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
styles.content,
|
|
||||||
{
|
|
||||||
opacity: fadeAnim,
|
|
||||||
transform: [{ translateY: slideAnim }],
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{/* 标题区域 */}
|
|
||||||
<View style={styles.titleSection}>
|
|
||||||
<Text style={styles.title}>找回密码</Text>
|
<Text style={styles.title}>找回密码</Text>
|
||||||
<View style={styles.underline} />
|
<Text style={styles.subtitle}>请输入邮箱并完成验证码验证</Text>
|
||||||
<Text style={styles.subtitle}>请输入邮箱并完成验证</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 表单区域 */}
|
|
||||||
<View style={styles.formSection}>
|
|
||||||
{/* 邮箱输入框 */}
|
|
||||||
<View style={styles.inputContainer}>
|
|
||||||
<Text style={styles.inputLabel}>邮箱</Text>
|
|
||||||
<View style={styles.inputWrapper}>
|
<View style={styles.inputWrapper}>
|
||||||
|
<MaterialCommunityIcons name="email-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="请输入邮箱地址"
|
placeholder="邮箱"
|
||||||
placeholderTextColor={colors.text.hint}
|
placeholderTextColor={colors.text.hint}
|
||||||
value={email}
|
value={email}
|
||||||
onChangeText={setEmail}
|
onChangeText={setEmail}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
/>
|
/>
|
||||||
{email.length > 0 && validateEmail(email) && (
|
|
||||||
<MaterialCommunityIcons name="check-circle" size={20} color={colors.primary.main} style={styles.checkIcon} />
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 验证码 */}
|
|
||||||
<View style={styles.inputContainer}>
|
|
||||||
<Text style={styles.inputLabel}>验证码</Text>
|
|
||||||
<View style={styles.codeRow}>
|
<View style={styles.codeRow}>
|
||||||
<View style={[styles.inputWrapper, styles.codeInputWrapper]}>
|
<View style={[styles.inputWrapper, styles.codeInput]}>
|
||||||
|
<MaterialCommunityIcons name="shield-key-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="6位验证码"
|
placeholder="验证码"
|
||||||
placeholderTextColor={colors.text.hint}
|
placeholderTextColor={colors.text.hint}
|
||||||
value={verificationCode}
|
value={verificationCode}
|
||||||
onChangeText={setVerificationCode}
|
onChangeText={setVerificationCode}
|
||||||
@@ -216,242 +245,64 @@ export const ForgotPasswordScreen: React.FC = () => {
|
|||||||
style={[styles.sendCodeButton, (sendingCode || countdown > 0) && styles.sendCodeButtonDisabled]}
|
style={[styles.sendCodeButton, (sendingCode || countdown > 0) && styles.sendCodeButtonDisabled]}
|
||||||
onPress={handleSendCode}
|
onPress={handleSendCode}
|
||||||
disabled={sendingCode || countdown > 0}
|
disabled={sendingCode || countdown > 0}
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
>
|
||||||
{sendingCode ? (
|
{sendingCode ? (
|
||||||
<ActivityIndicator size="small" color={colors.primary.main} />
|
<ActivityIndicator size="small" color="#fff" />
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.sendCodeButtonText}>{countdown > 0 ? `${countdown}s` : '获取验证码'}</Text>
|
<Text style={styles.sendCodeButtonText}>{countdown > 0 ? `${countdown}s` : '发送验证码'}</Text>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 新密码输入框 */}
|
|
||||||
<View style={styles.inputContainer}>
|
|
||||||
<Text style={styles.inputLabel}>新密码</Text>
|
|
||||||
<View style={styles.inputWrapper}>
|
<View style={styles.inputWrapper}>
|
||||||
|
<MaterialCommunityIcons name="lock-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="至少6位"
|
placeholder="新密码(至少6位)"
|
||||||
placeholderTextColor={colors.text.hint}
|
placeholderTextColor={colors.text.hint}
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChangeText={setNewPassword}
|
onChangeText={setNewPassword}
|
||||||
secureTextEntry={!showPassword}
|
secureTextEntry={!showPassword}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity onPress={() => setShowPassword((v) => !v)}>
|
||||||
onPress={() => setShowPassword((v) => !v)}
|
<MaterialCommunityIcons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={20} color={colors.text.hint} />
|
||||||
style={styles.eyeButton}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
|
|
||||||
size={20}
|
|
||||||
color={colors.text.hint}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 确认密码输入框 */}
|
|
||||||
<View style={styles.inputContainer}>
|
|
||||||
<Text style={styles.inputLabel}>确认密码</Text>
|
|
||||||
<View style={styles.inputWrapper}>
|
<View style={styles.inputWrapper}>
|
||||||
|
<MaterialCommunityIcons name="lock-check-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="再次输入新密码"
|
placeholder="确认新密码"
|
||||||
placeholderTextColor={colors.text.hint}
|
placeholderTextColor={colors.text.hint}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChangeText={setConfirmPassword}
|
onChangeText={setConfirmPassword}
|
||||||
secureTextEntry={!showConfirmPassword}
|
secureTextEntry={!showConfirmPassword}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
returnKeyType="done"
|
|
||||||
onSubmitEditing={handleResetPassword}
|
|
||||||
/>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => setShowConfirmPassword((v) => !v)}
|
|
||||||
style={styles.eyeButton}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={showConfirmPassword ? 'eye-off-outline' : 'eye-outline'}
|
|
||||||
size={20}
|
|
||||||
color={colors.text.hint}
|
|
||||||
/>
|
/>
|
||||||
|
<TouchableOpacity onPress={() => setShowConfirmPassword((v) => !v)}>
|
||||||
|
<MaterialCommunityIcons name={showConfirmPassword ? 'eye-off-outline' : 'eye-outline'} size={20} color={colors.text.hint} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 重置密码按钮 */}
|
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.submitButton, loading && styles.submitButtonDisabled]}
|
style={[styles.submitButton, loading && styles.submitButtonDisabled]}
|
||||||
onPress={handleResetPassword}
|
onPress={handleResetPassword}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
activeOpacity={0.9}
|
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? <ActivityIndicator size="small" color="#fff" /> : <Text style={styles.submitButtonText}>重置密码</Text>}
|
||||||
<ActivityIndicator size="small" color={colors.text.inverse} />
|
|
||||||
) : (
|
|
||||||
<Text style={styles.submitButtonText}>重置密码</Text>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* 返回登录 */}
|
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.backButton}
|
|
||||||
onPress={() => router.back()}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons name="arrow-left" size={18} color={colors.primary.main} style={{ marginRight: 4 }} />
|
|
||||||
<Text style={styles.backButtonText}>返回登录</Text>
|
<Text style={styles.backButtonText}>返回登录</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
</LinearGradient>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function createForgotPasswordStyles(colors: AppColors) {
|
|
||||||
return StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
},
|
|
||||||
keyboardView: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
scrollContent: {
|
|
||||||
flexGrow: 1,
|
|
||||||
paddingHorizontal: 28,
|
|
||||||
paddingTop: 50,
|
|
||||||
paddingBottom: 40,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
flex: 1,
|
|
||||||
maxWidth: 400,
|
|
||||||
width: '100%',
|
|
||||||
alignSelf: 'center',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 标题区域
|
|
||||||
titleSection: {
|
|
||||||
marginBottom: 40,
|
|
||||||
marginTop: 20,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: '700',
|
|
||||||
color: colors.text.primary,
|
|
||||||
lineHeight: 40,
|
|
||||||
},
|
|
||||||
underline: {
|
|
||||||
width: 40,
|
|
||||||
height: 4,
|
|
||||||
backgroundColor: colors.primary.main,
|
|
||||||
borderRadius: 2,
|
|
||||||
marginTop: 12,
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: 16,
|
|
||||||
color: colors.text.secondary,
|
|
||||||
},
|
|
||||||
|
|
||||||
// 表单区域
|
|
||||||
formSection: {
|
|
||||||
marginBottom: 24,
|
|
||||||
},
|
|
||||||
|
|
||||||
// 输入框
|
|
||||||
inputContainer: {
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
inputLabel: {
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: colors.text.secondary,
|
|
||||||
marginBottom: 10,
|
|
||||||
},
|
|
||||||
inputWrapper: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: colors.background.default,
|
|
||||||
borderRadius: 14,
|
|
||||||
paddingHorizontal: 18,
|
|
||||||
height: 56,
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 16,
|
|
||||||
color: colors.text.primary,
|
|
||||||
height: 56,
|
|
||||||
},
|
|
||||||
checkIcon: {
|
|
||||||
marginLeft: 8,
|
|
||||||
},
|
|
||||||
eyeButton: {
|
|
||||||
padding: 4,
|
|
||||||
marginLeft: 4,
|
|
||||||
},
|
|
||||||
|
|
||||||
// 验证码行
|
|
||||||
codeRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
codeInputWrapper: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
sendCodeButton: {
|
|
||||||
height: 56,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
backgroundColor: colors.primary.light + '20',
|
|
||||||
borderRadius: 14,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
minWidth: 100,
|
|
||||||
},
|
|
||||||
sendCodeButtonDisabled: {
|
|
||||||
opacity: 0.5,
|
|
||||||
},
|
|
||||||
sendCodeButtonText: {
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.primary.main,
|
|
||||||
},
|
|
||||||
|
|
||||||
// 提交按钮
|
|
||||||
submitButton: {
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 14,
|
|
||||||
backgroundColor: colors.primary.main,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
submitButtonDisabled: {
|
|
||||||
opacity: 0.7,
|
|
||||||
},
|
|
||||||
submitButtonText: {
|
|
||||||
color: colors.text.inverse,
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 返回按钮
|
|
||||||
backButton: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginTop: 24,
|
|
||||||
},
|
|
||||||
backButtonText: {
|
|
||||||
color: colors.primary.main,
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default ForgotPasswordScreen;
|
export default ForgotPasswordScreen;
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* 登录页 LoginScreen(简洁表单样式)
|
* 登录页 LoginScreen(响应式适配 - 分栏布局)
|
||||||
* 胡萝卜BBS - 用户登录
|
* 胡萝卜BBS - 用户登录
|
||||||
*
|
*
|
||||||
* 设计风格:
|
* 布局规则:
|
||||||
* - 纯白背景,扁平化设计
|
* - 屏幕宽度 < 768px:单栏布局,橙色渐变背景
|
||||||
* - 增大间距,避免页面太空
|
* - 屏幕宽度 >= 768px:分栏布局,左侧橙色右侧中性灰
|
||||||
* - 输入框带灰色背景填充
|
|
||||||
* - 橙色圆角按钮
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
@@ -26,45 +24,83 @@ import {
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { useAppColors, useResolvedColorScheme, type AppColors } from '../../theme';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||||
import { useAuthStore } from '../../stores';
|
import { useAuthStore } from '../../stores';
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
import { useResponsive } from '../../hooks';
|
import { useResponsive, useResponsiveValue } from '../../hooks';
|
||||||
import { showPrompt } from '../../services/promptService';
|
import { showPrompt } from '../../services/promptService';
|
||||||
|
|
||||||
|
// 分栏布局断点
|
||||||
|
const SPLIT_BREAKPOINT = 768;
|
||||||
|
|
||||||
export const LoginScreen: React.FC = () => {
|
export const LoginScreen: React.FC = () => {
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const resolved = useResolvedColorScheme();
|
|
||||||
const styles = useMemo(() => createLoginStyles(colors), [colors]);
|
const styles = useMemo(() => createLoginStyles(colors), [colors]);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const login = useAuthStore((state) => state.login);
|
const login = useAuthStore((state) => state.login);
|
||||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
|
const storeError = useAuthStore((state) => state.error);
|
||||||
const setStoreError = useAuthStore((state) => state.setError);
|
const setStoreError = useAuthStore((state) => state.setError);
|
||||||
|
|
||||||
const { isLandscape } = useResponsive();
|
// 响应式布局
|
||||||
|
const { isLandscape, width } = useResponsive();
|
||||||
|
|
||||||
|
// 是否使用分栏布局
|
||||||
|
const isSplitLayout = width >= SPLIT_BREAKPOINT;
|
||||||
|
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||||
|
|
||||||
// 动画值
|
// 动画值
|
||||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||||
const slideAnim = useRef(new Animated.Value(20)).current;
|
const slideAnim = useRef(new Animated.Value(50)).current;
|
||||||
|
const scaleAnim = useRef(new Animated.Value(0.9)).current;
|
||||||
|
const inputFadeAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
|
||||||
|
// 响应式值 - 大屏模式单独放大Logo尺寸,更显大气
|
||||||
|
const logoSize = useResponsiveValue({
|
||||||
|
xs: 64, sm: 72, md: 80,
|
||||||
|
lg: isSplitLayout ? 140 : 96, // 大屏模式Logo图标进一步放大
|
||||||
|
xl: isSplitLayout ? 160 : 110
|
||||||
|
});
|
||||||
|
const logoContainerSize = useResponsiveValue({
|
||||||
|
xs: 110, sm: 120, md: 130,
|
||||||
|
lg: isSplitLayout ? 200 : 150, // 大屏模式Logo容器进一步放大
|
||||||
|
xl: isSplitLayout ? 220 : 170
|
||||||
|
});
|
||||||
|
const appNameSize = useResponsiveValue({
|
||||||
|
xs: 28, sm: 30, md: 32,
|
||||||
|
lg: isSplitLayout ? 60 : 36, // 大屏模式标题文字大幅放大
|
||||||
|
xl: isSplitLayout ? 72 : 40
|
||||||
|
});
|
||||||
|
const formMaxWidth = isSplitLayout ? 420 : undefined;
|
||||||
|
|
||||||
// 启动入场动画
|
// 启动入场动画
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
Animated.sequence([
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
Animated.timing(fadeAnim, {
|
Animated.timing(fadeAnim, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
duration: 400,
|
duration: 600,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}),
|
}),
|
||||||
|
Animated.timing(scaleAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 600,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]),
|
||||||
Animated.timing(slideAnim, {
|
Animated.timing(slideAnim, {
|
||||||
toValue: 0,
|
toValue: 0,
|
||||||
|
duration: 500,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(inputFadeAnim, {
|
||||||
|
toValue: 1,
|
||||||
duration: 400,
|
duration: 400,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}),
|
}),
|
||||||
@@ -81,13 +117,20 @@ export const LoginScreen: React.FC = () => {
|
|||||||
showPrompt({ title: '提示', message: '请输入密码', type: 'warning' });
|
showPrompt({ title: '提示', message: '请输入密码', type: 'warning' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!agreedToTerms) {
|
if (password.length < 6) {
|
||||||
showPrompt({ title: '提示', message: '请同意用户协议和隐私政策', type: 'warning' });
|
showPrompt({ title: '提示', message: '密码长度不能少于6位', type: 'warning' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// store error 同步到本地
|
||||||
|
useEffect(() => {
|
||||||
|
if (storeError) {
|
||||||
|
setErrorMsg(storeError);
|
||||||
|
}
|
||||||
|
}, [storeError]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
router.replace(hrefs.hrefHome());
|
router.replace(hrefs.hrefHome());
|
||||||
@@ -95,6 +138,7 @@ export const LoginScreen: React.FC = () => {
|
|||||||
}, [isAuthenticated, router]);
|
}, [isAuthenticated, router]);
|
||||||
|
|
||||||
const clearError = () => {
|
const clearError = () => {
|
||||||
|
setErrorMsg(null);
|
||||||
setStoreError(null);
|
setStoreError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -108,11 +152,11 @@ export const LoginScreen: React.FC = () => {
|
|||||||
const success = await login({ username, password });
|
const success = await login({ username, password });
|
||||||
if (!success) {
|
if (!success) {
|
||||||
if (!useAuthStore.getState().error) {
|
if (!useAuthStore.getState().error) {
|
||||||
showPrompt({ title: '登录失败', message: '请检查用户名和密码', type: 'error' });
|
setErrorMsg('登录失败,请稍后重试');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
showPrompt({ title: '登录失败', message: error.message || '请检查用户名和密码', type: 'error' });
|
setErrorMsg(error.message || '登录失败,请检查用户名和密码');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -123,46 +167,86 @@ export const LoginScreen: React.FC = () => {
|
|||||||
router.push(hrefs.hrefAuthRegister());
|
router.push(hrefs.hrefAuthRegister());
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
// 渲染左侧面板(Logo和品牌信息)- 优化大屏布局
|
||||||
<SafeAreaView style={styles.container}>
|
const renderLeftPanel = () => (
|
||||||
<StatusBar barStyle={resolved === 'dark' ? 'light-content' : 'dark-content'} backgroundColor={colors.background.paper} />
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
||||||
style={styles.keyboardView}
|
|
||||||
>
|
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={[
|
|
||||||
styles.scrollContent,
|
|
||||||
isLandscape && styles.scrollContentLandscape,
|
|
||||||
]}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
>
|
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.content,
|
styles.leftPanel,
|
||||||
|
isSplitLayout && styles.splitLeftPanel, // 大屏模式专用布局
|
||||||
{
|
{
|
||||||
opacity: fadeAnim,
|
opacity: fadeAnim,
|
||||||
|
transform: [{ scale: scaleAnim }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{/* 胡萝卜图标 - 靠上显示 */}
|
||||||
|
<View style={[
|
||||||
|
styles.logoContainer,
|
||||||
|
styles.logoContainerTop,
|
||||||
|
isSplitLayout && styles.splitLogoContainer, // 大屏模式Logo容器样式
|
||||||
|
{ width: logoContainerSize, height: logoContainerSize, borderRadius: logoContainerSize / 2 }
|
||||||
|
]}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="carrot"
|
||||||
|
size={logoSize}
|
||||||
|
color="#FFF"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 品牌名称 - 优化大屏排版 */}
|
||||||
|
<Text style={[styles.appName, isSplitLayout && styles.splitAppName]}>萝卜社区</Text>
|
||||||
|
<Text style={[styles.subtitle, isSplitLayout && styles.splitSubtitle]}>发现有趣的内容,结识志同道合的朋友</Text>
|
||||||
|
|
||||||
|
{/* 装饰元素 - 大屏模式改为水平排列 + 大幅放大 */}
|
||||||
|
<View style={[styles.leftPanelDecorBottom, isSplitLayout && styles.splitLeftPanelDecorBottom]}>
|
||||||
|
{isSplitLayout ? (
|
||||||
|
// 大屏模式:水平排列 + 大幅放大图标
|
||||||
|
<View style={styles.splitDecorRow}>
|
||||||
|
<MaterialCommunityIcons name="chat-outline" size={80} color="rgba(255,255,255,0.4)" style={styles.splitDecorIcon} />
|
||||||
|
<MaterialCommunityIcons name="account-group-outline" size={90} color="rgba(255,255,255,0.45)" style={styles.splitDecorIcon} />
|
||||||
|
<MaterialCommunityIcons name="bell-outline" size={85} color="rgba(255,255,255,0.35)" style={styles.splitDecorIcon} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
// 小屏模式:保持原有横向排列
|
||||||
|
<View style={styles.decorRow}>
|
||||||
|
<MaterialCommunityIcons name="chat-outline" size={48} color="rgba(255,255,255,0.35)" />
|
||||||
|
<MaterialCommunityIcons name="account-group-outline" size={44} color="rgba(255,255,255,0.3)" />
|
||||||
|
<MaterialCommunityIcons name="bell-outline" size={40} color="rgba(255,255,255,0.25)" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 渲染表单内容
|
||||||
|
const renderFormContent = () => (
|
||||||
|
<View style={isSplitLayout ? styles.splitFormWrapper : styles.singleFormWrapper}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.formCard,
|
||||||
|
// 核心修改:大屏模式使用专用的阴影样式,小屏用原有阴影
|
||||||
|
isSplitLayout ? styles.splitFormCardShadow : styles.normalFormCardShadow,
|
||||||
|
formMaxWidth && { maxWidth: formMaxWidth, width: '100%' },
|
||||||
|
{
|
||||||
|
opacity: inputFadeAnim,
|
||||||
transform: [{ translateY: slideAnim }],
|
transform: [{ translateY: slideAnim }],
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{/* 标题区域 - 增大间距 */}
|
<Text style={styles.formTitle}>欢迎回来</Text>
|
||||||
<View style={styles.titleSection}>
|
|
||||||
<Text style={styles.title}>欢迎回来</Text>
|
|
||||||
<View style={styles.underline} />
|
|
||||||
<Text style={styles.subtitle}>请登录您的账号</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 表单区域 */}
|
|
||||||
<View style={styles.formSection}>
|
|
||||||
{/* 用户名输入框 */}
|
{/* 用户名输入框 */}
|
||||||
<View style={styles.inputContainer}>
|
<View style={styles.inputContainer}>
|
||||||
<Text style={styles.inputLabel}>用户名</Text>
|
|
||||||
<View style={styles.inputWrapper}>
|
<View style={styles.inputWrapper}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="account-outline"
|
||||||
|
size={22}
|
||||||
|
color={colors.primary.main}
|
||||||
|
style={styles.inputIcon}
|
||||||
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="请输入用户名"
|
placeholder="用户名 / 邮箱 / 手机号"
|
||||||
placeholderTextColor={colors.text.hint}
|
placeholderTextColor={colors.text.hint}
|
||||||
value={username}
|
value={username}
|
||||||
onChangeText={(v) => { setUsername(v); clearError(); }}
|
onChangeText={(v) => { setUsername(v); clearError(); }}
|
||||||
@@ -171,15 +255,29 @@ export const LoginScreen: React.FC = () => {
|
|||||||
returnKeyType="next"
|
returnKeyType="next"
|
||||||
/>
|
/>
|
||||||
{username.length > 0 && (
|
{username.length > 0 && (
|
||||||
<MaterialCommunityIcons name="check-circle" size={20} color={colors.primary.main} style={styles.checkIcon} />
|
<TouchableOpacity
|
||||||
|
onPress={() => setUsername('')}
|
||||||
|
style={styles.clearButton}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="close-circle"
|
||||||
|
size={20}
|
||||||
|
color={colors.text.hint}
|
||||||
|
/>
|
||||||
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 密码输入框 */}
|
{/* 密码输入框 */}
|
||||||
<View style={styles.inputContainer}>
|
<View style={styles.inputContainer}>
|
||||||
<Text style={styles.inputLabel}>密码</Text>
|
|
||||||
<View style={styles.inputWrapper}>
|
<View style={styles.inputWrapper}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="lock-outline"
|
||||||
|
size={22}
|
||||||
|
color={colors.primary.main}
|
||||||
|
style={styles.inputIcon}
|
||||||
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="请输入密码"
|
placeholder="请输入密码"
|
||||||
@@ -193,7 +291,7 @@ export const LoginScreen: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => setShowPassword(!showPassword)}
|
onPress={() => setShowPassword(!showPassword)}
|
||||||
style={styles.eyeButton}
|
style={styles.clearButton}
|
||||||
>
|
>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
|
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
|
||||||
@@ -212,171 +310,343 @@ export const LoginScreen: React.FC = () => {
|
|||||||
<Text style={styles.forgotPasswordText}>忘记密码?</Text>
|
<Text style={styles.forgotPasswordText}>忘记密码?</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* 服务条款勾选 */}
|
{/* 内联错误提示 */}
|
||||||
<View style={styles.termsContainer}>
|
{errorMsg && (
|
||||||
<TouchableOpacity
|
<View style={styles.errorBox}>
|
||||||
onPress={() => setAgreedToTerms(!agreedToTerms)}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
style={styles.checkboxWrapper}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name={agreedToTerms ? 'checkbox-marked' : 'checkbox-blank-outline'}
|
name="alert-circle-outline"
|
||||||
size={22}
|
size={16}
|
||||||
color={agreedToTerms ? colors.primary.main : colors.text.hint}
|
color={colors.error.dark}
|
||||||
|
style={{ marginRight: 6, marginTop: 1 }}
|
||||||
/>
|
/>
|
||||||
</TouchableOpacity>
|
<Text style={styles.errorText}>{errorMsg}</Text>
|
||||||
<Text style={styles.termsText}>
|
|
||||||
我已阅读并同意
|
|
||||||
<Text
|
|
||||||
style={styles.termsLink}
|
|
||||||
onPress={() => router.push(hrefs.hrefProfileTerms())}
|
|
||||||
>
|
|
||||||
《用户协议》
|
|
||||||
</Text>
|
|
||||||
和
|
|
||||||
<Text
|
|
||||||
style={styles.termsLink}
|
|
||||||
onPress={() => router.push(hrefs.hrefProfilePrivacy())}
|
|
||||||
>
|
|
||||||
《隐私政策》
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 登录按钮 */}
|
{/* 登录按钮 */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.loginButton, loading && styles.loginButtonDisabled]}
|
style={[styles.loginButton, loading && styles.loginButtonDisabled]}
|
||||||
onPress={handleLogin}
|
onPress={handleLogin}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
activeOpacity={0.9}
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colors.primary.main, colors.primary.light]}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 0 }}
|
||||||
|
style={styles.loginButtonGradient}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ActivityIndicator size="small" color={colors.text.inverse} />
|
<ActivityIndicator size="small" color="#FFF" />
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.loginButtonText}>登 录</Text>
|
<Text style={styles.loginButtonText}>登 录</Text>
|
||||||
)}
|
)}
|
||||||
|
</LinearGradient>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* 底部注册提示 */}
|
{/* 底部注册提示 */}
|
||||||
<View style={styles.footer}>
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.footerSection,
|
||||||
|
{ opacity: inputFadeAnim },
|
||||||
|
]}
|
||||||
|
>
|
||||||
<Text style={styles.footerText}>还没有账号?</Text>
|
<Text style={styles.footerText}>还没有账号?</Text>
|
||||||
<TouchableOpacity onPress={handleGoToRegister}>
|
<TouchableOpacity onPress={handleGoToRegister}>
|
||||||
<Text style={styles.registerLink}>立即注册</Text>
|
<Text style={styles.registerLink}>立即注册</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
|
||||||
|
|
||||||
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 渲染分栏布局
|
||||||
|
const renderSplitLayout = () => (
|
||||||
|
<View style={styles.splitContainer}>
|
||||||
|
{/* 左侧橙色面板 */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colors.primary.main, colors.primary.light, '#FFB088']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.leftPanelContainer}
|
||||||
|
>
|
||||||
|
{/* 装饰性背景元素 */}
|
||||||
|
<View style={styles.decorCircle1} />
|
||||||
|
<View style={styles.decorCircle2} />
|
||||||
|
|
||||||
|
<StatusBar barStyle="light-content" />
|
||||||
|
<SafeAreaView style={styles.leftPanelSafeArea}>
|
||||||
|
{renderLeftPanel()}
|
||||||
|
</SafeAreaView>
|
||||||
|
</LinearGradient>
|
||||||
|
|
||||||
|
{/* 右侧中性灰面板 */}
|
||||||
|
<View style={styles.rightPanelContainer}>
|
||||||
|
<StatusBar barStyle="dark-content" />
|
||||||
|
<SafeAreaView style={styles.rightPanelSafeArea}>
|
||||||
|
{/* 核心修复:用View包裹,确保垂直+水平居中 */}
|
||||||
|
<View style={styles.splitRightContentWrapper}>
|
||||||
|
{renderFormContent()}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 渲染单栏布局
|
||||||
|
const renderSingleLayout = () => (
|
||||||
|
<SafeAreaView style={styles.container}>
|
||||||
|
<StatusBar barStyle="light-content" />
|
||||||
|
<LinearGradient
|
||||||
|
colors={[colors.primary.main, colors.primary.light, '#FFB088']}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
style={styles.gradient}
|
||||||
|
>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
style={styles.keyboardView}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.scrollContent,
|
||||||
|
isLandscape && styles.scrollContentLandscape,
|
||||||
|
]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
{/* 装饰性背景元素 */}
|
||||||
|
<View style={styles.decorCircle1} />
|
||||||
|
<View style={styles.decorCircle2} />
|
||||||
|
|
||||||
|
{/* 头部 */}
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.headerSection,
|
||||||
|
isLandscape && styles.headerSectionLandscape,
|
||||||
|
{
|
||||||
|
opacity: fadeAnim,
|
||||||
|
transform: [{ scale: scaleAnim }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={[
|
||||||
|
styles.logoContainer,
|
||||||
|
{ width: logoContainerSize, height: logoContainerSize, borderRadius: logoContainerSize / 2 }
|
||||||
|
]}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="carrot"
|
||||||
|
size={logoSize}
|
||||||
|
color="#FFF"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={[styles.appName, { fontSize: appNameSize }]}>萝卜社区</Text>
|
||||||
|
<Text style={styles.subtitle}>发现有趣的内容,结识志同道合的朋友</Text>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* 表单 */}
|
||||||
|
{renderFormContent()}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
|
</LinearGradient>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return isSplitLayout ? renderSplitLayout() : renderSingleLayout();
|
||||||
};
|
};
|
||||||
|
|
||||||
function createLoginStyles(colors: AppColors) {
|
function createLoginStyles(colors: AppColors) {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: colors.background.paper,
|
},
|
||||||
|
gradient: {
|
||||||
|
flex: 1,
|
||||||
},
|
},
|
||||||
keyboardView: {
|
keyboardView: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
// 单栏布局内容
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
paddingHorizontal: 28,
|
paddingHorizontal: spacing.xl,
|
||||||
paddingTop: 60,
|
paddingVertical: spacing['2xl'],
|
||||||
paddingBottom: 40,
|
},
|
||||||
|
scrollContentCentered: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
flex: 1,
|
||||||
},
|
},
|
||||||
scrollContentLandscape: {
|
scrollContentLandscape: {
|
||||||
paddingTop: 30,
|
paddingVertical: spacing.lg,
|
||||||
},
|
},
|
||||||
content: {
|
// 装饰性背景元素
|
||||||
flex: 1,
|
decorCircle1: {
|
||||||
maxWidth: 400,
|
position: 'absolute',
|
||||||
width: '100%',
|
top: -100,
|
||||||
alignSelf: 'center',
|
right: -100,
|
||||||
|
width: 300,
|
||||||
|
height: 300,
|
||||||
|
borderRadius: 150,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||||
},
|
},
|
||||||
|
decorCircle2: {
|
||||||
// 标题区域 - 增大间距
|
position: 'absolute',
|
||||||
titleSection: {
|
bottom: 100,
|
||||||
marginBottom: 48,
|
left: -150,
|
||||||
marginTop: 20,
|
width: 250,
|
||||||
|
height: 250,
|
||||||
|
borderRadius: 125,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||||
},
|
},
|
||||||
title: {
|
// 头部区域
|
||||||
fontSize: 32,
|
headerSection: {
|
||||||
fontWeight: '700',
|
alignItems: 'center',
|
||||||
color: colors.text.primary,
|
marginTop: spacing['3xl'],
|
||||||
lineHeight: 40,
|
marginBottom: spacing['3xl'],
|
||||||
},
|
},
|
||||||
underline: {
|
headerSectionLandscape: {
|
||||||
width: 40,
|
marginTop: spacing.lg,
|
||||||
height: 4,
|
marginBottom: spacing.lg,
|
||||||
backgroundColor: colors.primary.main,
|
},
|
||||||
borderRadius: 2,
|
logoContainer: {
|
||||||
marginTop: 12,
|
backgroundColor: 'rgba(255,255,255,0.25)',
|
||||||
marginBottom: 16,
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: 'rgba(255,255,255,0.4)',
|
||||||
|
...shadows.md,
|
||||||
|
},
|
||||||
|
// 大屏模式Logo容器样式 - 增加阴影和内边距,更立体
|
||||||
|
splitLogoContainer: {
|
||||||
|
borderWidth: 3,
|
||||||
|
borderColor: 'rgba(255,255,255,0.5)',
|
||||||
|
...shadows.lg,
|
||||||
|
},
|
||||||
|
logoContainerTop: {
|
||||||
|
marginTop: spacing['3xl'],
|
||||||
|
},
|
||||||
|
appName: {
|
||||||
|
fontWeight: '800',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
textShadowColor: 'rgba(0,0,0,0.1)',
|
||||||
|
textShadowOffset: { width: 0, height: 2 },
|
||||||
|
textShadowRadius: 4,
|
||||||
|
},
|
||||||
|
// 大屏模式标题样式 - 大幅放大,增加间距和文字效果
|
||||||
|
splitAppName: {
|
||||||
|
fontSize: 72, // 大幅放大主标题
|
||||||
|
marginBottom: spacing['2xl'],
|
||||||
|
textShadowOffset: { width: 0, height: 4 },
|
||||||
|
textShadowRadius: 8,
|
||||||
|
letterSpacing: 4, // 增加字间距,更显大气
|
||||||
|
fontWeight: '900',
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
fontSize: 16,
|
fontSize: fontSizes.md,
|
||||||
color: colors.text.secondary,
|
color: 'rgba(255,255,255,0.9)',
|
||||||
|
textAlign: 'center',
|
||||||
|
paddingHorizontal: spacing.xl,
|
||||||
},
|
},
|
||||||
|
// 大屏模式副标题样式 - 大幅放大
|
||||||
// 表单区域
|
splitSubtitle: {
|
||||||
formSection: {
|
fontSize: 24, // 放大副标题
|
||||||
marginBottom: 32,
|
marginBottom: spacing['6xl'], // 增加底部间距
|
||||||
},
|
lineHeight: 36,
|
||||||
|
paddingHorizontal: spacing['4xl'],
|
||||||
// 输入框
|
letterSpacing: 1.5,
|
||||||
inputContainer: {
|
|
||||||
marginBottom: 24,
|
|
||||||
},
|
|
||||||
inputLabel: {
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
color: colors.text.secondary,
|
},
|
||||||
marginBottom: 10,
|
// 表单卡片(基础样式,无阴影)
|
||||||
|
formCard: {
|
||||||
|
backgroundColor: colors.background.paper,
|
||||||
|
borderRadius: borderRadius['2xl'],
|
||||||
|
padding: spacing['2xl'],
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
// 小屏模式:原有默认阴影
|
||||||
|
normalFormCardShadow: {
|
||||||
|
...shadows.md,
|
||||||
|
},
|
||||||
|
// 大屏模式:专用全向阴影
|
||||||
|
splitFormCardShadow: {
|
||||||
|
// iOS 全向柔和阴影
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.12,
|
||||||
|
shadowRadius: 12,
|
||||||
|
// Android 高程 + 轻微边框(模拟顶部阴影)
|
||||||
|
elevation: 8,
|
||||||
|
// 新增:极细微的顶部边框,强化区分度
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: 'rgba(0,0,0,0.05)',
|
||||||
|
},
|
||||||
|
formTitle: {
|
||||||
|
fontSize: fontSizes['2xl'],
|
||||||
|
fontWeight: '700',
|
||||||
|
color: colors.text.primary,
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
inputContainer: {
|
||||||
|
marginBottom: spacing.lg,
|
||||||
},
|
},
|
||||||
inputWrapper: {
|
inputWrapper: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: colors.background.default,
|
backgroundColor: colors.background.default,
|
||||||
borderRadius: 14,
|
borderRadius: borderRadius.lg,
|
||||||
paddingHorizontal: 18,
|
borderWidth: 1.5,
|
||||||
|
borderColor: colors.divider,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
height: 56,
|
height: 56,
|
||||||
},
|
},
|
||||||
|
inputIcon: {
|
||||||
|
marginRight: spacing.sm,
|
||||||
|
},
|
||||||
input: {
|
input: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: 16,
|
fontSize: fontSizes.md,
|
||||||
color: colors.text.primary,
|
color: colors.text.primary,
|
||||||
height: 56,
|
|
||||||
},
|
},
|
||||||
checkIcon: {
|
clearButton: {
|
||||||
marginLeft: 8,
|
padding: spacing.xs,
|
||||||
},
|
},
|
||||||
eyeButton: {
|
|
||||||
padding: 4,
|
|
||||||
marginLeft: 4,
|
|
||||||
},
|
|
||||||
|
|
||||||
// 忘记密码
|
|
||||||
forgotPassword: {
|
forgotPassword: {
|
||||||
alignSelf: 'flex-end',
|
alignSelf: 'flex-end',
|
||||||
marginBottom: 28,
|
marginBottom: spacing.md,
|
||||||
marginTop: -8,
|
|
||||||
},
|
},
|
||||||
forgotPasswordText: {
|
forgotPasswordText: {
|
||||||
fontSize: 14,
|
fontSize: fontSizes.sm,
|
||||||
color: colors.primary.main,
|
color: colors.primary.main,
|
||||||
fontWeight: '500',
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
errorBox: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
backgroundColor: colors.accent.light,
|
||||||
|
borderRadius: borderRadius.md,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
borderLeftWidth: 3,
|
||||||
|
borderLeftColor: colors.accent.main,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
flex: 1,
|
||||||
|
fontSize: fontSizes.sm,
|
||||||
|
color: colors.accent.dark,
|
||||||
|
lineHeight: 20,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 登录按钮
|
|
||||||
loginButton: {
|
loginButton: {
|
||||||
height: 56,
|
borderRadius: borderRadius.lg,
|
||||||
borderRadius: 14,
|
overflow: 'hidden',
|
||||||
backgroundColor: colors.primary.main,
|
...shadows.md,
|
||||||
|
},
|
||||||
|
loginButtonGradient: {
|
||||||
|
height: 52,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
@@ -384,59 +654,116 @@ function createLoginStyles(colors: AppColors) {
|
|||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
},
|
},
|
||||||
loginButtonText: {
|
loginButtonText: {
|
||||||
fontSize: 17,
|
fontSize: fontSizes.lg,
|
||||||
fontWeight: '600',
|
fontWeight: '700',
|
||||||
color: colors.text.inverse,
|
color: '#FFFFFF',
|
||||||
},
|
},
|
||||||
|
// 底部区域
|
||||||
// 底部
|
footerSection: {
|
||||||
footer: {
|
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginTop: 40,
|
paddingTop: spacing.xl,
|
||||||
},
|
},
|
||||||
footerText: {
|
footerText: {
|
||||||
fontSize: 15,
|
fontSize: fontSizes.md,
|
||||||
color: colors.text.secondary,
|
color: colors.text.primary,
|
||||||
},
|
},
|
||||||
registerLink: {
|
registerLink: {
|
||||||
fontSize: 15,
|
fontSize: fontSizes.md,
|
||||||
color: colors.primary.main,
|
color: colors.primary.main,
|
||||||
fontWeight: '600',
|
fontWeight: '700',
|
||||||
marginLeft: 6,
|
marginLeft: spacing.xs,
|
||||||
|
textDecorationLine: 'underline',
|
||||||
},
|
},
|
||||||
policyFooter: {
|
// ========== 分栏布局样式 ==========
|
||||||
alignItems: 'center',
|
splitContainer: {
|
||||||
marginTop: 24,
|
|
||||||
},
|
|
||||||
policyText: {
|
|
||||||
fontSize: 13,
|
|
||||||
color: colors.text.hint,
|
|
||||||
},
|
|
||||||
policyLink: {
|
|
||||||
color: colors.primary.main,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
termsContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
marginBottom: 20,
|
|
||||||
marginTop: 4,
|
|
||||||
},
|
|
||||||
checkboxWrapper: {
|
|
||||||
paddingTop: 2,
|
|
||||||
},
|
|
||||||
termsText: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: colors.text.secondary,
|
|
||||||
marginLeft: 10,
|
|
||||||
flex: 1,
|
flex: 1,
|
||||||
lineHeight: 22,
|
flexDirection: 'row',
|
||||||
},
|
},
|
||||||
termsLink: {
|
leftPanelContainer: {
|
||||||
color: colors.primary.main,
|
flex: 1, // 50%
|
||||||
fontWeight: '500',
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
leftPanelSafeArea: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: spacing['4xl'], // 增加内边距,不贴边
|
||||||
|
},
|
||||||
|
leftPanel: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: spacing['2xl'],
|
||||||
|
},
|
||||||
|
// 大屏模式左侧面板布局
|
||||||
|
splitLeftPanel: {
|
||||||
|
width: '100%',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingVertical: spacing['6xl'],
|
||||||
|
},
|
||||||
|
leftPanelDecor: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: spacing['4xl'],
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
leftPanelDecorBottom: {
|
||||||
|
marginTop: spacing['2xl'],
|
||||||
|
},
|
||||||
|
// 大屏模式装饰元素布局
|
||||||
|
splitLeftPanelDecorBottom: {
|
||||||
|
marginTop: spacing['6xl'],
|
||||||
|
width: '100%',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
// 大屏模式装饰元素水平排列
|
||||||
|
splitDecorRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
width: '100%',
|
||||||
|
gap: spacing['2xl'], // 增加图标间距
|
||||||
|
},
|
||||||
|
// 大屏模式装饰图标样式
|
||||||
|
splitDecorIcon: {
|
||||||
|
transform: [{ scale: 1.1 }],
|
||||||
|
opacity: 0.9,
|
||||||
|
},
|
||||||
|
decorRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-around',
|
||||||
|
width: '80%',
|
||||||
|
},
|
||||||
|
rightPanelContainer: {
|
||||||
|
flex: 1, // 50%
|
||||||
|
backgroundColor: colors.background.paper,
|
||||||
|
},
|
||||||
|
rightPanelSafeArea: {
|
||||||
|
flex: 1,
|
||||||
|
// 移除左右内边距,交给子容器控制
|
||||||
|
},
|
||||||
|
// 分栏布局右侧内容包裹器(核心修复)
|
||||||
|
splitRightContentWrapper: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
// 新增:左右内边距,保证表单和边缘有间距
|
||||||
|
paddingHorizontal: spacing['2xl'],
|
||||||
|
},
|
||||||
|
// 表单内容包裹器(区分大屏/小屏)
|
||||||
|
splitFormWrapper: {
|
||||||
|
// 关键修改:设置最大宽度 + 自适应宽度,实现水平居中
|
||||||
|
maxWidth: 420,
|
||||||
|
width: '100%',
|
||||||
|
},
|
||||||
|
singleFormWrapper: {
|
||||||
|
flexGrow: 1,
|
||||||
|
width: '100%',
|
||||||
|
// 移除这里的阴影,避免重复
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,186 +0,0 @@
|
|||||||
/**
|
|
||||||
* 注册步骤1:输入邮箱
|
|
||||||
* 包含邮箱输入框和获取验证码按钮
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
StyleSheet,
|
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
|
||||||
} from 'react-native';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
||||||
import { useAppColors, type AppColors } from '../../theme';
|
|
||||||
import { RegisterStepProps } from './types';
|
|
||||||
|
|
||||||
export const RegisterStep1Email: React.FC<RegisterStepProps> = ({
|
|
||||||
formData,
|
|
||||||
updateFormData,
|
|
||||||
onNext,
|
|
||||||
colors: propColors,
|
|
||||||
sendingCode,
|
|
||||||
countdown,
|
|
||||||
onSendCode,
|
|
||||||
}) => {
|
|
||||||
const colors = propColors || useAppColors();
|
|
||||||
const styles = createStyles(colors);
|
|
||||||
|
|
||||||
const [emailError, setEmailError] = useState('');
|
|
||||||
|
|
||||||
const validateEmail = (email: string): boolean => {
|
|
||||||
if (!email.trim()) {
|
|
||||||
setEmailError('请输入邮箱');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
||||||
setEmailError('请输入正确的邮箱地址');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
setEmailError('');
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSendCode = async () => {
|
|
||||||
if (!validateEmail(formData.email)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (onSendCode) {
|
|
||||||
const success = await onSendCode(formData.email);
|
|
||||||
if (success) {
|
|
||||||
onNext();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<View style={styles.formSection}>
|
|
||||||
{/* 邮箱输入框 */}
|
|
||||||
<View style={styles.inputContainer}>
|
|
||||||
<Text style={styles.inputLabel}>邮箱</Text>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.inputWrapper,
|
|
||||||
emailError ? styles.inputWrapperError : null,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
placeholder="请输入邮箱地址"
|
|
||||||
placeholderTextColor={colors.text?.hint || '#999'}
|
|
||||||
value={formData.email}
|
|
||||||
onChangeText={(text) => {
|
|
||||||
updateFormData({ email: text });
|
|
||||||
if (emailError) setEmailError('');
|
|
||||||
}}
|
|
||||||
autoCapitalize="none"
|
|
||||||
autoCorrect={false}
|
|
||||||
keyboardType="email-address"
|
|
||||||
editable={!sendingCode}
|
|
||||||
/>
|
|
||||||
{formData.email.length > 0 &&
|
|
||||||
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email) && (
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="check-circle"
|
|
||||||
size={20}
|
|
||||||
color={colors.primary.main}
|
|
||||||
style={styles.checkIcon}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{emailError ? (
|
|
||||||
<Text style={styles.errorText}>{emailError}</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 获取验证码按钮 */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.actionButton,
|
|
||||||
(sendingCode || (countdown ?? 0) > 0) && styles.actionButtonDisabled,
|
|
||||||
]}
|
|
||||||
onPress={handleSendCode}
|
|
||||||
disabled={sendingCode || (countdown ?? 0) > 0}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
>
|
|
||||||
<Text style={styles.actionButtonText}>
|
|
||||||
{sendingCode
|
|
||||||
? '发送中...'
|
|
||||||
: (countdown ?? 0) > 0
|
|
||||||
? `${countdown}秒后重试`
|
|
||||||
: '获取验证码'}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createStyles(colors: AppColors) {
|
|
||||||
return StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
paddingTop: 20,
|
|
||||||
},
|
|
||||||
formSection: {
|
|
||||||
marginBottom: 24,
|
|
||||||
},
|
|
||||||
inputContainer: {
|
|
||||||
marginBottom: 24,
|
|
||||||
},
|
|
||||||
inputLabel: {
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: colors.text?.secondary || '#666',
|
|
||||||
marginBottom: 10,
|
|
||||||
},
|
|
||||||
inputWrapper: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: colors.background.default,
|
|
||||||
borderRadius: 14,
|
|
||||||
paddingHorizontal: 18,
|
|
||||||
height: 56,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'transparent',
|
|
||||||
},
|
|
||||||
inputWrapperError: {
|
|
||||||
borderColor: colors.error?.main || '#FF4444',
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 16,
|
|
||||||
color: colors.text?.primary || '#333',
|
|
||||||
height: 56,
|
|
||||||
},
|
|
||||||
checkIcon: {
|
|
||||||
marginLeft: 8,
|
|
||||||
},
|
|
||||||
errorText: {
|
|
||||||
fontSize: 12,
|
|
||||||
color: colors.error?.main || '#FF4444',
|
|
||||||
marginTop: 6,
|
|
||||||
marginLeft: 4,
|
|
||||||
},
|
|
||||||
actionButton: {
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 14,
|
|
||||||
backgroundColor: colors.primary.main,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginTop: 8,
|
|
||||||
},
|
|
||||||
actionButtonDisabled: {
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
actionButtonText: {
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.text.inverse,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default RegisterStep1Email;
|
|
||||||
@@ -1,201 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
StyleSheet,
|
|
||||||
TouchableOpacity,
|
|
||||||
} from 'react-native';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
||||||
import { useAppColors, type AppColors } from '../../theme';
|
|
||||||
import { VerificationCodeInput } from '../../components/common';
|
|
||||||
import { RegisterStepProps } from './types';
|
|
||||||
|
|
||||||
export const RegisterStep2Verification: React.FC<RegisterStepProps> = ({
|
|
||||||
formData,
|
|
||||||
updateFormData,
|
|
||||||
onNext,
|
|
||||||
onPrev,
|
|
||||||
colors: propColors,
|
|
||||||
countdown,
|
|
||||||
onSendCode,
|
|
||||||
}) => {
|
|
||||||
const colors = propColors || useAppColors();
|
|
||||||
const styles = createStyles(colors);
|
|
||||||
|
|
||||||
const [codeError, setCodeError] = useState('');
|
|
||||||
const [isVerifying, setIsVerifying] = useState(false);
|
|
||||||
|
|
||||||
const handleComplete = async (code: string) => {
|
|
||||||
if (code.length !== 6) {
|
|
||||||
setCodeError('请输入6位验证码');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setCodeError('');
|
|
||||||
setIsVerifying(true);
|
|
||||||
|
|
||||||
// 模拟验证,实际应该调用API验证验证码
|
|
||||||
// const success = await authService.verifyCode(formData.email, code);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsVerifying(false);
|
|
||||||
onNext();
|
|
||||||
}, 500);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleResendCode = async () => {
|
|
||||||
if (onSendCode && countdown === 0) {
|
|
||||||
await onSendCode(formData.email);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatCountdown = (seconds: number): string => {
|
|
||||||
const minutes = Math.floor(seconds / 60);
|
|
||||||
const secs = seconds % 60;
|
|
||||||
return `${minutes}:${secs.toString().padStart(2, '0')}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<View style={styles.emailInfo}>
|
|
||||||
<Text style={styles.emailText}>验证码已发送至 {formData.email}</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.formSection}>
|
|
||||||
{/* 验证码输入 */}
|
|
||||||
<View style={styles.codeInputContainer}>
|
|
||||||
<VerificationCodeInput
|
|
||||||
value={formData.verificationCode}
|
|
||||||
onChangeValue={(value) => {
|
|
||||||
updateFormData({ verificationCode: value });
|
|
||||||
if (codeError) setCodeError('');
|
|
||||||
}}
|
|
||||||
length={6}
|
|
||||||
autoFocus={true}
|
|
||||||
onComplete={handleComplete}
|
|
||||||
disabled={isVerifying}
|
|
||||||
/>
|
|
||||||
{codeError ? (
|
|
||||||
<Text style={styles.errorText}>{codeError}</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 重发倒计时 */}
|
|
||||||
<View style={styles.resendContainer}>
|
|
||||||
{(countdown ?? 0) > 0 ? (
|
|
||||||
<Text style={styles.countdownText}>
|
|
||||||
{formatCountdown(countdown!)} 后重新发送
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<TouchableOpacity onPress={handleResendCode} activeOpacity={0.8}>
|
|
||||||
<Text style={styles.resendText}>重新发送验证码</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 下一步按钮 */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[
|
|
||||||
styles.actionButton,
|
|
||||||
formData.verificationCode.length !== 6 && styles.actionButtonDisabled,
|
|
||||||
isVerifying && styles.actionButtonDisabled,
|
|
||||||
]}
|
|
||||||
onPress={() => handleComplete(formData.verificationCode)}
|
|
||||||
disabled={formData.verificationCode.length !== 6 || isVerifying}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
>
|
|
||||||
<Text style={styles.actionButtonText}>
|
|
||||||
{isVerifying ? '验证中...' : '下一步'}
|
|
||||||
</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{/* 返回按钮 */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.backButton}
|
|
||||||
onPress={onPrev}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="arrow-left"
|
|
||||||
size={18}
|
|
||||||
color={colors.text?.secondary || '#666'}
|
|
||||||
/>
|
|
||||||
<Text style={styles.backButtonText}>返回上一步</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createStyles(colors: AppColors) {
|
|
||||||
return StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
paddingTop: 20,
|
|
||||||
},
|
|
||||||
emailInfo: {
|
|
||||||
marginBottom: 32,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
emailText: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: colors.text?.secondary || '#666',
|
|
||||||
},
|
|
||||||
formSection: {
|
|
||||||
marginBottom: 24,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
codeInputContainer: {
|
|
||||||
marginBottom: 24,
|
|
||||||
width: '100%',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
errorText: {
|
|
||||||
fontSize: 12,
|
|
||||||
color: colors.error?.main || '#FF4444',
|
|
||||||
marginTop: 12,
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
resendContainer: {
|
|
||||||
marginBottom: 32,
|
|
||||||
},
|
|
||||||
countdownText: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: colors.text?.secondary || '#999',
|
|
||||||
},
|
|
||||||
resendText: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: colors.primary.main,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
actionButton: {
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 14,
|
|
||||||
backgroundColor: colors.primary.main,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
width: '100%',
|
|
||||||
maxWidth: 320,
|
|
||||||
},
|
|
||||||
actionButtonDisabled: {
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
actionButtonText: {
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.text.inverse,
|
|
||||||
},
|
|
||||||
backButton: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginTop: 20,
|
|
||||||
paddingVertical: 8,
|
|
||||||
},
|
|
||||||
backButtonText: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: colors.text?.secondary || '#666',
|
|
||||||
marginLeft: 6,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default RegisterStep2Verification;
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
StyleSheet,
|
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
|
||||||
ActivityIndicator,
|
|
||||||
} from 'react-native';
|
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
||||||
import { useAppColors, type AppColors } from '../../theme';
|
|
||||||
import { RegisterStepProps } from './types';
|
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
|
||||||
|
|
||||||
export const RegisterStep3Profile: React.FC<RegisterStepProps> = ({
|
|
||||||
formData,
|
|
||||||
updateFormData,
|
|
||||||
onNext,
|
|
||||||
onPrev,
|
|
||||||
colors: propColors,
|
|
||||||
loading,
|
|
||||||
}) => {
|
|
||||||
const colors = propColors || useAppColors();
|
|
||||||
const styles = createStyles(colors);
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
|
||||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
|
||||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
|
||||||
const newErrors: Record<string, string> = {};
|
|
||||||
|
|
||||||
if (!formData.username.trim()) {
|
|
||||||
newErrors.username = '请输入用户名';
|
|
||||||
} else if (formData.username.length < 3 || formData.username.length > 20) {
|
|
||||||
newErrors.username = '用户名长度需在3-20个字符之间';
|
|
||||||
} else if (!/^[a-zA-Z0-9_]+$/.test(formData.username)) {
|
|
||||||
newErrors.username = '用户名只能包含字母、数字和下划线';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.nickname.trim()) {
|
|
||||||
newErrors.nickname = '请输入昵称';
|
|
||||||
} else if (formData.nickname.length < 2 || formData.nickname.length > 20) {
|
|
||||||
newErrors.nickname = '昵称长度需在2-20个字符之间';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.password) {
|
|
||||||
newErrors.password = '请输入密码';
|
|
||||||
} else if (formData.password.length < 8) {
|
|
||||||
newErrors.password = '密码长度不能少于8位';
|
|
||||||
} else if (!/[A-Z]/.test(formData.password)) {
|
|
||||||
newErrors.password = '密码必须包含大写字母';
|
|
||||||
} else if (!/[a-z]/.test(formData.password)) {
|
|
||||||
newErrors.password = '密码必须包含小写字母';
|
|
||||||
} else if (!/[0-9]/.test(formData.password)) {
|
|
||||||
newErrors.password = '密码必须包含数字';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (formData.password !== formData.confirmPassword) {
|
|
||||||
newErrors.confirmPassword = '两次输入的密码不一致';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!agreedToTerms) {
|
|
||||||
newErrors.terms = '请同意用户协议和隐私政策';
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
|
||||||
return Object.keys(newErrors).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = () => {
|
|
||||||
if (validateForm()) {
|
|
||||||
onNext();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderInput = (
|
|
||||||
label: string,
|
|
||||||
field: keyof typeof formData,
|
|
||||||
placeholder: string,
|
|
||||||
options: {
|
|
||||||
secureTextEntry?: boolean;
|
|
||||||
keyboardType?: 'default' | 'email-address' | 'numeric' | 'phone-pad';
|
|
||||||
maxLength?: number;
|
|
||||||
autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters';
|
|
||||||
showToggle?: boolean;
|
|
||||||
toggleValue?: boolean;
|
|
||||||
onToggle?: () => void;
|
|
||||||
} = {}
|
|
||||||
) => {
|
|
||||||
const {
|
|
||||||
secureTextEntry = false,
|
|
||||||
keyboardType = 'default',
|
|
||||||
maxLength,
|
|
||||||
autoCapitalize = 'none',
|
|
||||||
showToggle,
|
|
||||||
toggleValue,
|
|
||||||
onToggle,
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
const error = errors[field as string];
|
|
||||||
const value = formData[field] || '';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.inputContainer}>
|
|
||||||
<Text style={styles.inputLabel}>{label}</Text>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.inputWrapper,
|
|
||||||
error ? styles.inputWrapperError : null,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
placeholder={placeholder}
|
|
||||||
placeholderTextColor={colors.text?.hint || '#999'}
|
|
||||||
value={value}
|
|
||||||
onChangeText={(text) => {
|
|
||||||
updateFormData({ [field]: text } as Partial<typeof formData>);
|
|
||||||
if (errors[field as string]) {
|
|
||||||
setErrors((prev) => ({ ...prev, [field as string]: '' }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
autoCapitalize={autoCapitalize}
|
|
||||||
autoCorrect={false}
|
|
||||||
keyboardType={keyboardType}
|
|
||||||
maxLength={maxLength}
|
|
||||||
secureTextEntry={secureTextEntry && !toggleValue}
|
|
||||||
editable={!loading}
|
|
||||||
/>
|
|
||||||
{showToggle && onToggle && (
|
|
||||||
<TouchableOpacity onPress={onToggle} style={styles.eyeButton}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={toggleValue ? 'eye-off-outline' : 'eye-outline'}
|
|
||||||
size={20}
|
|
||||||
color={colors.text?.hint || '#999'}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
{error ? <Text style={styles.errorText}>{error}</Text> : null}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<View style={styles.formSection}>
|
|
||||||
{/* 用户名 */}
|
|
||||||
{renderInput('用户名', 'username', '3-20个字符,字母数字下划线', {
|
|
||||||
maxLength: 20,
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* 昵称 */}
|
|
||||||
{renderInput('昵称', 'nickname', '2-20个字符', {
|
|
||||||
autoCapitalize: 'sentences',
|
|
||||||
maxLength: 20,
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* 手机号(选填) */}
|
|
||||||
{renderInput('手机号(选填)', 'phone', '请输入手机号', {
|
|
||||||
keyboardType: 'phone-pad',
|
|
||||||
maxLength: 11,
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* 密码 */}
|
|
||||||
{renderInput('密码', 'password', '至少8位,含大小写字母和数字', {
|
|
||||||
secureTextEntry: true,
|
|
||||||
showToggle: true,
|
|
||||||
toggleValue: showPassword,
|
|
||||||
onToggle: () => setShowPassword(!showPassword),
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* 确认密码 */}
|
|
||||||
{renderInput('确认密码', 'confirmPassword', '再次输入密码', {
|
|
||||||
secureTextEntry: true,
|
|
||||||
showToggle: true,
|
|
||||||
toggleValue: showConfirmPassword,
|
|
||||||
onToggle: () => setShowConfirmPassword(!showConfirmPassword),
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* 服务条款 */}
|
|
||||||
<View style={styles.termsContainer}>
|
|
||||||
<TouchableOpacity
|
|
||||||
onPress={() => {
|
|
||||||
setAgreedToTerms(!agreedToTerms);
|
|
||||||
if (errors.terms) {
|
|
||||||
setErrors((prev) => ({ ...prev, terms: '' }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
style={styles.checkboxWrapper}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={agreedToTerms ? 'checkbox-marked' : 'checkbox-blank-outline'}
|
|
||||||
size={22}
|
|
||||||
color={agreedToTerms ? colors.primary.main : colors.text?.hint || '#999'}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text style={styles.termsText}>
|
|
||||||
我已阅读并同意
|
|
||||||
<Text
|
|
||||||
style={styles.termsLink}
|
|
||||||
onPress={() => router.push(hrefs.hrefProfileTerms())}
|
|
||||||
>
|
|
||||||
《用户协议》
|
|
||||||
</Text>
|
|
||||||
和
|
|
||||||
<Text
|
|
||||||
style={styles.termsLink}
|
|
||||||
onPress={() => router.push(hrefs.hrefProfilePrivacy())}
|
|
||||||
>
|
|
||||||
《隐私政策》
|
|
||||||
</Text>
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
{errors.terms ? (
|
|
||||||
<Text style={[styles.errorText, { marginTop: -12, marginBottom: 12 }]}>
|
|
||||||
{errors.terms}
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* 完成注册按钮 */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.actionButton, loading && styles.actionButtonDisabled]}
|
|
||||||
onPress={handleSubmit}
|
|
||||||
disabled={loading}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<ActivityIndicator size="small" color="#FFF" />
|
|
||||||
) : (
|
|
||||||
<Text style={styles.actionButtonText}>完成注册</Text>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
{/* 返回按钮 */}
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.backButton}
|
|
||||||
onPress={onPrev}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
disabled={loading}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="arrow-left"
|
|
||||||
size={18}
|
|
||||||
color={colors.text?.secondary || '#666'}
|
|
||||||
/>
|
|
||||||
<Text style={styles.backButtonText}>返回上一步</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createStyles(colors: AppColors) {
|
|
||||||
return StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
paddingTop: 20,
|
|
||||||
},
|
|
||||||
formSection: {
|
|
||||||
marginBottom: 24,
|
|
||||||
},
|
|
||||||
inputContainer: {
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
inputLabel: {
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: colors.text?.secondary || '#666',
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
inputWrapper: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: colors.background.default,
|
|
||||||
borderRadius: 14,
|
|
||||||
paddingHorizontal: 18,
|
|
||||||
height: 52,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'transparent',
|
|
||||||
},
|
|
||||||
inputWrapperError: {
|
|
||||||
borderColor: colors.error?.main || '#FF4444',
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 16,
|
|
||||||
color: colors.text?.primary || '#333',
|
|
||||||
height: 52,
|
|
||||||
},
|
|
||||||
eyeButton: {
|
|
||||||
padding: 4,
|
|
||||||
marginLeft: 4,
|
|
||||||
},
|
|
||||||
errorText: {
|
|
||||||
fontSize: 12,
|
|
||||||
color: colors.error?.main || '#FF4444',
|
|
||||||
marginTop: 6,
|
|
||||||
marginLeft: 4,
|
|
||||||
},
|
|
||||||
termsContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
marginBottom: 20,
|
|
||||||
marginTop: 4,
|
|
||||||
},
|
|
||||||
checkboxWrapper: {
|
|
||||||
paddingTop: 2,
|
|
||||||
},
|
|
||||||
termsText: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: colors.text?.secondary || '#666',
|
|
||||||
marginLeft: 10,
|
|
||||||
flex: 1,
|
|
||||||
lineHeight: 22,
|
|
||||||
},
|
|
||||||
termsLink: {
|
|
||||||
color: colors.primary.main,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
actionButton: {
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 14,
|
|
||||||
backgroundColor: colors.primary.main,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
actionButtonDisabled: {
|
|
||||||
opacity: 0.7,
|
|
||||||
},
|
|
||||||
actionButtonText: {
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.text.inverse,
|
|
||||||
},
|
|
||||||
backButton: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginTop: 16,
|
|
||||||
paddingVertical: 8,
|
|
||||||
},
|
|
||||||
backButtonText: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: colors.text?.secondary || '#666',
|
|
||||||
marginLeft: 6,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default RegisterStep3Profile;
|
|
||||||
@@ -1,445 +0,0 @@
|
|||||||
/**
|
|
||||||
* 身份认证申请表单页面
|
|
||||||
* 用户填写认证信息并提交申请
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
StyleSheet,
|
|
||||||
TextInput,
|
|
||||||
TouchableOpacity,
|
|
||||||
ScrollView,
|
|
||||||
Image,
|
|
||||||
Alert,
|
|
||||||
ActivityIndicator,
|
|
||||||
Platform,
|
|
||||||
} from 'react-native';
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
import { useRouter, useLocalSearchParams } from 'expo-router';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
|
||||||
import { useAppColors, type AppColors } from '../../theme';
|
|
||||||
import { useVerificationStore } from '../../stores/verificationStore';
|
|
||||||
import { showPrompt } from '../../services/promptService';
|
|
||||||
import { uploadService } from '../../services/uploadService';
|
|
||||||
import type { UserIdentity } from '../../types/dto';
|
|
||||||
|
|
||||||
const IDENTITY_CONFIG: Record<string, { title: string; idField: string; idLabel: string }> = {
|
|
||||||
student: { title: '在校学生', idField: 'student_id', idLabel: '学号' },
|
|
||||||
teacher: { title: '教职工', idField: 'employee_id', idLabel: '工号' },
|
|
||||||
staff: { title: '工作人员', idField: 'employee_id', idLabel: '工号' },
|
|
||||||
alumni: { title: '校友', idField: 'student_id', idLabel: '学号(原)' },
|
|
||||||
};
|
|
||||||
|
|
||||||
export const VerificationFormScreen: React.FC = () => {
|
|
||||||
const colors = useAppColors();
|
|
||||||
const styles = createStyles(colors);
|
|
||||||
const router = useRouter();
|
|
||||||
const params = useLocalSearchParams<{ identity?: string }>();
|
|
||||||
const { submitVerification, isLoading } = useVerificationStore();
|
|
||||||
|
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [formData, setFormData] = useState({
|
|
||||||
real_name: '',
|
|
||||||
student_id: '',
|
|
||||||
employee_id: '',
|
|
||||||
department: '',
|
|
||||||
proof_materials: '',
|
|
||||||
});
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
|
||||||
|
|
||||||
const identity = params.identity as UserIdentity || 'student';
|
|
||||||
const config = IDENTITY_CONFIG[identity] || IDENTITY_CONFIG.student;
|
|
||||||
|
|
||||||
const validateForm = (): boolean => {
|
|
||||||
const newErrors: Record<string, string> = {};
|
|
||||||
|
|
||||||
if (!formData.real_name.trim()) {
|
|
||||||
newErrors.real_name = '请输入真实姓名';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.idField === 'student_id' && !formData.student_id.trim()) {
|
|
||||||
newErrors.student_id = `请输入${config.idLabel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.idField === 'employee_id' && !formData.employee_id.trim()) {
|
|
||||||
newErrors.employee_id = `请输入${config.idLabel}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.proof_materials) {
|
|
||||||
newErrors.proof_materials = '请上传证明材料';
|
|
||||||
}
|
|
||||||
|
|
||||||
setErrors(newErrors);
|
|
||||||
return Object.keys(newErrors).length === 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePickImage = async () => {
|
|
||||||
try {
|
|
||||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
||||||
if (!permissionResult.granted) {
|
|
||||||
Alert.alert('提示', '需要相册权限才能上传图片');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await ImagePicker.launchImageLibraryAsync({
|
|
||||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
|
||||||
allowsEditing: true,
|
|
||||||
aspect: [4, 3],
|
|
||||||
quality: 0.8,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!result.canceled && result.assets[0]) {
|
|
||||||
setFormData({ ...formData, proof_materials: result.assets[0].uri });
|
|
||||||
if (errors.proof_materials) {
|
|
||||||
setErrors({ ...errors, proof_materials: '' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to pick image:', error);
|
|
||||||
Alert.alert('错误', '选择图片失败');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
|
||||||
if (!validateForm()) return;
|
|
||||||
|
|
||||||
setUploading(true);
|
|
||||||
try {
|
|
||||||
let proofMaterialsUrl = formData.proof_materials;
|
|
||||||
|
|
||||||
if (formData.proof_materials.startsWith('file://') || formData.proof_materials.startsWith('content://') || formData.proof_materials.startsWith('blob:')) {
|
|
||||||
const uploadResult = await uploadService.uploadImage({
|
|
||||||
uri: formData.proof_materials,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!uploadResult || !uploadResult.url) {
|
|
||||||
showPrompt({
|
|
||||||
title: '上传失败',
|
|
||||||
message: '证明材料图片上传失败,请重试',
|
|
||||||
type: 'error',
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
proofMaterialsUrl = uploadResult.url;
|
|
||||||
}
|
|
||||||
|
|
||||||
const success = await submitVerification({
|
|
||||||
identity,
|
|
||||||
real_name: formData.real_name.trim(),
|
|
||||||
student_id: formData.student_id.trim() || undefined,
|
|
||||||
employee_id: formData.employee_id.trim() || undefined,
|
|
||||||
department: formData.department.trim() || undefined,
|
|
||||||
proof_materials: proofMaterialsUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (success) {
|
|
||||||
showPrompt({
|
|
||||||
title: '提交成功',
|
|
||||||
message: '您的认证申请已提交,请等待审核',
|
|
||||||
type: 'success',
|
|
||||||
});
|
|
||||||
router.back();
|
|
||||||
} else {
|
|
||||||
const error = useVerificationStore.getState().error;
|
|
||||||
showPrompt({
|
|
||||||
title: '提交失败',
|
|
||||||
message: error || '请稍后重试',
|
|
||||||
type: 'error',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderInput = (
|
|
||||||
field: keyof typeof formData,
|
|
||||||
label: string,
|
|
||||||
placeholder: string,
|
|
||||||
options: {
|
|
||||||
required?: boolean;
|
|
||||||
keyboardType?: 'default' | 'numeric' | 'email-address';
|
|
||||||
maxLength?: number;
|
|
||||||
} = {}
|
|
||||||
) => {
|
|
||||||
const { required, keyboardType = 'default', maxLength } = options;
|
|
||||||
const error = errors[field];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.inputContainer}>
|
|
||||||
<Text style={styles.inputLabel}>
|
|
||||||
{label}
|
|
||||||
{required && <Text style={styles.required}> *</Text>}
|
|
||||||
</Text>
|
|
||||||
<View style={[styles.inputWrapper, error && styles.inputWrapperError]}>
|
|
||||||
<TextInput
|
|
||||||
style={styles.input}
|
|
||||||
placeholder={placeholder}
|
|
||||||
placeholderTextColor={colors.text?.hint || '#999'}
|
|
||||||
value={formData[field]}
|
|
||||||
onChangeText={(text) => {
|
|
||||||
setFormData({ ...formData, [field]: text });
|
|
||||||
if (errors[field]) {
|
|
||||||
setErrors({ ...errors, [field]: '' });
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
keyboardType={keyboardType}
|
|
||||||
maxLength={maxLength}
|
|
||||||
editable={!isLoading && !uploading}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.container}>
|
|
||||||
<View style={styles.header}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.backButton}
|
|
||||||
onPress={() => router.back()}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="arrow-left"
|
|
||||||
size={24}
|
|
||||||
color={colors.text?.primary || '#333'}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
<Text style={styles.headerTitle}>{config.title}认证</Text>
|
|
||||||
<View style={styles.headerRight} />
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={styles.scrollContent}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
>
|
|
||||||
<View style={styles.infoCard}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="information-outline"
|
|
||||||
size={20}
|
|
||||||
color={colors.primary.main}
|
|
||||||
/>
|
|
||||||
<Text style={styles.infoText}>
|
|
||||||
请填写真实信息,上传清晰的证明材料照片,审核通过后将获得认证标识
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.formSection}>
|
|
||||||
{renderInput('real_name', '真实姓名', '请输入身份证上的姓名', { required: true })}
|
|
||||||
|
|
||||||
{config.idField === 'student_id' && (
|
|
||||||
renderInput('student_id', config.idLabel, `请输入${config.idLabel}`, {
|
|
||||||
required: true,
|
|
||||||
keyboardType: 'numeric',
|
|
||||||
maxLength: 20,
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
|
|
||||||
{config.idField === 'employee_id' && (
|
|
||||||
renderInput('employee_id', config.idLabel, `请输入${config.idLabel}`, {
|
|
||||||
required: true,
|
|
||||||
keyboardType: 'numeric',
|
|
||||||
maxLength: 20,
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
|
|
||||||
{renderInput('department', '院系/部门', '选填,如:计算机学院', {
|
|
||||||
maxLength: 50,
|
|
||||||
})}
|
|
||||||
|
|
||||||
<View style={styles.inputContainer}>
|
|
||||||
<Text style={styles.inputLabel}>
|
|
||||||
证明材料<Text style={styles.required}> *</Text>
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.hintText}>
|
|
||||||
请上传学生证、工作证、教师资格证等证明材料照片
|
|
||||||
</Text>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.uploadButton, errors.proof_materials && styles.uploadButtonError]}
|
|
||||||
onPress={handlePickImage}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
disabled={isLoading || uploading}
|
|
||||||
>
|
|
||||||
{formData.proof_materials ? (
|
|
||||||
<Image
|
|
||||||
source={{ uri: formData.proof_materials }}
|
|
||||||
style={styles.previewImage}
|
|
||||||
resizeMode="cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<View style={styles.uploadPlaceholder}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="camera-plus-outline"
|
|
||||||
size={40}
|
|
||||||
color={colors.text?.hint || '#999'}
|
|
||||||
/>
|
|
||||||
<Text style={styles.uploadText}>点击上传图片</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
{errors.proof_materials && (
|
|
||||||
<Text style={styles.errorText}>{errors.proof_materials}</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.submitButton, (isLoading || uploading) && styles.submitButtonDisabled]}
|
|
||||||
onPress={handleSubmit}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
disabled={isLoading || uploading}
|
|
||||||
>
|
|
||||||
{(isLoading || uploading) ? (
|
|
||||||
<ActivityIndicator size="small" color={colors.text.inverse} />
|
|
||||||
) : (
|
|
||||||
<Text style={styles.submitButtonText}>提交认证申请</Text>
|
|
||||||
)}
|
|
||||||
</TouchableOpacity>
|
|
||||||
</ScrollView>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function createStyles(colors: AppColors) {
|
|
||||||
return StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
paddingVertical: 12,
|
|
||||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
||||||
borderBottomColor: colors.divider || '#E0E0E0',
|
|
||||||
},
|
|
||||||
backButton: {
|
|
||||||
width: 40,
|
|
||||||
height: 40,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
headerTitle: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.text?.primary || '#333',
|
|
||||||
},
|
|
||||||
headerRight: {
|
|
||||||
width: 40,
|
|
||||||
},
|
|
||||||
scrollContent: {
|
|
||||||
flexGrow: 1,
|
|
||||||
padding: 24,
|
|
||||||
},
|
|
||||||
infoCard: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
backgroundColor: colors.primary.main + '15',
|
|
||||||
borderRadius: 12,
|
|
||||||
padding: 16,
|
|
||||||
marginBottom: 24,
|
|
||||||
},
|
|
||||||
infoText: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 14,
|
|
||||||
color: colors.text?.secondary || '#666',
|
|
||||||
marginLeft: 8,
|
|
||||||
lineHeight: 20,
|
|
||||||
},
|
|
||||||
formSection: {
|
|
||||||
marginBottom: 24,
|
|
||||||
},
|
|
||||||
inputContainer: {
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
inputLabel: {
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: colors.text?.primary || '#333',
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
required: {
|
|
||||||
color: colors.primary.main,
|
|
||||||
},
|
|
||||||
hintText: {
|
|
||||||
fontSize: 13,
|
|
||||||
color: colors.text?.hint || '#999',
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
inputWrapper: {
|
|
||||||
backgroundColor: colors.background.default,
|
|
||||||
borderRadius: 12,
|
|
||||||
paddingHorizontal: 16,
|
|
||||||
height: 50,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'transparent',
|
|
||||||
},
|
|
||||||
inputWrapperError: {
|
|
||||||
borderColor: colors.error?.main || '#FF4444',
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: 16,
|
|
||||||
color: colors.text?.primary || '#333',
|
|
||||||
height: 50,
|
|
||||||
},
|
|
||||||
errorText: {
|
|
||||||
fontSize: 12,
|
|
||||||
color: colors.error?.main || '#FF4444',
|
|
||||||
marginTop: 6,
|
|
||||||
},
|
|
||||||
uploadButton: {
|
|
||||||
backgroundColor: colors.background.default,
|
|
||||||
borderRadius: 12,
|
|
||||||
overflow: 'hidden',
|
|
||||||
minHeight: 200,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: 'transparent',
|
|
||||||
},
|
|
||||||
uploadButtonError: {
|
|
||||||
borderColor: colors.error?.main || '#FF4444',
|
|
||||||
},
|
|
||||||
uploadPlaceholder: {
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: 24,
|
|
||||||
},
|
|
||||||
uploadText: {
|
|
||||||
fontSize: 14,
|
|
||||||
color: colors.text?.hint || '#999',
|
|
||||||
marginTop: 12,
|
|
||||||
},
|
|
||||||
previewImage: {
|
|
||||||
width: '100%',
|
|
||||||
height: 200,
|
|
||||||
},
|
|
||||||
submitButton: {
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 14,
|
|
||||||
backgroundColor: colors.primary.main,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
submitButtonDisabled: {
|
|
||||||
opacity: 0.7,
|
|
||||||
},
|
|
||||||
submitButtonText: {
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.text.inverse,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default VerificationFormScreen;
|
|
||||||
@@ -1,323 +0,0 @@
|
|||||||
import React, { useState } from 'react';
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
StyleSheet,
|
|
||||||
TouchableOpacity,
|
|
||||||
ScrollView,
|
|
||||||
Alert,
|
|
||||||
} from 'react-native';
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
|
||||||
import { useAppColors, type AppColors } from '../../theme';
|
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
|
||||||
|
|
||||||
// 身份类型选项
|
|
||||||
const IDENTITY_OPTIONS = [
|
|
||||||
{
|
|
||||||
key: 'student',
|
|
||||||
title: '在校学生',
|
|
||||||
description: '需要提交学生证或校园卡照片',
|
|
||||||
icon: 'school-outline',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'teacher',
|
|
||||||
title: '教职工',
|
|
||||||
description: '需要提交工作证或教师资格证',
|
|
||||||
icon: 'account-tie',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'staff',
|
|
||||||
title: '工作人员',
|
|
||||||
description: '需要提交工作证或在职证明',
|
|
||||||
icon: 'badge-account-outline',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'alumni',
|
|
||||||
title: '校友',
|
|
||||||
description: '需要提交毕业证或校友卡',
|
|
||||||
icon: 'account-school-outline',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const VerificationGuideScreen: React.FC = () => {
|
|
||||||
const colors = useAppColors();
|
|
||||||
const styles = createStyles(colors);
|
|
||||||
const router = useRouter();
|
|
||||||
const [selectedIdentity, setSelectedIdentity] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const handleSkip = () => {
|
|
||||||
Alert.alert(
|
|
||||||
'跳过认证',
|
|
||||||
'跳过后您将以普通用户身份使用,部分功能可能受限。您可以在设置中随时完成认证。',
|
|
||||||
[
|
|
||||||
{ text: '继续认证', style: 'cancel' },
|
|
||||||
{
|
|
||||||
text: '跳过',
|
|
||||||
style: 'default',
|
|
||||||
onPress: () => {
|
|
||||||
router.replace(hrefs.hrefHome());
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleContinue = () => {
|
|
||||||
if (!selectedIdentity) {
|
|
||||||
Alert.alert('提示', '请选择您的身份类型');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 导航到认证表单页面
|
|
||||||
router.push(hrefs.hrefVerificationForm(selectedIdentity));
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderIdentityCard = (option: typeof IDENTITY_OPTIONS[0]) => {
|
|
||||||
const isSelected = selectedIdentity === option.key;
|
|
||||||
return (
|
|
||||||
<TouchableOpacity
|
|
||||||
key={option.key}
|
|
||||||
style={[styles.identityCard, isSelected && styles.identityCardSelected]}
|
|
||||||
onPress={() => setSelectedIdentity(option.key)}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<View style={styles.identityIconContainer}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name={option.icon as any}
|
|
||||||
size={32}
|
|
||||||
color={isSelected ? colors.primary.main : colors.text?.secondary || '#666'}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<View style={styles.identityContent}>
|
|
||||||
<Text style={[styles.identityTitle, isSelected && styles.identityTitleSelected]}>
|
|
||||||
{option.title}
|
|
||||||
</Text>
|
|
||||||
<Text style={styles.identityDescription}>{option.description}</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.checkContainer}>
|
|
||||||
{isSelected && (
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="check-circle"
|
|
||||||
size={24}
|
|
||||||
color={colors.primary.main}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</TouchableOpacity>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SafeAreaView style={styles.container}>
|
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={styles.scrollContent}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{/* 头部图标 */}
|
|
||||||
<View style={styles.header}>
|
|
||||||
<View style={styles.iconCircle}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="shield-check-outline"
|
|
||||||
size={48}
|
|
||||||
color={colors.primary.main}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text style={styles.title}>身份认证</Text>
|
|
||||||
<Text style={styles.subtitle}>
|
|
||||||
完成身份认证,解锁更多专属功能
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 认证优势 */}
|
|
||||||
<View style={styles.benefitsContainer}>
|
|
||||||
<View style={styles.benefitItem}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="check-decagram"
|
|
||||||
size={20}
|
|
||||||
color={colors.primary.main}
|
|
||||||
/>
|
|
||||||
<Text style={styles.benefitText}>获得认证标识</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.benefitItem}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="lock-open-outline"
|
|
||||||
size={20}
|
|
||||||
color={colors.primary.main}
|
|
||||||
/>
|
|
||||||
<Text style={styles.benefitText}>解锁专属频道</Text>
|
|
||||||
</View>
|
|
||||||
<View style={styles.benefitItem}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="account-group-outline"
|
|
||||||
size={20}
|
|
||||||
color={colors.primary.main}
|
|
||||||
/>
|
|
||||||
<Text style={styles.benefitText}>加入社群圈子</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 身份选择 */}
|
|
||||||
<View style={styles.sectionContainer}>
|
|
||||||
<Text style={styles.sectionTitle}>选择您的身份</Text>
|
|
||||||
{IDENTITY_OPTIONS.map(renderIdentityCard)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 按钮区域 */}
|
|
||||||
<View style={styles.buttonContainer}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={[styles.continueButton, !selectedIdentity && styles.continueButtonDisabled]}
|
|
||||||
onPress={handleContinue}
|
|
||||||
activeOpacity={0.9}
|
|
||||||
>
|
|
||||||
<Text style={styles.continueButtonText}>继续认证</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity style={styles.skipButton} onPress={handleSkip} activeOpacity={0.8}>
|
|
||||||
<Text style={styles.skipButtonText}>暂不认证</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</ScrollView>
|
|
||||||
</SafeAreaView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function createStyles(colors: AppColors) {
|
|
||||||
return StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
},
|
|
||||||
scrollContent: {
|
|
||||||
flexGrow: 1,
|
|
||||||
paddingHorizontal: 24,
|
|
||||||
paddingTop: 40,
|
|
||||||
paddingBottom: 40,
|
|
||||||
},
|
|
||||||
header: {
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 32,
|
|
||||||
},
|
|
||||||
iconCircle: {
|
|
||||||
width: 100,
|
|
||||||
height: 100,
|
|
||||||
borderRadius: 50,
|
|
||||||
backgroundColor: colors.primary.main + '15',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginBottom: 20,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: '700',
|
|
||||||
color: colors.text?.primary || '#333',
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: 15,
|
|
||||||
color: colors.text?.secondary || '#666',
|
|
||||||
textAlign: 'center',
|
|
||||||
},
|
|
||||||
benefitsContainer: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
justifyContent: 'center',
|
|
||||||
gap: 16,
|
|
||||||
marginBottom: 40,
|
|
||||||
},
|
|
||||||
benefitItem: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 6,
|
|
||||||
},
|
|
||||||
benefitText: {
|
|
||||||
fontSize: 13,
|
|
||||||
color: colors.text?.secondary || '#666',
|
|
||||||
},
|
|
||||||
sectionContainer: {
|
|
||||||
marginBottom: 32,
|
|
||||||
},
|
|
||||||
sectionTitle: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.text?.primary || '#333',
|
|
||||||
marginBottom: 16,
|
|
||||||
},
|
|
||||||
identityCard: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
padding: 16,
|
|
||||||
backgroundColor: colors.background.default,
|
|
||||||
borderRadius: 14,
|
|
||||||
marginBottom: 12,
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: 'transparent',
|
|
||||||
},
|
|
||||||
identityCardSelected: {
|
|
||||||
backgroundColor: colors.primary.main + '15',
|
|
||||||
borderColor: colors.primary.main,
|
|
||||||
},
|
|
||||||
identityIconContainer: {
|
|
||||||
width: 48,
|
|
||||||
height: 48,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
marginRight: 16,
|
|
||||||
},
|
|
||||||
identityContent: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
identityTitle: {
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.text?.primary || '#333',
|
|
||||||
marginBottom: 4,
|
|
||||||
},
|
|
||||||
identityTitleSelected: {
|
|
||||||
color: colors.primary.main,
|
|
||||||
},
|
|
||||||
identityDescription: {
|
|
||||||
fontSize: 13,
|
|
||||||
color: colors.text?.secondary || '#666',
|
|
||||||
},
|
|
||||||
checkContainer: {
|
|
||||||
width: 24,
|
|
||||||
height: 24,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
buttonContainer: {
|
|
||||||
gap: 12,
|
|
||||||
},
|
|
||||||
continueButton: {
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 14,
|
|
||||||
backgroundColor: colors.primary.main,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
continueButtonDisabled: {
|
|
||||||
backgroundColor: colors.background.disabled,
|
|
||||||
},
|
|
||||||
continueButtonText: {
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: '600',
|
|
||||||
color: colors.text.inverse,
|
|
||||||
},
|
|
||||||
skipButton: {
|
|
||||||
height: 56,
|
|
||||||
borderRadius: 14,
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.divider || '#E0E0E0',
|
|
||||||
},
|
|
||||||
skipButtonText: {
|
|
||||||
fontSize: 17,
|
|
||||||
fontWeight: '500',
|
|
||||||
color: colors.text?.secondary || '#666',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default VerificationGuideScreen;
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
/**
|
|
||||||
* 欢迎页 WelcomeScreen
|
|
||||||
* 胡萝卜BBS - 用户欢迎页
|
|
||||||
*
|
|
||||||
* 亮色模式:白色主色调 + 胡萝卜橙辅色
|
|
||||||
* 暗色模式:深色背景 + 胡萝卜橙辅色
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useEffect, useRef, useMemo } from 'react';
|
|
||||||
import {
|
|
||||||
View,
|
|
||||||
Text,
|
|
||||||
StyleSheet,
|
|
||||||
TouchableOpacity,
|
|
||||||
Animated,
|
|
||||||
StatusBar,
|
|
||||||
Dimensions,
|
|
||||||
} from 'react-native';
|
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
|
||||||
import { useRouter } from 'expo-router';
|
|
||||||
import { useAppColors, useResolvedColorScheme, type AppColors } from '../../theme';
|
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
|
||||||
|
|
||||||
const { width, height } = Dimensions.get('window');
|
|
||||||
|
|
||||||
export const WelcomeScreen: React.FC = () => {
|
|
||||||
const router = useRouter();
|
|
||||||
const colors = useAppColors();
|
|
||||||
const resolved = useResolvedColorScheme();
|
|
||||||
const styles = useMemo(() => createWelcomeStyles(colors), [colors]);
|
|
||||||
const isDark = resolved === 'dark';
|
|
||||||
|
|
||||||
// 动画值
|
|
||||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
|
||||||
const slideUpAnim = useRef(new Animated.Value(50)).current;
|
|
||||||
const scaleAnim = useRef(new Animated.Value(0.9)).current;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// 启动入场动画
|
|
||||||
Animated.parallel([
|
|
||||||
Animated.timing(fadeAnim, {
|
|
||||||
toValue: 1,
|
|
||||||
duration: 800,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
Animated.timing(slideUpAnim, {
|
|
||||||
toValue: 0,
|
|
||||||
duration: 800,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
Animated.timing(scaleAnim, {
|
|
||||||
toValue: 1,
|
|
||||||
duration: 800,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
]).start();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleSignIn = () => {
|
|
||||||
router.push(hrefs.hrefAuthLogin());
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateAccount = () => {
|
|
||||||
router.push(hrefs.hrefAuthRegister());
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<View style={styles.container}>
|
|
||||||
<StatusBar barStyle={isDark ? 'light-content' : 'dark-content'} />
|
|
||||||
|
|
||||||
{/* 装饰性背景元素 */}
|
|
||||||
<View style={styles.decorCircle1} />
|
|
||||||
<View style={styles.decorCircle2} />
|
|
||||||
<View style={styles.decorCircle3} />
|
|
||||||
|
|
||||||
<SafeAreaView style={styles.safeArea}>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
styles.content,
|
|
||||||
{
|
|
||||||
opacity: fadeAnim,
|
|
||||||
transform: [
|
|
||||||
{ translateY: slideUpAnim },
|
|
||||||
{ scale: scaleAnim },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{/* 品牌区域 */}
|
|
||||||
<View style={styles.brandSection}>
|
|
||||||
<Text style={styles.welcomeEn}>Welcome To</Text>
|
|
||||||
<Text style={styles.brandName}>萝卜社区</Text>
|
|
||||||
<View style={styles.brandUnderline} />
|
|
||||||
|
|
||||||
<Text style={styles.subtitle}>
|
|
||||||
发现有趣的内容{'\n'}
|
|
||||||
结识志同道合的朋友
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 按钮区域 */}
|
|
||||||
<View style={styles.buttonSection}>
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.signInButton}
|
|
||||||
onPress={handleSignIn}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<Text style={styles.signInButtonText}>登录</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
|
|
||||||
<TouchableOpacity
|
|
||||||
style={styles.createAccountButton}
|
|
||||||
onPress={handleCreateAccount}
|
|
||||||
activeOpacity={0.8}
|
|
||||||
>
|
|
||||||
<Text style={styles.createAccountButtonText}>创建账号</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
</SafeAreaView>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function createWelcomeStyles(colors: AppColors) {
|
|
||||||
return StyleSheet.create({
|
|
||||||
container: {
|
|
||||||
flex: 1,
|
|
||||||
backgroundColor: colors.background.default,
|
|
||||||
},
|
|
||||||
safeArea: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
paddingHorizontal: 32,
|
|
||||||
paddingTop: 60,
|
|
||||||
paddingBottom: 40,
|
|
||||||
},
|
|
||||||
|
|
||||||
// 装饰性背景元素 - 使用胡萝卜橙的淡色
|
|
||||||
decorCircle1: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: -100,
|
|
||||||
right: -100,
|
|
||||||
width: 400,
|
|
||||||
height: 400,
|
|
||||||
borderRadius: 200,
|
|
||||||
backgroundColor: colors.primary.main + '08', // 非常淡的胡萝卜橙
|
|
||||||
},
|
|
||||||
decorCircle2: {
|
|
||||||
position: 'absolute',
|
|
||||||
top: height * 0.3,
|
|
||||||
left: -80,
|
|
||||||
width: 200,
|
|
||||||
height: 200,
|
|
||||||
borderRadius: 100,
|
|
||||||
backgroundColor: colors.primary.main + '10',
|
|
||||||
},
|
|
||||||
decorCircle3: {
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: -50,
|
|
||||||
right: -50,
|
|
||||||
width: 250,
|
|
||||||
height: 250,
|
|
||||||
borderRadius: 125,
|
|
||||||
backgroundColor: colors.primary.main + '08',
|
|
||||||
},
|
|
||||||
|
|
||||||
// 品牌区域
|
|
||||||
brandSection: {
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
badge: {
|
|
||||||
backgroundColor: colors.primary.main + '15',
|
|
||||||
paddingHorizontal: 20,
|
|
||||||
paddingVertical: 8,
|
|
||||||
borderRadius: 20,
|
|
||||||
marginBottom: 24,
|
|
||||||
},
|
|
||||||
badgeText: {
|
|
||||||
color: colors.primary.main,
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: '600',
|
|
||||||
letterSpacing: 1,
|
|
||||||
},
|
|
||||||
welcomeEn: {
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: '400',
|
|
||||||
color: colors.text.secondary,
|
|
||||||
marginBottom: 4,
|
|
||||||
letterSpacing: 1,
|
|
||||||
},
|
|
||||||
brandName: {
|
|
||||||
fontSize: 42,
|
|
||||||
fontWeight: '800',
|
|
||||||
color: colors.text.primary,
|
|
||||||
letterSpacing: 4,
|
|
||||||
marginBottom: 8,
|
|
||||||
},
|
|
||||||
brandUnderline: {
|
|
||||||
width: 80,
|
|
||||||
height: 4,
|
|
||||||
backgroundColor: colors.primary.main,
|
|
||||||
borderRadius: 2,
|
|
||||||
marginBottom: 24,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: 48,
|
|
||||||
fontWeight: '800',
|
|
||||||
color: colors.text.primary,
|
|
||||||
marginBottom: 16,
|
|
||||||
letterSpacing: 2,
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: 16,
|
|
||||||
color: colors.text.secondary,
|
|
||||||
textAlign: 'center',
|
|
||||||
lineHeight: 24,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
},
|
|
||||||
|
|
||||||
// 按钮区域
|
|
||||||
buttonSection: {
|
|
||||||
width: '100%',
|
|
||||||
gap: 16,
|
|
||||||
},
|
|
||||||
signInButton: {
|
|
||||||
backgroundColor: colors.primary.main,
|
|
||||||
borderRadius: 16,
|
|
||||||
height: 56,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
shadowColor: colors.primary.main,
|
|
||||||
shadowOffset: { width: 0, height: 4 },
|
|
||||||
shadowOpacity: 0.3,
|
|
||||||
shadowRadius: 8,
|
|
||||||
elevation: 5,
|
|
||||||
},
|
|
||||||
signInButtonText: {
|
|
||||||
color: colors.primary.contrast,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '700',
|
|
||||||
letterSpacing: 1,
|
|
||||||
},
|
|
||||||
createAccountButton: {
|
|
||||||
backgroundColor: 'transparent',
|
|
||||||
borderRadius: 16,
|
|
||||||
height: 56,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderWidth: 2,
|
|
||||||
borderColor: colors.primary.main,
|
|
||||||
},
|
|
||||||
createAccountButtonText: {
|
|
||||||
color: colors.primary.main,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: '700',
|
|
||||||
letterSpacing: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export default WelcomeScreen;
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user