Compare commits
41 Commits
master
...
4b5ce1ba21
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b5ce1ba21 | ||
|
|
6f84e17772 | ||
|
|
9bbed8cf5e | ||
|
|
be8f6de8cf | ||
|
|
96a5207cf8 | ||
|
|
25194313ae | ||
|
|
445c1c5561 | ||
|
|
accf7c04e8 | ||
|
|
542d385d08 | ||
|
|
82c2970a85 | ||
|
|
189b977fac | ||
|
|
69717ea407 | ||
|
|
775deeb9c4 | ||
|
|
e8651215f7 | ||
|
|
72842352d9 | ||
|
|
c771bd9755 | ||
|
|
5614b4078a | ||
|
|
20e9d69540 | ||
|
|
259de04f3e | ||
|
|
94c11062f0 | ||
|
|
1bee7ea551 | ||
|
|
2ef267a897 | ||
|
|
774b5c4b47 | ||
|
|
e0ee29caf8 | ||
|
|
584d98307c | ||
| e969e5bad4 | |||
| ebb0c003e3 | |||
|
|
3c071957ce | ||
|
|
fa10ef5116 | ||
|
|
7c33409624 | ||
|
|
f6176c945b | ||
|
|
b19a2ced6f | ||
|
|
db7885086f | ||
|
|
ba99900624 | ||
|
|
4b89b50006 | ||
|
|
b6583e07c8 | ||
|
|
6d1514b2d1 | ||
|
|
d280ad1656 | ||
|
|
c903990aaf | ||
|
|
405cd271db | ||
|
|
9529ea39c4 |
@@ -272,6 +272,42 @@ jobs:
|
||||
name: carrot-bbs-android-release-apk
|
||||
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:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
@@ -10,6 +10,7 @@ RUN npx expo export --platform web --output-dir dist-web
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=builder /app/dist-web /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
@@ -23,6 +23,29 @@ const devUpdatesUrl =
|
||||
|
||||
const expo = appJson.expo;
|
||||
|
||||
// 检测是否为 Web 平台
|
||||
const isWeb = process.env.EXPO_TARGET === 'web' || process.env.PLATFORM === 'web';
|
||||
|
||||
// 原生平台特有的插件(在 Web 端会报错)
|
||||
const nativeOnlyPlugins = [
|
||||
'expo-camera',
|
||||
'expo-notifications',
|
||||
'expo-background-fetch',
|
||||
'expo-media-library',
|
||||
'expo-image-picker',
|
||||
'expo-video',
|
||||
'expo-sqlite',
|
||||
'./plugins/withMainActivityConfigChange',
|
||||
];
|
||||
|
||||
// 过滤插件:Web 端排除原生插件
|
||||
const filteredPlugins = isWeb
|
||||
? expo.plugins.filter((plugin) => {
|
||||
const pluginName = Array.isArray(plugin) ? plugin[0] : plugin;
|
||||
return !nativeOnlyPlugins.includes(pluginName);
|
||||
})
|
||||
: expo.plugins;
|
||||
|
||||
module.exports = {
|
||||
...expo,
|
||||
name: isDevVariant ? `${expo.name} Dev` : expo.name,
|
||||
@@ -40,10 +63,27 @@ module.exports = {
|
||||
...expo.android,
|
||||
package: 'skin.carrot.bbs',
|
||||
},
|
||||
// Web 端使用过滤后的插件
|
||||
plugins: filteredPlugins,
|
||||
extra: {
|
||||
...(expo.extra || {}),
|
||||
appVariant: isDevVariant ? 'dev' : 'release',
|
||||
apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl,
|
||||
updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
|
||||
},
|
||||
// Web 端配置
|
||||
...(isWeb && {
|
||||
web: {
|
||||
...expo.web,
|
||||
build: {
|
||||
...expo.web?.build,
|
||||
// 配置 Web 端别名,避免加载原生模块
|
||||
babel: {
|
||||
dangerouslyAddModulePathsToTranspile: [
|
||||
'react-native-webrtc',
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
24
app.json
24
app.json
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "萝卜社区",
|
||||
"slug": "qojo",
|
||||
"version": "1.0.11",
|
||||
"version": "1.0.13",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
@@ -15,11 +15,13 @@
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"infoPlist": {
|
||||
"NSMicrophoneUsageDescription": "允许萝卜社区访问您的麦克风以进行语音通话",
|
||||
"UIBackgroundModes": [
|
||||
"fetch",
|
||||
"remote-notification",
|
||||
"fetch",
|
||||
"remote-notification"
|
||||
"remote-notification",
|
||||
"audio"
|
||||
],
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
},
|
||||
@@ -34,9 +36,10 @@
|
||||
},
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "skin.carrot.bbs",
|
||||
"versionCode": 6,
|
||||
"versionCode": 7,
|
||||
"permissions": [
|
||||
"VIBRATE",
|
||||
"RECORD_AUDIO",
|
||||
"RECEIVE_BOOT_COMPLETED",
|
||||
"WAKE_LOCK",
|
||||
"READ_EXTERNAL_STORAGE",
|
||||
@@ -59,6 +62,17 @@
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
"./plugins/withMainActivityConfigChange",
|
||||
[
|
||||
"./plugins/withForegroundService",
|
||||
{
|
||||
"notificationTitle": "萝卜社区",
|
||||
"notificationBody": "正在后台同步消息",
|
||||
"notificationIcon": "ic_notification",
|
||||
"channelId": "carrot_sync_foreground",
|
||||
"channelName": "消息同步"
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-router",
|
||||
{
|
||||
@@ -82,9 +96,9 @@
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-background-fetch",
|
||||
"expo-background-task",
|
||||
{
|
||||
"minimumInterval": 900
|
||||
"minimumInterval": 15
|
||||
}
|
||||
],
|
||||
[
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useCallback } from 'react';
|
||||
import { Platform, useWindowDimensions } from 'react-native';
|
||||
import { Tabs, usePathname } from 'expo-router';
|
||||
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 { BREAKPOINTS } from '../../../src/hooks/useResponsive';
|
||||
import { useHomeTabBarVisibilityStore, useTotalUnreadCount } from '../../../src/stores';
|
||||
import { useHomeTabBarVisibilityStore, useTotalUnreadCount, useHomeTabPressStore } from '../../../src/stores';
|
||||
|
||||
const TAB_BAR_HEIGHT = 56;
|
||||
const TAB_BAR_FLOATING_MARGIN = 12;
|
||||
@@ -19,10 +19,17 @@ export default function TabsLayout() {
|
||||
const pathname = usePathname();
|
||||
const unreadCount = useTotalUnreadCount();
|
||||
const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
|
||||
const triggerHomeTabPress = useHomeTabPressStore((s) => s.triggerPress);
|
||||
const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop;
|
||||
|
||||
const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/');
|
||||
|
||||
const handleHomeTabPress = useCallback(() => {
|
||||
if (pathname === '/home') {
|
||||
triggerHomeTabPress();
|
||||
}
|
||||
}, [pathname, triggerHomeTabPress]);
|
||||
|
||||
const tabBarStyle = useMemo(() => {
|
||||
if (hideTabBar) {
|
||||
return { display: 'none' as const, height: 0, overflow: 'hidden' as const };
|
||||
@@ -81,6 +88,9 @@ export default function TabsLayout() {
|
||||
/>
|
||||
),
|
||||
}}
|
||||
listeners={{
|
||||
tabPress: handleHomeTabPress,
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="apps"
|
||||
|
||||
11
app/(app)/(tabs)/apps/materials/_layout.tsx
Normal file
11
app/(app)/(tabs)/apps/materials/_layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Stack } from 'expo-router';
|
||||
|
||||
export default function MaterialsStackLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="subject" />
|
||||
<Stack.Screen name="detail" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
5
app/(app)/(tabs)/apps/materials/detail.tsx
Normal file
5
app/(app)/(tabs)/apps/materials/detail.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { MaterialDetailScreen } from '../../../../../src/screens/material';
|
||||
|
||||
export default function MaterialDetailRoute() {
|
||||
return <MaterialDetailScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/apps/materials/index.tsx
Normal file
5
app/(app)/(tabs)/apps/materials/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { MaterialsScreen } from '../../../../../src/screens/material';
|
||||
|
||||
export default function MaterialsRoute() {
|
||||
return <MaterialsScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/apps/materials/subject.tsx
Normal file
5
app/(app)/(tabs)/apps/materials/subject.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SubjectMaterialsScreen } from '../../../../../src/screens/material';
|
||||
|
||||
export default function SubjectMaterialsRoute() {
|
||||
return <SubjectMaterialsScreen />;
|
||||
}
|
||||
@@ -36,6 +36,14 @@ export default function ProfileStackLayout() {
|
||||
<Stack.Screen name="bookmarks" options={{ title: '收藏' }} />
|
||||
<Stack.Screen name="notification-settings" 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>
|
||||
);
|
||||
}
|
||||
|
||||
5
app/(app)/(tabs)/profile/about.tsx
Normal file
5
app/(app)/(tabs)/profile/about.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AboutScreen } from '../../../../src/screens/profile/AboutScreen';
|
||||
|
||||
export default function AboutRoute() {
|
||||
return <AboutScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/account-deletion.tsx
Normal file
5
app/(app)/(tabs)/profile/account-deletion.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { AccountDeletionScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function AccountDeletionRoute() {
|
||||
return <AccountDeletionScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/chat-settings.tsx
Normal file
5
app/(app)/(tabs)/profile/chat-settings.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { ChatSettingsScreen } from '../../../../src/screens/profile/ChatSettingsScreen';
|
||||
|
||||
export default function ChatSettingsRoute() {
|
||||
return <ChatSettingsScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/data-storage.tsx
Normal file
5
app/(app)/(tabs)/profile/data-storage.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DataStorageScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function DataStorageRoute() {
|
||||
return <DataStorageScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/privacy-settings.tsx
Normal file
5
app/(app)/(tabs)/profile/privacy-settings.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PrivacySettingsScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function PrivacySettingsRoute() {
|
||||
return <PrivacySettingsScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/privacy.tsx
Normal file
5
app/(app)/(tabs)/profile/privacy.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PrivacyPolicyScreen } from '../../../../src/screens/profile/PrivacyPolicyScreen';
|
||||
|
||||
export default function PrivacyPolicyRoute() {
|
||||
return <PrivacyPolicyScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/terms.tsx
Normal file
5
app/(app)/(tabs)/profile/terms.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { TermsOfServiceScreen } from '../../../../src/screens/profile/TermsOfServiceScreen';
|
||||
|
||||
export default function TermsOfServiceRoute() {
|
||||
return <TermsOfServiceScreen />;
|
||||
}
|
||||
5
app/(app)/(tabs)/profile/verification.tsx
Normal file
5
app/(app)/(tabs)/profile/verification.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { VerificationSettingsScreen } from '@/screens/profile/VerificationSettingsScreen';
|
||||
|
||||
export default function VerificationSettingsPage() {
|
||||
return <VerificationSettingsScreen />;
|
||||
}
|
||||
5
app/(auth)/verification-form.tsx
Normal file
5
app/(auth)/verification-form.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { VerificationFormScreen } from '@/screens/auth/VerificationFormScreen';
|
||||
|
||||
export default function VerificationFormPage() {
|
||||
return <VerificationFormScreen />;
|
||||
}
|
||||
5
app/(auth)/verification-guide.tsx
Normal file
5
app/(auth)/verification-guide.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { VerificationGuideScreen } from '@/screens/auth/VerificationGuideScreen';
|
||||
|
||||
export default function VerificationGuidePage() {
|
||||
return <VerificationGuideScreen />;
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import * as Notifications from 'expo-notifications';
|
||||
import * as SystemUI from 'expo-system-ui';
|
||||
|
||||
import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences';
|
||||
import { api } from '../src/services/api';
|
||||
import { wsService } from '../src/services/wsService';
|
||||
import {
|
||||
ThemeBootstrap,
|
||||
useAppColors,
|
||||
@@ -21,6 +23,8 @@ import AppPromptBar from '../src/components/common/AppPromptBar';
|
||||
import AppDialogHost from '../src/components/common/AppDialogHost';
|
||||
import { installAlertOverride } from '../src/services/alertOverride';
|
||||
import { useAuthStore } from '../src/stores';
|
||||
import { checkForAPKUpdate } from '../src/services/apkUpdateService';
|
||||
import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call';
|
||||
|
||||
registerNotificationPresentationHandler();
|
||||
|
||||
@@ -48,6 +52,25 @@ if (Platform.OS === 'web' && typeof document !== 'undefined') {
|
||||
}
|
||||
*:focus { 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);
|
||||
}
|
||||
@@ -137,21 +160,71 @@ function NotificationBootstrap() {
|
||||
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() {
|
||||
const router = useRouter();
|
||||
const colors = useAppColors();
|
||||
const resolved = useResolvedColorScheme();
|
||||
|
||||
useEffect(() => {
|
||||
api.setExpoRouter(router);
|
||||
wsService.setRouter(router);
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
||||
<SystemChrome />
|
||||
<SessionGate>
|
||||
<NotificationBootstrap />
|
||||
<APKUpdateBootstrap />
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="(auth)" 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
|
||||
name="post/[postId]"
|
||||
options={{
|
||||
@@ -197,6 +270,9 @@ export default function RootLayout() {
|
||||
<ThemedStack />
|
||||
<AppPromptBar />
|
||||
<AppDialogHost />
|
||||
<IncomingCallModal />
|
||||
<CallScreen />
|
||||
<FloatingCallWindow />
|
||||
</QueryClientProvider>
|
||||
</ThemedProviders>
|
||||
</SafeAreaProvider>
|
||||
|
||||
@@ -7,5 +7,5 @@ export default function Index() {
|
||||
if (isAuthenticated) {
|
||||
return <Redirect href="/home" />;
|
||||
}
|
||||
return <Redirect href="/login" />;
|
||||
return <Redirect href="/welcome" />;
|
||||
}
|
||||
|
||||
5
app/privacy.tsx
Normal file
5
app/privacy.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { PrivacyPolicyScreen } from '../src/screens/profile/PrivacyPolicyScreen';
|
||||
|
||||
export default function PrivacyRoute() {
|
||||
return <PrivacyPolicyScreen />;
|
||||
}
|
||||
5
app/terms.tsx
Normal file
5
app/terms.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { TermsOfServiceScreen } from '../src/screens/profile/TermsOfServiceScreen';
|
||||
|
||||
export default function TermsRoute() {
|
||||
return <TermsOfServiceScreen />;
|
||||
}
|
||||
5
app/welcome.tsx
Normal file
5
app/welcome.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { WelcomeScreen } from '../src/screens/auth';
|
||||
|
||||
export default function WelcomeRoute() {
|
||||
return <WelcomeScreen />;
|
||||
}
|
||||
@@ -6,6 +6,10 @@ const config = getDefaultConfig(__dirname);
|
||||
// Add wasm asset support
|
||||
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,
|
||||
// but there's currently no official alternative for custom dev server headers.
|
||||
// For production, configure COEP/COOP headers on your hosting platform.
|
||||
|
||||
19
nginx.conf
Normal file
19
nginx.conf
Normal file
@@ -0,0 +1,19 @@
|
||||
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",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "carrot_bbs",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.2",
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
@@ -29,6 +29,7 @@
|
||||
"expo-haptics": "~55.0.8",
|
||||
"expo-image": "^55.0.5",
|
||||
"expo-image-picker": "^55.0.10",
|
||||
"expo-intent-launcher": "~55.0.9",
|
||||
"expo-linear-gradient": "^55.0.8",
|
||||
"expo-media-library": "~55.0.9",
|
||||
"expo-notifications": "^55.0.10",
|
||||
@@ -53,6 +54,7 @@
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-sse": "^1.2.1",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-webrtc": "^124.0.7",
|
||||
"react-native-webview": "13.16.0",
|
||||
"react-native-worklets": "0.7.2",
|
||||
"zod": "^4.3.6",
|
||||
@@ -5747,6 +5749,15 @@
|
||||
"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": {
|
||||
"version": "55.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/expo-json-utils/-/expo-json-utils-55.0.0.tgz",
|
||||
@@ -9789,6 +9800,55 @@
|
||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||
"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": {
|
||||
"version": "13.16.0",
|
||||
"resolved": "https://registry.npmmirror.com/react-native-webview/-/react-native-webview-13.16.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "carrot_bbs",
|
||||
"version": "1.0.1",
|
||||
"version": "1.0.3",
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
@@ -25,7 +25,6 @@
|
||||
"axios": "^1.13.6",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~55.0.4",
|
||||
"expo-background-fetch": "~55.0.10",
|
||||
"expo-background-task": "~55.0.10",
|
||||
"expo-camera": "^55.0.10",
|
||||
"expo-constants": "~55.0.7",
|
||||
@@ -35,6 +34,7 @@
|
||||
"expo-haptics": "~55.0.8",
|
||||
"expo-image": "^55.0.5",
|
||||
"expo-image-picker": "^55.0.10",
|
||||
"expo-intent-launcher": "~55.0.9",
|
||||
"expo-linear-gradient": "^55.0.8",
|
||||
"expo-media-library": "~55.0.9",
|
||||
"expo-notifications": "^55.0.10",
|
||||
@@ -59,6 +59,7 @@
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-sse": "^1.2.1",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-webrtc": "^124.0.7",
|
||||
"react-native-webview": "13.16.0",
|
||||
"react-native-worklets": "0.7.2",
|
||||
"zod": "^4.3.6",
|
||||
|
||||
467
plugins/withForegroundService.js
Normal file
467
plugins/withForegroundService.js
Normal file
@@ -0,0 +1,467 @@
|
||||
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;
|
||||
73
plugins/withMainActivityConfigChange.js
Normal file
73
plugins/withMainActivityConfigChange.js
Normal file
@@ -0,0 +1,73 @@
|
||||
const { withMainActivity } = require('@expo/config-plugins');
|
||||
|
||||
/**
|
||||
* 在 MainActivity 中添加 onConfigurationChanged 方法
|
||||
* 用于监听系统深色模式变化并通知 React Native
|
||||
*/
|
||||
const withMainActivityConfigChange = (config) => {
|
||||
return withMainActivity(config, async (config) => {
|
||||
const { contents } = config.modResults;
|
||||
|
||||
// 检查是否已经添加了 onConfigurationChanged
|
||||
if (contents.includes('onConfigurationChanged')) {
|
||||
return config;
|
||||
}
|
||||
|
||||
// 需要添加的 imports
|
||||
const importsToAdd = `import android.content.Intent
|
||||
import android.content.res.Configuration`;
|
||||
|
||||
// 需要添加的方法
|
||||
const methodToAdd = `
|
||||
/**
|
||||
* 监听系统配置变化(包括深色模式切换),并广播给 React Native
|
||||
* 这对于 Appearance API 正确检测系统主题至关重要
|
||||
*/
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
val intent = Intent("onConfigurationChanged")
|
||||
intent.putExtra("newConfig", newConfig)
|
||||
sendBroadcast(intent)
|
||||
}
|
||||
`;
|
||||
|
||||
let newContents = contents;
|
||||
|
||||
// 添加 imports(在已有 imports 后面)
|
||||
if (!newContents.includes('import android.content.Intent')) {
|
||||
// 找到最后一个 import 语句
|
||||
const importRegex = /import [^\n]+\n/g;
|
||||
const imports = newContents.match(importRegex);
|
||||
if (imports && imports.length > 0) {
|
||||
const lastImport = imports[imports.length - 1];
|
||||
const lastImportIndex = newContents.lastIndexOf(lastImport);
|
||||
const insertPosition = lastImportIndex + lastImport.length;
|
||||
newContents =
|
||||
newContents.slice(0, insertPosition) +
|
||||
importsToAdd +
|
||||
'\n' +
|
||||
newContents.slice(insertPosition);
|
||||
}
|
||||
}
|
||||
|
||||
// 在类体的开头添加 onConfigurationChanged 方法
|
||||
// 找到类定义后的第一个方法或属性
|
||||
const classBodyRegex = /class MainActivity\s*:\s*ReactActivity\s*\(\)\s*\{([\s\S]*)/;
|
||||
const match = newContents.match(classBodyRegex);
|
||||
if (match) {
|
||||
// 找到 onCreate 方法之前插入
|
||||
const onCreateIndex = newContents.indexOf('override fun onCreate');
|
||||
if (onCreateIndex !== -1) {
|
||||
newContents =
|
||||
newContents.slice(0, onCreateIndex) +
|
||||
methodToAdd +
|
||||
newContents.slice(onCreateIndex);
|
||||
}
|
||||
}
|
||||
|
||||
config.modResults.contents = newContents;
|
||||
return config;
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = withMainActivityConfigChange;
|
||||
@@ -18,7 +18,7 @@ interface CommentItemProps {
|
||||
comment: Comment;
|
||||
onUserPress: () => void;
|
||||
onReply: () => void;
|
||||
onLike: () => void;
|
||||
onLike: (comment: Comment) => void; // 点赞回调,传入评论对象
|
||||
floorNumber?: number; // 楼层号
|
||||
isAuthor?: boolean; // 是否是楼主
|
||||
replyToUser?: string; // 回复给哪位用户
|
||||
@@ -29,6 +29,7 @@ interface CommentItemProps {
|
||||
onDelete?: (comment: Comment) => void; // 删除评论的回调
|
||||
onImagePress?: (images: ImageGridItem[], index: number) => void; // 点击图片查看大图
|
||||
currentUserId?: string; // 当前用户ID,用于判断子评论作者
|
||||
onReport?: (comment: Comment) => void; // 举报评论的回调
|
||||
}
|
||||
|
||||
function createCommentItemStyles(colors: AppColors) {
|
||||
@@ -214,6 +215,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
onDelete,
|
||||
onImagePress,
|
||||
currentUserId,
|
||||
onReport,
|
||||
}) => {
|
||||
const colors = useAppColors();
|
||||
const styles = useMemo(() => createCommentItemStyles(colors), [colors]);
|
||||
@@ -504,6 +506,25 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
回复
|
||||
</Text>
|
||||
</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 && (
|
||||
<TouchableOpacity
|
||||
@@ -521,6 +542,18 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
</Text>
|
||||
</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>
|
||||
</TouchableOpacity>
|
||||
@@ -592,7 +625,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
{/* 操作按钮 - 更紧凑 */}
|
||||
<View style={styles.actions}>
|
||||
{/* 点赞 */}
|
||||
<TouchableOpacity style={styles.actionButton} onPress={onLike}>
|
||||
<TouchableOpacity style={styles.actionButton} onPress={() => onLike(comment)}>
|
||||
<MaterialCommunityIcons
|
||||
name={comment.is_liked ? 'heart' : 'heart-outline'}
|
||||
size={14}
|
||||
@@ -615,6 +648,19 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
||||
</Text>
|
||||
</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 && (
|
||||
<TouchableOpacity
|
||||
|
||||
@@ -22,7 +22,8 @@ export type PostCardActionType =
|
||||
| 'unbookmark' // 取消收藏
|
||||
| 'share' // 分享
|
||||
| 'imagePress' // 点击图片
|
||||
| 'delete'; // 删除
|
||||
| 'delete' // 删除
|
||||
| 'report'; // 举报
|
||||
|
||||
/**
|
||||
* Action payload 类型
|
||||
|
||||
@@ -7,12 +7,13 @@ import {
|
||||
Alert,
|
||||
Modal,
|
||||
Dimensions,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
|
||||
@@ -133,22 +134,80 @@ function createQrScannerStyles(colors: AppColors) {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
webNotSupportedContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
padding: spacing.xl,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
webNotSupportedText: {
|
||||
marginTop: spacing.lg,
|
||||
fontSize: fontSizes.md,
|
||||
color: 'rgba(255,255,255,0.72)',
|
||||
textAlign: 'center',
|
||||
lineHeight: 24,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||
const router = useRouter();
|
||||
// Web 端不支持扫码组件
|
||||
const QRCodeScannerWeb: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||
const themeColors = useAppColors();
|
||||
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
||||
const [permission, requestPermission] = useCameraPermissions();
|
||||
const [scanned, setScanned] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setScanned(false);
|
||||
if (!permission?.granted) {
|
||||
requestPermission();
|
||||
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 themeColors = useAppColors();
|
||||
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
||||
|
||||
// 动态导入 expo-camera,避免 Web 端加载
|
||||
const [cameraModule, setCameraModule] = useState<typeof import('expo-camera') | null>(null);
|
||||
const [permission, setPermission] = useState<any>(null);
|
||||
const [scanned, setScanned] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible && Platform.OS !== 'web') {
|
||||
import('expo-camera').then((module) => {
|
||||
setCameraModule(module);
|
||||
const [perm, requestPerm] = module.useCameraPermissions();
|
||||
setPermission(perm);
|
||||
if (!perm?.granted) {
|
||||
requestPerm();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
@@ -178,7 +237,7 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
||||
}
|
||||
};
|
||||
|
||||
if (!permission?.granted) {
|
||||
if (!cameraModule || !permission?.granted) {
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||
<View style={styles.container}>
|
||||
@@ -192,7 +251,10 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
||||
<View style={styles.permissionContainer}>
|
||||
<MaterialCommunityIcons name="camera-off" size={64} color="rgba(255,255,255,0.5)" />
|
||||
<Text style={styles.permissionText}>需要相机权限才能扫码</Text>
|
||||
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
|
||||
<TouchableOpacity
|
||||
style={styles.permissionButton}
|
||||
onPress={() => cameraModule?.useCameraPermissions()[1]()}
|
||||
>
|
||||
<Text style={styles.permissionButtonText}>授予权限</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
@@ -201,6 +263,8 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
||||
);
|
||||
}
|
||||
|
||||
const { CameraView } = cameraModule;
|
||||
|
||||
return (
|
||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||
<View style={styles.container}>
|
||||
@@ -243,4 +307,9 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
||||
);
|
||||
};
|
||||
|
||||
// 根据平台导出不同组件
|
||||
export const QRCodeScanner: React.FC<QRCodeScannerProps> = Platform.OS === 'web'
|
||||
? QRCodeScannerWeb
|
||||
: QRCodeScannerNative;
|
||||
|
||||
export default QRCodeScanner;
|
||||
|
||||
493
src/components/business/ReportDialog/ReportDialog.tsx
Normal file
493
src/components/business/ReportDialog/ReportDialog.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
/**
|
||||
* 举报对话框组件
|
||||
* 支持举报帖子、评论、消息
|
||||
* 提供友好的用户界面和流畅的交互体验
|
||||
*/
|
||||
|
||||
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;
|
||||
2
src/components/business/ReportDialog/index.ts
Normal file
2
src/components/business/ReportDialog/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default } from './ReportDialog';
|
||||
export type { ReportDialogProps } from './ReportDialog';
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* SystemMessageItem 系统消息项组件 - 美化版
|
||||
* SystemMessageItem 系统消息项组件 - 扁平化风格
|
||||
* 根据系统消息类型显示不同图标和内容
|
||||
* 采用卡片式设计,符合胡萝卜BBS整体风格
|
||||
* 与登录、注册、设置页面风格保持一致
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
@@ -9,7 +9,7 @@ import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
|
||||
import Text from '../common/Text';
|
||||
import Avatar from '../common/Avatar';
|
||||
@@ -124,17 +124,13 @@ function createSystemMessageStyles(colors: AppColors) {
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
padding: spacing.md,
|
||||
padding: 16,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginHorizontal: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
...shadows.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider || '#E5E5EA',
|
||||
},
|
||||
unreadContainer: {
|
||||
backgroundColor: colors.primary.light + '08',
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: colors.primary.main,
|
||||
backgroundColor: colors.primary.main + '0A',
|
||||
},
|
||||
unreadIndicator: {
|
||||
position: 'absolute',
|
||||
@@ -143,19 +139,19 @@ function createSystemMessageStyles(colors: AppColors) {
|
||||
marginTop: -4,
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: borderRadius.full,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
iconContainer: {
|
||||
marginRight: spacing.md,
|
||||
marginRight: 14,
|
||||
},
|
||||
avatarWrapper: {
|
||||
position: 'relative',
|
||||
},
|
||||
iconWrapper: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: borderRadius.lg,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
@@ -163,9 +159,9 @@ function createSystemMessageStyles(colors: AppColors) {
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: borderRadius.full,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: 9,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
@@ -180,17 +176,17 @@ function createSystemMessageStyles(colors: AppColors) {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
marginBottom: 4,
|
||||
},
|
||||
titleLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
marginRight: spacing.sm,
|
||||
marginRight: 8,
|
||||
},
|
||||
title: {
|
||||
fontWeight: '500',
|
||||
fontSize: fontSizes.md,
|
||||
fontSize: 15,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
unreadTitle: {
|
||||
@@ -200,10 +196,10 @@ function createSystemMessageStyles(colors: AppColors) {
|
||||
statusBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingHorizontal: 6,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
marginLeft: spacing.xs,
|
||||
borderRadius: 4,
|
||||
marginLeft: 6,
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 10,
|
||||
@@ -211,55 +207,59 @@ function createSystemMessageStyles(colors: AppColors) {
|
||||
marginLeft: 2,
|
||||
},
|
||||
time: {
|
||||
fontSize: fontSizes.sm,
|
||||
fontSize: 13,
|
||||
flexShrink: 0,
|
||||
color: colors.text.hint,
|
||||
},
|
||||
messageContent: {
|
||||
lineHeight: 20,
|
||||
fontSize: fontSizes.sm,
|
||||
fontSize: 14,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
extraInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xs,
|
||||
backgroundColor: colors.primary.light + '12',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.md,
|
||||
marginTop: 8,
|
||||
backgroundColor: colors.background.default,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 8,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
extraText: {
|
||||
marginLeft: spacing.xs,
|
||||
marginLeft: 6,
|
||||
flex: 1,
|
||||
fontWeight: '500',
|
||||
fontSize: 13,
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: 'row',
|
||||
marginTop: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
marginTop: 12,
|
||||
gap: 10,
|
||||
},
|
||||
actionBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingVertical: 8,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 10,
|
||||
borderWidth: 1,
|
||||
gap: spacing.xs,
|
||||
gap: 4,
|
||||
},
|
||||
rejectBtn: {
|
||||
borderColor: `${colors.error.main}55`,
|
||||
backgroundColor: `${colors.error.main}18`,
|
||||
borderColor: colors.error.main + '40',
|
||||
backgroundColor: colors.error.light + '20',
|
||||
},
|
||||
approveBtn: {
|
||||
borderColor: `${colors.success.main}55`,
|
||||
backgroundColor: `${colors.success.main}18`,
|
||||
borderColor: colors.success.main + '40',
|
||||
backgroundColor: colors.success.light + '20',
|
||||
},
|
||||
actionBtnText: {
|
||||
fontWeight: '500',
|
||||
fontSize: 13,
|
||||
},
|
||||
arrowContainer: {
|
||||
marginLeft: spacing.sm,
|
||||
marginLeft: 8,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: 20,
|
||||
|
||||
@@ -135,26 +135,33 @@ function createTabBarStyles(colors: AppColors) {
|
||||
modernContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.xl,
|
||||
borderRadius: borderRadius['2xl'],
|
||||
marginHorizontal: spacing.lg,
|
||||
marginVertical: spacing.md,
|
||||
marginVertical: spacing.sm,
|
||||
padding: spacing.xs,
|
||||
shadowColor: colors.chat.shadow,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 16,
|
||||
elevation: 6,
|
||||
},
|
||||
modernTab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
position: 'relative',
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
modernTabActive: {
|
||||
backgroundColor: colors.primary.main + '15',
|
||||
backgroundColor: colors.primary.main + '12',
|
||||
shadowColor: colors.primary.main + '20',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
modernTabContent: {
|
||||
flexDirection: 'row',
|
||||
@@ -165,7 +172,7 @@ function createTabBarStyles(colors: AppColors) {
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
modernTabText: {
|
||||
fontWeight: '500',
|
||||
fontWeight: '600',
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
modernTabTextActive: {
|
||||
@@ -206,7 +213,7 @@ const TabBar: React.FC<TabBarProps> = ({
|
||||
key={index}
|
||||
style={[styles.modernTab, isActive && styles.modernTabActive]}
|
||||
onPress={() => onTabChange(index)}
|
||||
activeOpacity={0.85}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.modernTabContent}>
|
||||
{icon && (
|
||||
|
||||
@@ -22,3 +22,4 @@ export { default as TabBar } from './TabBar';
|
||||
export { default as VoteCard } from './VoteCard';
|
||||
export { default as VoteEditor } from './VoteEditor';
|
||||
export { default as VotePreview } from './VotePreview';
|
||||
export { default as ReportDialog } from './ReportDialog';
|
||||
|
||||
412
src/components/call/CallScreen.tsx
Normal file
412
src/components/call/CallScreen.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { callStore } from '../../stores/callStore';
|
||||
import { RTCView } from 'react-native-webrtc';
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
|
||||
const CallScreen: React.FC = () => {
|
||||
const currentCall = callStore((s) => s.currentCall);
|
||||
const callDuration = callStore((s) => s.callDuration);
|
||||
const peerStream = callStore((s) => s.peerStream);
|
||||
const localStream = callStore((s) => s.localStream);
|
||||
const endCall = callStore((s) => s.endCall);
|
||||
const toggleMute = callStore((s) => s.toggleMute);
|
||||
const toggleSpeaker = callStore((s) => s.toggleSpeaker);
|
||||
const toggleVideo = callStore((s) => s.toggleVideo);
|
||||
const isMinimized = callStore((s) => s.isMinimized);
|
||||
const toggleMinimize = callStore((s) => s.toggleMinimize);
|
||||
|
||||
// Track whether peer has video
|
||||
const [hasPeerVideo, setHasPeerVideo] = useState(false);
|
||||
// Track whether local has video
|
||||
const [hasLocalVideo, setHasLocalVideo] = useState(false);
|
||||
|
||||
// Check peer stream for video tracks
|
||||
useEffect(() => {
|
||||
if (peerStream) {
|
||||
const videoTracks = peerStream.getVideoTracks();
|
||||
const hasVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled);
|
||||
setHasPeerVideo(hasVideo);
|
||||
} else {
|
||||
setHasPeerVideo(false);
|
||||
}
|
||||
}, [peerStream]);
|
||||
// Check local stream for video tracks
|
||||
useEffect(() => {
|
||||
if (localStream) {
|
||||
const videoTracks = localStream.getVideoTracks();
|
||||
const hasVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled);
|
||||
setHasLocalVideo(hasVideo);
|
||||
} else {
|
||||
setHasLocalVideo(false);
|
||||
}
|
||||
}, [localStream]);
|
||||
// Don't render full screen when minimized or no call
|
||||
if (!currentCall || isMinimized) return null;
|
||||
const getStatusText = (): string => {
|
||||
switch (currentCall.status) {
|
||||
case 'calling':
|
||||
return '正在等待对方接听...';
|
||||
case 'ringing':
|
||||
return '来电响铃中...';
|
||||
case 'connecting':
|
||||
return '连接中...';
|
||||
case 'connected':
|
||||
return formatDuration(callDuration);
|
||||
case 'reconnecting':
|
||||
return '网络重连中...';
|
||||
case 'ended':
|
||||
return '通话已结束';
|
||||
case 'failed':
|
||||
return '连接失败';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
const handleEndCall = () => {
|
||||
endCall('hangup');
|
||||
};
|
||||
const handleToggleMute = () => {
|
||||
toggleMute();
|
||||
};
|
||||
const handleToggleSpeaker = () => {
|
||||
toggleSpeaker();
|
||||
};
|
||||
const handleToggleVideo = () => {
|
||||
toggleVideo();
|
||||
};
|
||||
const handleMinimize = () => {
|
||||
toggleMinimize();
|
||||
};
|
||||
// Determine if we should show video UI
|
||||
const showRemoteVideo = hasPeerVideo;
|
||||
// Use currentCall.isVideoEnabled directly for local video
|
||||
// This is more reliable than checking localStream.getVideoTracks()
|
||||
// because the stream object reference may not trigger useEffect properly
|
||||
const showLocalVideo = currentCall?.isVideoEnabled && localStream;
|
||||
const isVideoCallActive = showRemoteVideo || showLocalVideo;
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
|
||||
{/* Background */}
|
||||
<View style={styles.background} />
|
||||
{/* Remote video - full screen when peer has video */}
|
||||
{showRemoteVideo && peerStream && (
|
||||
<RTCView
|
||||
streamURL={peerStream.toURL()}
|
||||
style={styles.fullScreenVideo}
|
||||
objectFit="cover"
|
||||
mirror={false}
|
||||
/>
|
||||
)}
|
||||
{/* Local video - picture in picture */}
|
||||
{showLocalVideo && localStream && (
|
||||
<View style={styles.localVideoContainer}>
|
||||
<RTCView
|
||||
streamURL={localStream.toURL()}
|
||||
style={styles.localVideo}
|
||||
objectFit="cover"
|
||||
mirror={true}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
{/* Top area: minimize button */}
|
||||
<View style={styles.topBar}>
|
||||
<TouchableOpacity
|
||||
style={styles.topButton}
|
||||
onPress={handleMinimize}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="chevron-down" size={28} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{/* Center: Peer info - only show when no video */}
|
||||
{!isVideoCallActive && (
|
||||
<View style={styles.centerArea}>
|
||||
<View style={styles.avatarOuter}>
|
||||
{currentCall.peerAvatar ? (
|
||||
<Image source={{ uri: currentCall.peerAvatar }} style={styles.avatar} />
|
||||
) : (
|
||||
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
||||
<Text style={styles.avatarText}>
|
||||
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.peerName} numberOfLines={1}>
|
||||
{currentCall.peerName || '未知用户'}
|
||||
</Text>
|
||||
<Text style={styles.status}>{getStatusText()}</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Peer name overlay when video is active */}
|
||||
{isVideoCallActive && (
|
||||
<View style={styles.videoOverlayInfo}>
|
||||
<Text style={styles.videoPeerName} numberOfLines={1}>
|
||||
{currentCall.peerName || '未知用户'}
|
||||
</Text>
|
||||
<Text style={styles.videoStatus}>{getStatusText()}</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* Bottom controls */}
|
||||
<View style={styles.controls}>
|
||||
<View style={styles.controlRow}>
|
||||
{/* Mute */}
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, currentCall.isMuted && styles.controlButtonActive]}
|
||||
onPress={handleToggleMute}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.controlCircle, currentCall.isMuted && styles.controlCircleActive]}>
|
||||
<MaterialCommunityIcons
|
||||
name={currentCall.isMuted ? 'microphone-off' : 'microphone'}
|
||||
size={26}
|
||||
color={currentCall.isMuted ? '#333' : '#fff'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.controlLabel, currentCall.isMuted && styles.controlLabelActive]}>
|
||||
{currentCall.isMuted ? '取消静音' : '静音'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{/* Video toggle */}
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, hasLocalVideo && styles.controlButtonActive]}
|
||||
onPress={handleToggleVideo}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.controlCircle, hasLocalVideo && styles.controlCircleActive]}>
|
||||
<MaterialCommunityIcons
|
||||
name={hasLocalVideo ? 'video' : 'video-off'}
|
||||
size={26}
|
||||
color={hasLocalVideo ? '#333' : '#fff'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.controlLabel, hasLocalVideo && styles.controlLabelActive]}>
|
||||
{hasLocalVideo ? '关闭摄像头' : '打开摄像头'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{/* Speaker */}
|
||||
<TouchableOpacity
|
||||
style={[styles.controlButton, currentCall.isSpeakerOn && styles.controlButtonActive]}
|
||||
onPress={handleToggleSpeaker}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.controlCircle, currentCall.isSpeakerOn && styles.controlCircleActive]}>
|
||||
<MaterialCommunityIcons
|
||||
name={currentCall.isSpeakerOn ? 'volume-high' : 'cellphone'}
|
||||
size={26}
|
||||
color={currentCall.isSpeakerOn ? '#333' : '#fff'}
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.controlLabel, currentCall.isSpeakerOn && styles.controlLabelActive]}>
|
||||
{currentCall.isSpeakerOn ? '免提' : '听筒'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
{/* End call - prominent red button */}
|
||||
<TouchableOpacity
|
||||
style={styles.endCallButton}
|
||||
onPress={handleEndCall}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.endCallCircle}>
|
||||
<MaterialCommunityIcons name="phone-hangup" size={32} color="#fff" />
|
||||
</View>
|
||||
<Text style={styles.endCallLabel}>挂断</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const CONTROL_CIRCLE_SIZE = 56;
|
||||
const END_CALL_SIZE = 64;
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: '#1A1A2E',
|
||||
zIndex: 9999,
|
||||
},
|
||||
background: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: '#1A1A2E',
|
||||
},
|
||||
topBar: {
|
||||
position: 'absolute',
|
||||
top: 50,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: 44,
|
||||
zIndex: 100,
|
||||
},
|
||||
topButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
centerArea: {
|
||||
position: 'absolute',
|
||||
top: '28%',
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: 30,
|
||||
},
|
||||
avatarOuter: {
|
||||
marginBottom: 16,
|
||||
},
|
||||
avatar: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: 50,
|
||||
backgroundColor: '#3A3A5C',
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 40,
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
peerName: {
|
||||
fontSize: 22,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
marginBottom: 6,
|
||||
textAlign: 'center',
|
||||
maxWidth: 280,
|
||||
},
|
||||
status: {
|
||||
fontSize: 14,
|
||||
color: 'rgba(255, 255, 255, 0.5)',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
fullScreenVideo: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: '#1A1A2E',
|
||||
},
|
||||
localVideoContainer: {
|
||||
position: 'absolute',
|
||||
top: 100,
|
||||
right: 20,
|
||||
width: 120,
|
||||
height: 160,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: '#2A2A4E',
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(255, 255, 255, 0.2)',
|
||||
zIndex: 50,
|
||||
},
|
||||
localVideo: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
videoOverlayInfo: {
|
||||
position: 'absolute',
|
||||
top: 100,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
zIndex: 50,
|
||||
},
|
||||
videoPeerName: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
},
|
||||
videoStatus: {
|
||||
fontSize: 14,
|
||||
color: 'rgba(255, 255, 255, 0.8)',
|
||||
marginTop: 4,
|
||||
textShadowColor: 'rgba(0, 0, 0, 0.5)',
|
||||
textShadowOffset: { width: 0, height: 1 },
|
||||
textShadowRadius: 2,
|
||||
},
|
||||
controls: {
|
||||
position: 'absolute',
|
||||
bottom: 60,
|
||||
left: 0,
|
||||
right: 0,
|
||||
alignItems: 'center',
|
||||
zIndex: 100,
|
||||
},
|
||||
controlRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
gap: 24,
|
||||
marginBottom: 24,
|
||||
},
|
||||
controlButton: {
|
||||
alignItems: 'center',
|
||||
width: 70,
|
||||
},
|
||||
controlCircle: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.12)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
controlCircleActive: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
controlButtonActive: {},
|
||||
controlLabel: {
|
||||
fontSize: 11,
|
||||
color: 'rgba(255, 255, 255, 0.55)',
|
||||
marginTop: 8,
|
||||
textAlign: 'center',
|
||||
},
|
||||
controlLabelActive: {
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '500',
|
||||
},
|
||||
endCallButton: {
|
||||
alignItems: 'center',
|
||||
},
|
||||
endCallCircle: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
backgroundColor: '#E54D42',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
endCallLabel: {
|
||||
fontSize: 11,
|
||||
color: 'rgba(255, 255, 255, 0.55)',
|
||||
marginTop: 8,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
export default CallScreen;
|
||||
160
src/components/call/CallScreen.web.tsx
Normal file
160
src/components/call/CallScreen.web.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { callStore } from '../../stores/callStore';
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const CallScreen: React.FC = () => {
|
||||
const currentCall = callStore((s) => s.currentCall);
|
||||
const callDuration = callStore((s) => s.callDuration);
|
||||
const endCall = callStore((s) => s.endCall);
|
||||
const isMinimized = callStore((s) => s.isMinimized);
|
||||
|
||||
// Don't render full screen when minimized or no call
|
||||
if (!currentCall || isMinimized) return null;
|
||||
|
||||
const getStatusText = (): string => {
|
||||
switch (currentCall.status) {
|
||||
case 'ringing':
|
||||
return '通话功能暂不支持网页端';
|
||||
case 'connecting':
|
||||
return '通话功能暂不支持网页端';
|
||||
case 'connected':
|
||||
return '通话功能暂不支持网页端';
|
||||
case '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;
|
||||
159
src/components/call/FloatingCallWindow.tsx
Normal file
159
src/components/call/FloatingCallWindow.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { callStore } from '../../stores/callStore';
|
||||
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const FloatingCallWindow: React.FC = () => {
|
||||
const currentCall = callStore((s) => s.currentCall);
|
||||
const callDuration = callStore((s) => s.callDuration);
|
||||
const isMinimized = callStore((s) => s.isMinimized);
|
||||
const toggleMinimize = callStore((s) => s.toggleMinimize);
|
||||
const endCall = callStore((s) => s.endCall);
|
||||
|
||||
// Only show when there's an active call and it's minimized
|
||||
if (!currentCall || !isMinimized) return null;
|
||||
|
||||
const getStatusText = (): string => {
|
||||
switch (currentCall.status) {
|
||||
case 'calling':
|
||||
return '等待接听...';
|
||||
case 'ringing':
|
||||
return '来电响铃...';
|
||||
case 'connecting':
|
||||
return '连接中...';
|
||||
case 'connected':
|
||||
return formatDuration(callDuration);
|
||||
case 'reconnecting':
|
||||
return '重连中...';
|
||||
case 'ended':
|
||||
return '已结束';
|
||||
case 'failed':
|
||||
return '连接失败';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<TouchableOpacity
|
||||
style={styles.window}
|
||||
onPress={toggleMinimize}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{/* Avatar */}
|
||||
<View style={styles.avatarContainer}>
|
||||
{currentCall.peerAvatar ? (
|
||||
<Image
|
||||
source={{ uri: currentCall.peerAvatar }}
|
||||
style={styles.avatar}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.avatar, styles.avatarPlaceholder]}>
|
||||
<Text style={styles.avatarText}>
|
||||
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Info */}
|
||||
<View style={styles.info}>
|
||||
<Text style={styles.name} numberOfLines={1}>
|
||||
{currentCall.peerName || '未知用户'}
|
||||
</Text>
|
||||
<Text style={styles.status}>{getStatusText()}</Text>
|
||||
</View>
|
||||
|
||||
{/* End call button */}
|
||||
<TouchableOpacity
|
||||
style={styles.endButton}
|
||||
onPress={(e) => {
|
||||
e.stopPropagation();
|
||||
endCall('hangup');
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="phone-hangup" size={20} color="#fff" />
|
||||
</TouchableOpacity>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'absolute',
|
||||
top: 100,
|
||||
left: 16,
|
||||
right: 16,
|
||||
zIndex: 10000,
|
||||
},
|
||||
window: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: '#2A2A4E',
|
||||
borderRadius: 16,
|
||||
padding: 12,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 8,
|
||||
},
|
||||
avatarContainer: {
|
||||
marginRight: 12,
|
||||
},
|
||||
avatar: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: '#3A3A5C',
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
avatarText: {
|
||||
fontSize: 18,
|
||||
color: '#fff',
|
||||
fontWeight: '600',
|
||||
},
|
||||
info: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
name: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#FFFFFF',
|
||||
marginBottom: 2,
|
||||
},
|
||||
status: {
|
||||
fontSize: 12,
|
||||
color: 'rgba(255, 255, 255, 0.6)',
|
||||
},
|
||||
endButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
backgroundColor: '#E54D42',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginLeft: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default FloatingCallWindow;
|
||||
279
src/components/call/IncomingCallModal.tsx
Normal file
279
src/components/call/IncomingCallModal.tsx
Normal file
@@ -0,0 +1,279 @@
|
||||
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;
|
||||
3
src/components/call/index.ts
Normal file
3
src/components/call/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { default as CallScreen } from './CallScreen';
|
||||
export { default as IncomingCallModal } from './IncomingCallModal';
|
||||
export { default as FloatingCallWindow } from './FloatingCallWindow';
|
||||
@@ -15,12 +15,14 @@ import {
|
||||
Modal,
|
||||
Pressable,
|
||||
Dimensions,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import {
|
||||
useResponsive,
|
||||
useBreakpointGTE,
|
||||
FineBreakpointKey,
|
||||
} from '../../hooks/useResponsive';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
|
||||
export interface AdaptiveLayoutProps {
|
||||
/** 主内容区 */
|
||||
@@ -159,6 +161,9 @@ export function AdaptiveLayout({
|
||||
// 抽屉动画
|
||||
useEffect(() => {
|
||||
if (isDrawerOpen) {
|
||||
if (isDrawerOpen) {
|
||||
blurActiveElement();
|
||||
}
|
||||
Animated.parallel([
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 1,
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { Modal, Pressable, StyleSheet, Text, TouchableOpacity, View, Platform } from 'react-native';
|
||||
import type { AlertButton } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
||||
import { bindDialogListener, DialogPayload } from '../../services/dialogService';
|
||||
import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
|
||||
function createDialogHostStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
@@ -105,6 +106,12 @@ const AppDialogHost: React.FC = () => {
|
||||
return unbind;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (dialog) {
|
||||
blurActiveElement();
|
||||
}
|
||||
}, [dialog]);
|
||||
|
||||
const actions = useMemo<AlertButton[]>(() => {
|
||||
if (!dialog?.actions?.length) return [{ text: '确定' }];
|
||||
return dialog.actions;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Text,
|
||||
StatusBar,
|
||||
Alert,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import {
|
||||
@@ -32,6 +33,7 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
import { spacing, borderRadius, fontSizes } from '../../theme';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
@@ -126,6 +128,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
// 打开/关闭时重置状态
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
blurActiveElement();
|
||||
setCurrentIndex(initialIndex);
|
||||
setShowControls(true);
|
||||
setError(false);
|
||||
|
||||
@@ -132,10 +132,10 @@ function createImageGridStyles(colors: AppColors) {
|
||||
aspectRatio: 1,
|
||||
},
|
||||
gridItem2: {
|
||||
width: '49%',
|
||||
width: '48.5%',
|
||||
},
|
||||
gridItem3: {
|
||||
width: '32.5%',
|
||||
width: '31.8%',
|
||||
},
|
||||
masonryContainer: {
|
||||
flexDirection: 'row',
|
||||
|
||||
31
src/components/common/PagerView.tsx
Normal file
31
src/components/common/PagerView.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
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;
|
||||
27
src/components/common/PagerView.web.tsx
Normal file
27
src/components/common/PagerView.web.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
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;
|
||||
200
src/components/common/StepIndicator.tsx
Normal file
200
src/components/common/StepIndicator.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 步骤指示器组件 - 极简扁平设计
|
||||
* 与下方表单风格统一
|
||||
*/
|
||||
|
||||
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 { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle } from 'react-native';
|
||||
import { useAppColors, fontSizes } from '../../theme';
|
||||
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle, Platform } from 'react-native';
|
||||
import { useAppColors, fontSizes, fontWeights } from '../../theme';
|
||||
|
||||
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
|
||||
|
||||
@@ -16,38 +16,45 @@ interface CustomTextProps extends Omit<TextProps, 'style'> {
|
||||
numberOfLines?: number;
|
||||
onPress?: () => void;
|
||||
style?: TextStyle | TextStyle[];
|
||||
weight?: 'regular' | 'medium' | 'semibold' | 'bold';
|
||||
}
|
||||
|
||||
const variantStyles: Record<TextVariant, object> = {
|
||||
h1: {
|
||||
fontSize: fontSizes['4xl'],
|
||||
fontWeight: '700',
|
||||
lineHeight: fontSizes['4xl'] * 1.4,
|
||||
fontWeight: fontWeights.bold,
|
||||
lineHeight: fontSizes['4xl'] * 1.3,
|
||||
letterSpacing: -0.5,
|
||||
},
|
||||
h2: {
|
||||
fontSize: fontSizes['3xl'],
|
||||
fontWeight: '600',
|
||||
lineHeight: fontSizes['3xl'] * 1.4,
|
||||
fontWeight: fontWeights.semibold,
|
||||
lineHeight: fontSizes['3xl'] * 1.3,
|
||||
letterSpacing: -0.3,
|
||||
},
|
||||
h3: {
|
||||
fontSize: fontSizes['2xl'],
|
||||
fontWeight: '600',
|
||||
fontWeight: fontWeights.semibold,
|
||||
lineHeight: fontSizes['2xl'] * 1.3,
|
||||
letterSpacing: -0.2,
|
||||
},
|
||||
body: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '400',
|
||||
lineHeight: fontSizes.md * 1.5,
|
||||
fontWeight: fontWeights.regular,
|
||||
lineHeight: fontSizes.md * 1.6,
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
caption: {
|
||||
fontSize: fontSizes.sm,
|
||||
fontWeight: '400',
|
||||
lineHeight: fontSizes.sm * 1.4,
|
||||
fontWeight: fontWeights.regular,
|
||||
lineHeight: fontSizes.sm * 1.5,
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
label: {
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '500',
|
||||
fontWeight: fontWeights.medium,
|
||||
lineHeight: fontSizes.xs * 1.4,
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -55,6 +62,7 @@ const Text: React.FC<CustomTextProps> = ({
|
||||
children,
|
||||
variant = 'body',
|
||||
color,
|
||||
weight,
|
||||
numberOfLines,
|
||||
onPress,
|
||||
style,
|
||||
@@ -64,6 +72,7 @@ const Text: React.FC<CustomTextProps> = ({
|
||||
const textStyle = [
|
||||
styles.base,
|
||||
variantStyles[variant],
|
||||
weight ? { fontWeight: fontWeights[weight] } : null,
|
||||
color ? { color } : { color: colors.text.primary },
|
||||
style,
|
||||
];
|
||||
|
||||
256
src/components/common/VerificationCodeInput.tsx
Normal file
256
src/components/common/VerificationCodeInput.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 验证码输入组件
|
||||
* 参考设计: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
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
@@ -12,11 +12,13 @@ import {
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
StatusBar,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { VideoView, useVideoPlayer } from 'expo-video';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { blurActiveElement } from '../../infrastructure/platform';
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
@@ -79,6 +81,12 @@ export const VideoPlayerModal: React.FC<VideoPlayerModalProps> = ({
|
||||
url,
|
||||
onClose,
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
blurActiveElement();
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
|
||||
@@ -17,6 +17,7 @@ export { default as ResponsiveGrid } from './ResponsiveGrid';
|
||||
export { default as ResponsiveStack, HStack, VStack } from './ResponsiveStack';
|
||||
export { default as AdaptiveLayout, SidebarLayout } from './AdaptiveLayout';
|
||||
export { default as AppBackButton } from './AppBackButton';
|
||||
export { default as PagerView } from './PagerView';
|
||||
|
||||
// 图片相关组件
|
||||
export { default as SmartImage } from './SmartImage';
|
||||
@@ -32,3 +33,8 @@ export type { ImageGalleryProps, GalleryImageItem } from './ImageGallery';
|
||||
export type { ResponsiveGridProps } from './ResponsiveGrid';
|
||||
export type { ResponsiveStackProps, StackDirection, StackAlignment, StackJustify } from './ResponsiveStack';
|
||||
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依赖
|
||||
*/
|
||||
|
||||
import { messageRepository } from '../../data/repositories/MessageRepository';
|
||||
import { sseClient, SSEEventType } from '../../data/datasources/SSEClient';
|
||||
import { messageRepository, userCacheRepository, CachedMessage } from '@/database';
|
||||
import { wsClient, WSEventType } from '../../data/datasources/WSClient';
|
||||
import {
|
||||
Message,
|
||||
Conversation,
|
||||
@@ -18,6 +18,16 @@ import {
|
||||
import { messageService } from '../../services/messageService';
|
||||
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 =
|
||||
| 'message_received'
|
||||
@@ -62,7 +72,7 @@ class ProcessMessageUseCase {
|
||||
*/
|
||||
initialize(currentUserId: string | null): void {
|
||||
this.currentUserId = currentUserId;
|
||||
this.setupSSEListeners();
|
||||
this.setupWSListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,49 +91,49 @@ class ProcessMessageUseCase {
|
||||
/**
|
||||
* 设置SSE监听器
|
||||
*/
|
||||
private setupSSEListeners(): void {
|
||||
private setupWSListeners(): void {
|
||||
// 监听私聊消息
|
||||
const unsubChat = sseClient.on('chat', (message) => {
|
||||
const unsubChat = wsClient.on('chat', (message) => {
|
||||
this.handleNewMessage(message);
|
||||
});
|
||||
|
||||
// 监听群聊消息
|
||||
const unsubGroupMessage = sseClient.on('group_message', (message) => {
|
||||
const unsubGroupMessage = wsClient.on('group_message', (message) => {
|
||||
this.handleNewMessage(message);
|
||||
});
|
||||
|
||||
// 监听私聊已读回执
|
||||
const unsubRead = sseClient.on('read', (message) => {
|
||||
const unsubRead = wsClient.on('read', (message) => {
|
||||
this.handleReadReceipt(message);
|
||||
});
|
||||
|
||||
// 监听群聊已读回执
|
||||
const unsubGroupRead = sseClient.on('group_read', (message) => {
|
||||
const unsubGroupRead = wsClient.on('group_read', (message) => {
|
||||
this.handleGroupReadReceipt(message);
|
||||
});
|
||||
|
||||
// 监听私聊消息撤回
|
||||
const unsubRecall = sseClient.on('recall', (message) => {
|
||||
const unsubRecall = wsClient.on('recall', (message) => {
|
||||
this.handleRecallMessage(message);
|
||||
});
|
||||
|
||||
// 监听群聊消息撤回
|
||||
const unsubGroupRecall = sseClient.on('group_recall', (message) => {
|
||||
const unsubGroupRecall = wsClient.on('group_recall', (message) => {
|
||||
this.handleGroupRecallMessage(message);
|
||||
});
|
||||
|
||||
// 监听群聊输入状态
|
||||
const unsubGroupTyping = sseClient.on('group_typing', (message) => {
|
||||
const unsubGroupTyping = wsClient.on('group_typing', (message) => {
|
||||
this.handleGroupTyping(message);
|
||||
});
|
||||
|
||||
// 监听群通知
|
||||
const unsubGroupNotice = sseClient.on('group_notice', (message) => {
|
||||
const unsubGroupNotice = wsClient.on('group_notice', (message) => {
|
||||
this.handleGroupNotice(message);
|
||||
});
|
||||
|
||||
// 监听连接状态
|
||||
const unsubConnected = sseClient.on('connected', () => {
|
||||
const unsubConnected = wsClient.on('connected', () => {
|
||||
this.notifySubscribers({
|
||||
type: 'connection_changed',
|
||||
payload: { connected: true },
|
||||
@@ -131,7 +141,7 @@ class ProcessMessageUseCase {
|
||||
});
|
||||
});
|
||||
|
||||
const unsubDisconnected = sseClient.on('disconnected', () => {
|
||||
const unsubDisconnected = wsClient.on('disconnected', () => {
|
||||
this.notifySubscribers({
|
||||
type: 'connection_changed',
|
||||
payload: { connected: false },
|
||||
@@ -182,7 +192,16 @@ class ProcessMessageUseCase {
|
||||
|
||||
// 异步保存到本地数据库
|
||||
const isRead = _isAck || sender_id === this.currentUserId;
|
||||
messageRepository.saveMessage(newMessage, isRead).catch((error) => {
|
||||
messageRepository.saveMessage({
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -227,7 +246,7 @@ class ProcessMessageUseCase {
|
||||
*/
|
||||
private async getSenderInfo(userId: string): Promise<any | null> {
|
||||
// 先检查本地缓存
|
||||
const cachedUser = await messageRepository.getUserCache(userId);
|
||||
const cachedUser = await userCacheRepository.get(userId);
|
||||
if (cachedUser) {
|
||||
return cachedUser;
|
||||
}
|
||||
@@ -255,9 +274,9 @@ class ProcessMessageUseCase {
|
||||
*/
|
||||
private async fetchUserInfo(userId: string): Promise<any | null> {
|
||||
try {
|
||||
const response = await api.get(`/users/${userId}`);
|
||||
const response = await api.get<any>(`/users/${userId}`);
|
||||
if (response.code === 0 && response.data) {
|
||||
await messageRepository.saveUserCache(response.data);
|
||||
await userCacheRepository.save(response.data as any);
|
||||
return response.data;
|
||||
}
|
||||
return null;
|
||||
@@ -322,8 +341,8 @@ class ProcessMessageUseCase {
|
||||
|
||||
// 更新本地数据库状态
|
||||
messageRepository
|
||||
.updateMessageStatus(message_id, 'recalled', true)
|
||||
.catch((error) => {
|
||||
.updateStatus(message_id, 'recalled', true)
|
||||
.catch((error: unknown) => {
|
||||
console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error);
|
||||
});
|
||||
|
||||
@@ -349,8 +368,8 @@ class ProcessMessageUseCase {
|
||||
|
||||
// 更新本地数据库状态
|
||||
messageRepository
|
||||
.updateMessageStatus(message_id, 'recalled', true)
|
||||
.catch((error) => {
|
||||
.updateStatus(message_id, 'recalled', true)
|
||||
.catch((error: unknown) => {
|
||||
console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error);
|
||||
});
|
||||
|
||||
@@ -506,7 +525,16 @@ class ProcessMessageUseCase {
|
||||
});
|
||||
|
||||
// 保存到本地数据库
|
||||
await messageRepository.saveMessage(message, true);
|
||||
await messageRepository.saveMessage({
|
||||
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;
|
||||
}
|
||||
@@ -522,7 +550,8 @@ class ProcessMessageUseCase {
|
||||
* 获取本地消息
|
||||
*/
|
||||
async getLocalMessages(conversationId: string, limit: number = 20): Promise<Message[]> {
|
||||
return messageRepository.getMessagesByConversation(conversationId, limit);
|
||||
const cached = await messageRepository.getByConversation(conversationId, limit);
|
||||
return cached.map(cachedMessageToMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -534,14 +563,14 @@ class ProcessMessageUseCase {
|
||||
limit: number = 20
|
||||
): Promise<Message[]> {
|
||||
// 先从本地获取
|
||||
const localMessages = await messageRepository.getMessagesBeforeSeq(
|
||||
const localMessages = await messageRepository.getBeforeSeq(
|
||||
conversationId,
|
||||
beforeSeq,
|
||||
limit
|
||||
);
|
||||
|
||||
if (localMessages.length >= limit) {
|
||||
return localMessages;
|
||||
return localMessages.map(cachedMessageToMessage);
|
||||
}
|
||||
|
||||
// 本地数据不足,从服务端获取
|
||||
@@ -567,7 +596,16 @@ class ProcessMessageUseCase {
|
||||
);
|
||||
|
||||
// 保存到本地
|
||||
await messageRepository.saveMessages(serverMessages, false);
|
||||
await messageRepository.saveMessagesBatch(serverMessages.map((m: any) => ({
|
||||
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;
|
||||
}
|
||||
@@ -575,7 +613,7 @@ class ProcessMessageUseCase {
|
||||
console.error('[ProcessMessageUseCase] 获取历史消息失败:', error);
|
||||
}
|
||||
|
||||
return localMessages;
|
||||
return localMessages.map(cachedMessageToMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -602,7 +640,16 @@ class ProcessMessageUseCase {
|
||||
);
|
||||
|
||||
// 保存到本地
|
||||
await messageRepository.saveMessages(messages, false);
|
||||
await messageRepository.saveMessagesBatch(messages.map((m: any) => ({
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,323 +0,0 @@
|
||||
/**
|
||||
* 本地数据库数据源实现
|
||||
* 封装所有 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 @@
|
||||
/**
|
||||
* SSEClient - SSE连接管理
|
||||
* 只负责SSE连接管理,提供事件驱动架构
|
||||
* WSClient - WebSocket连接管理
|
||||
* 只负责WebSocket连接管理,提供事件驱动架构
|
||||
*/
|
||||
|
||||
import {
|
||||
sseService,
|
||||
wsService,
|
||||
WSChatMessage,
|
||||
WSGroupChatMessage,
|
||||
WSReadMessage,
|
||||
@@ -14,13 +14,13 @@ import {
|
||||
WSGroupTypingMessage,
|
||||
WSGroupNoticeMessage,
|
||||
WSMessageType,
|
||||
} from '../../services/sseService';
|
||||
} from '../../services/wsService';
|
||||
|
||||
// 事件处理器类型
|
||||
type EventHandler<T> = (data: T) => void;
|
||||
|
||||
// SSE事件类型
|
||||
export interface SSEEvents {
|
||||
// WS事件类型
|
||||
export interface WSEvents {
|
||||
'chat': WSChatMessage;
|
||||
'group_message': WSGroupChatMessage;
|
||||
'read': WSReadMessage;
|
||||
@@ -34,65 +34,65 @@ export interface SSEEvents {
|
||||
'error': Error;
|
||||
}
|
||||
|
||||
export type SSEEventType = keyof SSEEvents;
|
||||
export type WSEventType = keyof WSEvents;
|
||||
|
||||
class SSEClient {
|
||||
private handlers: Map<SSEEventType, Set<EventHandler<any>>> = new Map();
|
||||
class WSClient {
|
||||
private handlers: Map<WSEventType, Set<EventHandler<any>>> = new Map();
|
||||
private unsubscribeFns: Array<() => void> = [];
|
||||
private isInitialized = false;
|
||||
|
||||
/**
|
||||
* 初始化SSE监听
|
||||
* 初始化WebSocket监听
|
||||
*/
|
||||
initialize(): void {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
// 监听私聊消息
|
||||
const unsubChat = sseService.on('chat', (message) => {
|
||||
const unsubChat = wsService.on('chat', (message) => {
|
||||
this.emit('chat', message);
|
||||
});
|
||||
|
||||
// 监听群聊消息
|
||||
const unsubGroupMessage = sseService.on('group_message', (message) => {
|
||||
const unsubGroupMessage = wsService.on('group_message', (message) => {
|
||||
this.emit('group_message', message);
|
||||
});
|
||||
|
||||
// 监听私聊已读回执
|
||||
const unsubRead = sseService.on('read', (message) => {
|
||||
const unsubRead = wsService.on('read', (message) => {
|
||||
this.emit('read', message);
|
||||
});
|
||||
|
||||
// 监听群聊已读回执
|
||||
const unsubGroupRead = sseService.on('group_read', (message) => {
|
||||
const unsubGroupRead = wsService.on('group_read', (message) => {
|
||||
this.emit('group_read', message);
|
||||
});
|
||||
|
||||
// 监听私聊消息撤回
|
||||
const unsubRecall = sseService.on('recall', (message) => {
|
||||
const unsubRecall = wsService.on('recall', (message) => {
|
||||
this.emit('recall', message);
|
||||
});
|
||||
|
||||
// 监听群聊消息撤回
|
||||
const unsubGroupRecall = sseService.on('group_recall', (message) => {
|
||||
const unsubGroupRecall = wsService.on('group_recall', (message) => {
|
||||
this.emit('group_recall', message);
|
||||
});
|
||||
|
||||
// 监听群聊输入状态
|
||||
const unsubGroupTyping = sseService.on('group_typing', (message) => {
|
||||
const unsubGroupTyping = wsService.on('group_typing', (message) => {
|
||||
this.emit('group_typing', message);
|
||||
});
|
||||
|
||||
// 监听群通知
|
||||
const unsubGroupNotice = sseService.on('group_notice', (message) => {
|
||||
const unsubGroupNotice = wsService.on('group_notice', (message) => {
|
||||
this.emit('group_notice', message);
|
||||
});
|
||||
|
||||
// 监听连接状态
|
||||
const unsubConnect = sseService.onConnect(() => {
|
||||
const unsubConnect = wsService.onConnect(() => {
|
||||
this.emit('connected', undefined);
|
||||
});
|
||||
|
||||
const unsubDisconnect = sseService.onDisconnect(() => {
|
||||
const unsubDisconnect = wsService.onDisconnect(() => {
|
||||
this.emit('disconnected', undefined);
|
||||
});
|
||||
|
||||
@@ -113,7 +113,7 @@ class SSEClient {
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁SSE监听
|
||||
* 销毁WebSocket监听
|
||||
*/
|
||||
destroy(): void {
|
||||
this.unsubscribeFns.forEach((fn) => fn());
|
||||
@@ -125,9 +125,9 @@ class SSEClient {
|
||||
/**
|
||||
* 订阅事件
|
||||
*/
|
||||
on<T extends SSEEventType>(
|
||||
on<T extends WSEventType>(
|
||||
event: T,
|
||||
handler: EventHandler<SSEEvents[T]>
|
||||
handler: EventHandler<WSEvents[T]>
|
||||
): () => void {
|
||||
if (!this.handlers.has(event)) {
|
||||
this.handlers.set(event, new Set());
|
||||
@@ -142,9 +142,9 @@ class SSEClient {
|
||||
/**
|
||||
* 触发事件
|
||||
*/
|
||||
private emit<T extends SSEEventType>(
|
||||
private emit<T extends WSEventType>(
|
||||
event: T,
|
||||
data: SSEEvents[T]
|
||||
data: WSEvents[T]
|
||||
): void {
|
||||
const handlers = this.handlers.get(event);
|
||||
if (handlers) {
|
||||
@@ -152,7 +152,7 @@ class SSEClient {
|
||||
try {
|
||||
handler(data);
|
||||
} catch (error) {
|
||||
console.error(`[SSEClient] 事件处理器执行失败: ${event}`, error);
|
||||
console.error(`[WSClient] 事件处理器执行失败: ${event}`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -162,23 +162,23 @@ class SSEClient {
|
||||
* 检查连接状态
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return sseService.isConnected();
|
||||
return wsService.isConnected();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动SSE连接
|
||||
* 启动WebSocket连接
|
||||
*/
|
||||
async connect(): Promise<boolean> {
|
||||
return sseService.start();
|
||||
return wsService.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开SSE连接
|
||||
* 断开WebSocket连接
|
||||
*/
|
||||
disconnect(): void {
|
||||
sseService.stop();
|
||||
wsService.stop();
|
||||
}
|
||||
}
|
||||
|
||||
export const sseClient = new SSEClient();
|
||||
export default sseClient;
|
||||
export const wsClient = new WSClient();
|
||||
export default wsClient;
|
||||
@@ -5,5 +5,5 @@
|
||||
|
||||
export * from './interfaces';
|
||||
export * from './ApiDataSource';
|
||||
export * from './LocalDataSource';
|
||||
export { LocalDataSource, localDataSource } from '@/database';
|
||||
export * from './CacheDataSource';
|
||||
|
||||
@@ -20,6 +20,10 @@ export interface ILocalDataSource {
|
||||
run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }>;
|
||||
transaction(operations: () => 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>;
|
||||
}
|
||||
|
||||
// 缓存数据源接口
|
||||
|
||||
289
src/data/repositories/MaterialRepository.ts
Normal file
289
src/data/repositories/MaterialRepository.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* 学习资料 Repository
|
||||
* 提供学习资料的数据访问接口
|
||||
*/
|
||||
import type {
|
||||
MaterialSubject,
|
||||
MaterialFile,
|
||||
MaterialFileType,
|
||||
} from '../../types/material';
|
||||
import { api } from '../../services/api';
|
||||
|
||||
// ==================== 接口定义 ====================
|
||||
|
||||
export interface GetMaterialsParams {
|
||||
subjectId?: string;
|
||||
fileType?: MaterialFileType;
|
||||
keyword?: string;
|
||||
sortBy?: 'createdAt' | 'downloadCount' | 'title';
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface MaterialsResult {
|
||||
materials: MaterialFile[];
|
||||
total: number;
|
||||
page: number;
|
||||
pageSize: number;
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
// ==================== API 响应类型 ====================
|
||||
|
||||
interface SubjectResponse {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
color: string;
|
||||
description: string;
|
||||
sort_order: number;
|
||||
is_active: boolean;
|
||||
material_count: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface MaterialFileResponse {
|
||||
id: string;
|
||||
subject_id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
file_type: string;
|
||||
file_size: number;
|
||||
file_url: string;
|
||||
file_name: string;
|
||||
download_count: number;
|
||||
author_id: string;
|
||||
tags: string[];
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
subject?: SubjectResponse;
|
||||
}
|
||||
|
||||
// ==================== 转换函数 ====================
|
||||
|
||||
function toMaterialSubject(res: SubjectResponse): MaterialSubject {
|
||||
return {
|
||||
id: res.id,
|
||||
name: res.name,
|
||||
icon: res.icon,
|
||||
color: res.color,
|
||||
description: res.description,
|
||||
sort_order: res.sort_order,
|
||||
is_active: res.is_active,
|
||||
material_count: res.material_count,
|
||||
created_at: res.created_at,
|
||||
updated_at: res.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
function toMaterialFile(res: MaterialFileResponse): MaterialFile {
|
||||
return {
|
||||
id: res.id,
|
||||
subject_id: res.subject_id,
|
||||
title: res.title,
|
||||
description: res.description,
|
||||
file_type: res.file_type as MaterialFileType,
|
||||
file_size: res.file_size,
|
||||
file_url: res.file_url,
|
||||
file_name: res.file_name,
|
||||
download_count: res.download_count,
|
||||
author_id: res.author_id,
|
||||
tags: res.tags || [],
|
||||
status: res.status as 'active' | 'inactive',
|
||||
created_at: res.created_at,
|
||||
updated_at: res.updated_at,
|
||||
subject: res.subject ? toMaterialSubject(res.subject) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== Repository 实现 ====================
|
||||
|
||||
/**
|
||||
* 学习资料数据仓库
|
||||
* 使用真实 API
|
||||
*/
|
||||
export class MaterialRepository {
|
||||
private static instance: MaterialRepository;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): MaterialRepository {
|
||||
if (!MaterialRepository.instance) {
|
||||
MaterialRepository.instance = new MaterialRepository();
|
||||
}
|
||||
return MaterialRepository.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取学科分类列表
|
||||
*/
|
||||
async getSubjects(): Promise<MaterialSubject[]> {
|
||||
try {
|
||||
const response = await api.get<{ list: SubjectResponse[] }>('/materials/subjects');
|
||||
return response.data.list.map(toMaterialSubject);
|
||||
} catch (error) {
|
||||
console.error('获取学科列表失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取学科详情
|
||||
*/
|
||||
async getSubjectById(id: string): Promise<MaterialSubject | null> {
|
||||
try {
|
||||
const response = await api.get<SubjectResponse>(`/materials/subjects/${id}`);
|
||||
return toMaterialSubject(response.data);
|
||||
} catch (error) {
|
||||
console.error('获取学科详情失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资料列表
|
||||
*/
|
||||
async getMaterials(params: GetMaterialsParams): Promise<MaterialsResult> {
|
||||
const {
|
||||
subjectId,
|
||||
fileType,
|
||||
keyword,
|
||||
sortBy = 'createdAt',
|
||||
sortOrder = 'desc',
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
} = params;
|
||||
|
||||
try {
|
||||
const queryParams: Record<string, any> = {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
};
|
||||
|
||||
if (subjectId) {
|
||||
queryParams.subject_id = subjectId;
|
||||
}
|
||||
if (fileType) {
|
||||
queryParams.file_type = fileType;
|
||||
}
|
||||
if (keyword) {
|
||||
queryParams.keyword = keyword;
|
||||
}
|
||||
|
||||
const response = await api.get<{
|
||||
list: MaterialFileResponse[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_more: boolean;
|
||||
}>('/materials', queryParams);
|
||||
|
||||
return {
|
||||
materials: response.data.list.map(toMaterialFile),
|
||||
total: response.data.total,
|
||||
page: response.data.page,
|
||||
pageSize: response.data.page_size,
|
||||
hasMore: response.data.has_more,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('获取资料列表失败:', error);
|
||||
return {
|
||||
materials: [],
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize,
|
||||
hasMore: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取资料详情
|
||||
*/
|
||||
async getMaterialById(id: string): Promise<MaterialFile | null> {
|
||||
try {
|
||||
const response = await api.get<MaterialFileResponse>(`/materials/${id}`);
|
||||
return toMaterialFile(response.data);
|
||||
} catch (error) {
|
||||
console.error('获取资料详情失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索资料
|
||||
*/
|
||||
async searchMaterials(keyword: string, params?: { fileType?: MaterialFileType }): Promise<MaterialsResult> {
|
||||
return this.getMaterials({
|
||||
keyword,
|
||||
fileType: params?.fileType,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取热门资料
|
||||
*/
|
||||
async getHotMaterials(limit: number = 10): Promise<MaterialFile[]> {
|
||||
try {
|
||||
const response = await api.get<{
|
||||
list: MaterialFileResponse[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_more: boolean;
|
||||
}>('/materials', {
|
||||
page: 1,
|
||||
page_size: limit,
|
||||
sort_by: 'download_count',
|
||||
sort_order: 'desc',
|
||||
});
|
||||
return response.data.list.map(toMaterialFile);
|
||||
} catch (error) {
|
||||
console.error('获取热门资料失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最新资料
|
||||
*/
|
||||
async getLatestMaterials(limit: number = 10): Promise<MaterialFile[]> {
|
||||
try {
|
||||
const response = await api.get<{
|
||||
list: MaterialFileResponse[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_more: boolean;
|
||||
}>('/materials', {
|
||||
page: 1,
|
||||
page_size: limit,
|
||||
sort_by: 'created_at',
|
||||
sort_order: 'desc',
|
||||
});
|
||||
return response.data.list.map(toMaterialFile);
|
||||
} catch (error) {
|
||||
console.error('获取最新资料失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取资料的下载链接(增加下载次数)
|
||||
*/
|
||||
async downloadMaterial(id: string): Promise<{ file_url: string; file_name: string } | null> {
|
||||
try {
|
||||
const response = await api.get<{ file_url: string; file_name: string }>(`/materials/${id}/download`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取下载链接失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const materialRepository = MaterialRepository.getInstance();
|
||||
@@ -1,220 +0,0 @@
|
||||
/**
|
||||
* 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 { LocalDataSource, localDataSource } from '../datasources/LocalDataSource';
|
||||
import { localDataSource } from '@/database';
|
||||
import { PostMapper } from '../mappers/PostMapper';
|
||||
import type { Post, PostImage, PostStatus } from '../../core/entities/Post';
|
||||
import { createPost } from '../../core/entities/Post';
|
||||
@@ -23,8 +23,8 @@ import type {
|
||||
* API帖子响应类型
|
||||
*/
|
||||
interface PostApiResponse {
|
||||
id: number;
|
||||
user_id: number;
|
||||
id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
images?: Array<{ url: string; width?: number; height?: number; thumbnail_url?: string }>;
|
||||
@@ -44,7 +44,7 @@ interface PostApiResponse {
|
||||
content_edited_at?: string;
|
||||
channel?: { id: string; name: string } | null;
|
||||
author?: {
|
||||
id: number;
|
||||
id: string;
|
||||
username: string;
|
||||
nickname?: string;
|
||||
avatar?: string;
|
||||
@@ -79,13 +79,12 @@ interface CachedPost {
|
||||
|
||||
export class PostRepository implements IPostRepository {
|
||||
private api: ApiDataSource;
|
||||
private localDb: LocalDataSource;
|
||||
private localDb = localDataSource;
|
||||
private memoryCache: Map<string, { post: Post; timestamp: number }> = new Map();
|
||||
private readonly CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存过期时间
|
||||
private readonly CACHE_TTL = 5 * 60 * 1000;
|
||||
|
||||
constructor(api: ApiDataSource, localDb: LocalDataSource) {
|
||||
constructor(api: ApiDataSource) {
|
||||
this.api = api;
|
||||
this.localDb = localDb;
|
||||
}
|
||||
|
||||
// ==================== 私有辅助方法 ====================
|
||||
@@ -180,7 +179,6 @@ export class PostRepository implements IPostRepository {
|
||||
*/
|
||||
private async getFromLocalCache(id: string): Promise<Post | null> {
|
||||
try {
|
||||
await this.localDb.initialize();
|
||||
const result = await this.localDb.getFirst<CachedPost>(
|
||||
'SELECT * FROM posts_cache WHERE id = ?',
|
||||
[id]
|
||||
@@ -201,7 +199,6 @@ export class PostRepository implements IPostRepository {
|
||||
*/
|
||||
private async saveToLocalCache(post: Post): Promise<void> {
|
||||
try {
|
||||
await this.localDb.initialize();
|
||||
await this.localDb.enqueueWrite(async () => {
|
||||
await this.localDb.run(
|
||||
`INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
|
||||
@@ -218,7 +215,6 @@ export class PostRepository implements IPostRepository {
|
||||
*/
|
||||
private async clearLocalCache(id: string): Promise<void> {
|
||||
try {
|
||||
await this.localDb.initialize();
|
||||
await this.localDb.enqueueWrite(async () => {
|
||||
await this.localDb.run('DELETE FROM posts_cache WHERE id = ?', [id]);
|
||||
});
|
||||
@@ -555,7 +551,6 @@ export class PostRepository implements IPostRepository {
|
||||
async clearAllCache(): Promise<void> {
|
||||
this.memoryCache.clear();
|
||||
try {
|
||||
await this.localDb.initialize();
|
||||
await this.localDb.enqueueWrite(async () => {
|
||||
await this.localDb.run('DELETE FROM posts_cache');
|
||||
});
|
||||
@@ -575,5 +570,5 @@ export class PostRepository implements IPostRepository {
|
||||
|
||||
// ==================== 单例导出 ====================
|
||||
|
||||
export const postRepository = new PostRepository(apiDataSource, localDataSource);
|
||||
export const postRepository = new PostRepository(apiDataSource);
|
||||
export default postRepository;
|
||||
|
||||
88
src/database/LocalDataSource.ts
Normal file
88
src/database/LocalDataSource.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
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();
|
||||
6
src/database/core/DatabaseConfig.ts
Normal file
6
src/database/core/DatabaseConfig.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
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;
|
||||
}
|
||||
137
src/database/core/DatabaseManager.ts
Normal file
137
src/database/core/DatabaseManager.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
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 };
|
||||
34
src/database/core/LockManager.ts
Normal file
34
src/database/core/LockManager.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
58
src/database/index.ts
Normal file
58
src/database/index.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
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();
|
||||
};
|
||||
197
src/database/repositories/ConversationRepository.ts
Normal file
197
src/database/repositories/ConversationRepository.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
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();
|
||||
78
src/database/repositories/GroupCacheRepository.ts
Normal file
78
src/database/repositories/GroupCacheRepository.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
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();
|
||||
152
src/database/repositories/MessageRepository.ts
Normal file
152
src/database/repositories/MessageRepository.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
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();
|
||||
44
src/database/repositories/PostCacheRepository.ts
Normal file
44
src/database/repositories/PostCacheRepository.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
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();
|
||||
81
src/database/repositories/UserCacheRepository.ts
Normal file
81
src/database/repositories/UserCacheRepository.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
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();
|
||||
5
src/database/repositories/index.ts
Normal file
5
src/database/repositories/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
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';
|
||||
40
src/database/schema/ConversationSchema.ts
Normal file
40
src/database/schema/ConversationSchema.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
20
src/database/schema/GroupSchema.ts
Normal file
20
src/database/schema/GroupSchema.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
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)`,
|
||||
];
|
||||
41
src/database/schema/MessageSchema.ts
Normal file
41
src/database/schema/MessageSchema.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
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);
|
||||
}
|
||||
};
|
||||
66
src/database/schema/MigrationManager.ts
Normal file
66
src/database/schema/MigrationManager.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
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);
|
||||
}
|
||||
9
src/database/schema/PostSchema.ts
Normal file
9
src/database/schema/PostSchema.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
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[] = [];
|
||||
16
src/database/schema/UserSchema.ts
Normal file
16
src/database/schema/UserSchema.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
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[] = [];
|
||||
6
src/database/schema/index.ts
Normal file
6
src/database/schema/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export * from './MessageSchema';
|
||||
export * from './ConversationSchema';
|
||||
export * from './UserSchema';
|
||||
export * from './GroupSchema';
|
||||
export * from './PostSchema';
|
||||
export { runMigrations } from './MigrationManager';
|
||||
14
src/database/types/CachedMessage.ts
Normal file
14
src/database/types/CachedMessage.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
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
src/database/types/index.ts
Normal file
1
src/database/types/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { CachedMessage } from './CachedMessage';
|
||||
@@ -111,3 +111,16 @@ export type {
|
||||
UseDifferentialPostsResult,
|
||||
DiffUpdatesInfo,
|
||||
} from './useDifferentialPosts';
|
||||
|
||||
// ==================== 平台键盘 Hooks ====================
|
||||
export {
|
||||
usePlatformKeyboard,
|
||||
useKeyboardAvoidingProps,
|
||||
useKeyboardListener,
|
||||
} from './usePlatformKeyboard';
|
||||
|
||||
export type {
|
||||
KeyboardEventType,
|
||||
PlatformKeyboardOptions,
|
||||
PlatformKeyboardResult,
|
||||
} from './usePlatformKeyboard';
|
||||
|
||||
179
src/hooks/usePlatformKeyboard.ts
Normal file
179
src/hooks/usePlatformKeyboard.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 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,6 +14,7 @@ import { postManager } from '../stores/postManager';
|
||||
import { groupManager } from '../stores/groupManager';
|
||||
import { userManager } from '../stores/userManager';
|
||||
import { messageManager } from '../stores/messageManager';
|
||||
import type { PostListTab } from '../stores/postListSources';
|
||||
|
||||
// ==================== 预取配置 ====================
|
||||
|
||||
@@ -134,7 +135,7 @@ const prefetchService = new PrefetchService();
|
||||
* 预取帖子数据
|
||||
* @param types 帖子类型数组
|
||||
*/
|
||||
function prefetchPosts(types: string[] = ['latest', 'hot']): void {
|
||||
function prefetchPosts(types: PostListTab[] = ['latest', 'hot']): void {
|
||||
types.forEach((type, index) => {
|
||||
prefetchService.schedule({
|
||||
key: `posts:${type}:1`,
|
||||
@@ -152,10 +153,7 @@ function prefetchConversations(): void {
|
||||
key: 'conversations:list',
|
||||
executor: async () => {
|
||||
await messageManager.initialize();
|
||||
await messageManager.requestConversationListRefresh('prefetch', {
|
||||
force: true,
|
||||
allowDefer: true,
|
||||
});
|
||||
await messageManager.refreshConversations(true, 'prefetch');
|
||||
return messageManager.getConversations();
|
||||
},
|
||||
priority: Priority.HIGH,
|
||||
@@ -279,7 +277,7 @@ export function usePrefetch() {
|
||||
const hasInitialPrefetched = useRef(false);
|
||||
|
||||
/** 预取帖子 */
|
||||
const prefetchPostsData = useCallback((types?: string[]) => {
|
||||
const prefetchPostsData = useCallback((types?: PostListTab[]) => {
|
||||
prefetchPosts(types);
|
||||
}, []);
|
||||
|
||||
|
||||
36
src/infrastructure/cache/MediaCacheManager.ts
vendored
36
src/infrastructure/cache/MediaCacheManager.ts
vendored
@@ -4,8 +4,9 @@
|
||||
* @description 管理图片、视频、音频缓存,支持多种清理策略
|
||||
*/
|
||||
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
import { File, Directory, Paths } from 'expo-file-system';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Platform } from 'react-native';
|
||||
import {
|
||||
MediaType,
|
||||
CleanupTrigger,
|
||||
@@ -27,7 +28,7 @@ const CACHE_RECORD_PREFIX = 'media_cache_record_';
|
||||
* 获取缓存目录路径
|
||||
*/
|
||||
function getCacheDir(): string {
|
||||
const cachePath = Paths.cache;
|
||||
const cachePath = Paths.cache.uri;
|
||||
return `${cachePath}media_cache/`;
|
||||
}
|
||||
|
||||
@@ -102,13 +103,15 @@ export class MediaCacheManager {
|
||||
// 加载缓存记录
|
||||
await this.loadCacheRecords();
|
||||
|
||||
// 启动时清理
|
||||
if (this.policy.cleanupOnStart) {
|
||||
// 启动时清理 (非web平台)
|
||||
if (this.policy.cleanupOnStart && Platform.OS !== 'web') {
|
||||
await this.cleanup(CleanupTrigger.STARTUP);
|
||||
}
|
||||
|
||||
// 启动定期清理
|
||||
// 启动定期清理 (非web平台)
|
||||
if (Platform.OS !== 'web') {
|
||||
this.startPeriodicCleanup();
|
||||
}
|
||||
|
||||
this.isInitialized = true;
|
||||
} catch (error) {
|
||||
@@ -121,12 +124,19 @@ export class MediaCacheManager {
|
||||
* 确保缓存目录存在
|
||||
*/
|
||||
private async ensureDirectories(): Promise<void> {
|
||||
if (Platform.OS === 'web') return;
|
||||
|
||||
try {
|
||||
const dirs = [getImageDir(), getVideoDir(), getAudioDir()];
|
||||
for (const dir of dirs) {
|
||||
const dirFile = new File(dir);
|
||||
if (!await dirFile.exists) {
|
||||
await dirFile.create();
|
||||
const cacheDir = new Directory(Paths.cache, 'media_cache');
|
||||
if (!await cacheDir.exists) {
|
||||
await cacheDir.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) {
|
||||
@@ -168,6 +178,8 @@ export class MediaCacheManager {
|
||||
* 检查文件是否存在
|
||||
*/
|
||||
private async checkFileExists(path: string): Promise<boolean> {
|
||||
if (Platform.OS === 'web') return false;
|
||||
|
||||
try {
|
||||
const file = new File(path);
|
||||
return await file.exists;
|
||||
@@ -199,6 +211,10 @@ export class MediaCacheManager {
|
||||
size?: number;
|
||||
} = {}
|
||||
): Promise<string> {
|
||||
if (Platform.OS === 'web') {
|
||||
return uri;
|
||||
}
|
||||
|
||||
const { conversationId, messageId, size = 0 } = options;
|
||||
|
||||
// 生成唯一键
|
||||
|
||||
83
src/infrastructure/platform/domUtils.ts
Normal file
83
src/infrastructure/platform/domUtils.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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'
|
||||
);
|
||||
}
|
||||
12
src/infrastructure/platform/index.ts
Normal file
12
src/infrastructure/platform/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Platform Infrastructure
|
||||
*
|
||||
* 平台相关的基础设施代码
|
||||
*/
|
||||
|
||||
export {
|
||||
blurActiveElement,
|
||||
hasActiveElement,
|
||||
getActiveElementType,
|
||||
isInputFocused,
|
||||
} from './domUtils';
|
||||
@@ -61,6 +61,10 @@ export function hrefProfileBlocked(): string {
|
||||
return '/profile/blocked-users';
|
||||
}
|
||||
|
||||
export function hrefProfileChatSettings(): string {
|
||||
return '/profile/chat-settings';
|
||||
}
|
||||
|
||||
export function hrefProfileMyPosts(): string {
|
||||
return '/profile/my-posts';
|
||||
}
|
||||
@@ -155,3 +159,62 @@ export function hrefAuthRegister(): string {
|
||||
export function hrefAuthForgot(): string {
|
||||
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,
|
||||
} from '../../theme';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { Text, ResponsiveContainer } from '../../components/common';
|
||||
import { Text } from '../../components/common';
|
||||
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
|
||||
|
||||
type AppItem = {
|
||||
@@ -37,8 +37,18 @@ const APP_ENTRIES: AppItem[] = [
|
||||
icon: 'calendar-week',
|
||||
href: hrefs.hrefSchedule(),
|
||||
},
|
||||
{
|
||||
id: 'materials',
|
||||
title: '学习资料',
|
||||
subtitle: '按学科分类的学习资源网盘',
|
||||
icon: 'folder-multiple',
|
||||
href: hrefs.hrefMaterials(),
|
||||
},
|
||||
];
|
||||
|
||||
// 应用卡片最大宽度设置
|
||||
const APP_CARD_MAX_WIDTH = 720;
|
||||
|
||||
export const AppsScreen: React.FC = () => {
|
||||
const colors = useAppColors();
|
||||
const resolvedScheme = useResolvedColorScheme();
|
||||
@@ -56,6 +66,9 @@ export const AppsScreen: React.FC = () => {
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
maxWidth: APP_CARD_MAX_WIDTH,
|
||||
alignSelf: 'center' as const,
|
||||
width: '100%' as const,
|
||||
}),
|
||||
[colors]
|
||||
);
|
||||
@@ -129,17 +142,6 @@ export const AppsScreen: React.FC = () => {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={720}>
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{renderTiles()}
|
||||
</ScrollView>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={styles.scroll}
|
||||
contentContainerStyle={[
|
||||
@@ -150,7 +152,6 @@ export const AppsScreen: React.FC = () => {
|
||||
>
|
||||
{renderTiles()}
|
||||
</ScrollView>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
/**
|
||||
* 忘记密码页 ForgotPasswordScreen(简洁表单样式)
|
||||
* 胡萝卜BBS - 找回密码
|
||||
*
|
||||
* 设计风格:
|
||||
* - 纯白背景,扁平化设计
|
||||
* - 增大间距
|
||||
* - 输入框带灰色背景填充
|
||||
* - 橙色圆角按钮
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -9,123 +20,22 @@ import {
|
||||
Platform,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import { useAppColors, useResolvedColorScheme, type AppColors } from '../../theme';
|
||||
import { authService, resolveAuthApiError } from '../../services/authService';
|
||||
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 = () => {
|
||||
const colors = useAppColors();
|
||||
const resolved = useResolvedColorScheme();
|
||||
const styles = useMemo(() => createForgotPasswordStyles(colors), [colors]);
|
||||
const router = useRouter();
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
@@ -136,6 +46,26 @@ export const ForgotPasswordScreen: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
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(() => {
|
||||
if (countdown <= 0) {
|
||||
return;
|
||||
@@ -179,8 +109,20 @@ export const ForgotPasswordScreen: React.FC = () => {
|
||||
showPrompt({ title: '提示', message: '请输入验证码', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
showPrompt({ title: '提示', message: '新密码至少 6 位', type: 'warning' });
|
||||
if (newPassword.length < 8) {
|
||||
showPrompt({ title: '提示', message: '新密码至少 8 位', 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;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
@@ -208,32 +150,61 @@ export const ForgotPasswordScreen: React.FC = () => {
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<LinearGradient colors={['#FF6B35', '#FF8F66', '#FFB088']} style={styles.gradient}>
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.keyboardView}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
||||
<View style={styles.formCard}>
|
||||
<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}
|
||||
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.subtitle}>请输入邮箱并完成验证码验证</Text>
|
||||
<View style={styles.underline} />
|
||||
<Text style={styles.subtitle}>请输入邮箱并完成验证</Text>
|
||||
</View>
|
||||
|
||||
{/* 表单区域 */}
|
||||
<View style={styles.formSection}>
|
||||
{/* 邮箱输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.inputLabel}>邮箱</Text>
|
||||
<View style={styles.inputWrapper}>
|
||||
<MaterialCommunityIcons name="email-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="邮箱"
|
||||
placeholder="请输入邮箱地址"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
{email.length > 0 && validateEmail(email) && (
|
||||
<MaterialCommunityIcons name="check-circle" size={20} color={colors.primary.main} style={styles.checkIcon} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 验证码 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.inputLabel}>验证码</Text>
|
||||
<View style={styles.codeRow}>
|
||||
<View style={[styles.inputWrapper, styles.codeInput]}>
|
||||
<MaterialCommunityIcons name="shield-key-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
|
||||
<View style={[styles.inputWrapper, styles.codeInputWrapper]}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="验证码"
|
||||
placeholder="6位验证码"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={verificationCode}
|
||||
onChangeText={setVerificationCode}
|
||||
@@ -245,64 +216,242 @@ export const ForgotPasswordScreen: React.FC = () => {
|
||||
style={[styles.sendCodeButton, (sendingCode || countdown > 0) && styles.sendCodeButtonDisabled]}
|
||||
onPress={handleSendCode}
|
||||
disabled={sendingCode || countdown > 0}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{sendingCode ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
<ActivityIndicator size="small" color={colors.primary.main} />
|
||||
) : (
|
||||
<Text style={styles.sendCodeButtonText}>{countdown > 0 ? `${countdown}s` : '发送验证码'}</Text>
|
||||
<Text style={styles.sendCodeButtonText}>{countdown > 0 ? `${countdown}s` : '获取验证码'}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 新密码输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.inputLabel}>新密码</Text>
|
||||
<View style={styles.inputWrapper}>
|
||||
<MaterialCommunityIcons name="lock-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="新密码(至少6位)"
|
||||
placeholder="至少6位"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={newPassword}
|
||||
onChangeText={setNewPassword}
|
||||
secureTextEntry={!showPassword}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<TouchableOpacity onPress={() => setShowPassword((v) => !v)}>
|
||||
<MaterialCommunityIcons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={20} color={colors.text.hint} />
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowPassword((v) => !v)}
|
||||
style={styles.eyeButton}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
|
||||
size={20}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 确认密码输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.inputLabel}>确认密码</Text>
|
||||
<View style={styles.inputWrapper}>
|
||||
<MaterialCommunityIcons name="lock-check-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="确认新密码"
|
||||
placeholder="再次输入新密码"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
secureTextEntry={!showConfirmPassword}
|
||||
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>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 重置密码按钮 */}
|
||||
<TouchableOpacity
|
||||
style={[styles.submitButton, loading && styles.submitButtonDisabled]}
|
||||
onPress={handleResetPassword}
|
||||
disabled={loading}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{loading ? <ActivityIndicator size="small" color="#fff" /> : <Text style={styles.submitButtonText}>重置密码</Text>}
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color={colors.text.inverse} />
|
||||
) : (
|
||||
<Text style={styles.submitButtonText}>重置密码</Text>
|
||||
)}
|
||||
</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>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</LinearGradient>
|
||||
</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;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* 登录页 LoginScreen(响应式适配 - 分栏布局)
|
||||
* 登录页 LoginScreen(简洁表单样式)
|
||||
* 胡萝卜BBS - 用户登录
|
||||
*
|
||||
* 布局规则:
|
||||
* - 屏幕宽度 < 768px:单栏布局,橙色渐变背景
|
||||
* - 屏幕宽度 >= 768px:分栏布局,左侧橙色右侧中性灰
|
||||
* 设计风格:
|
||||
* - 纯白背景,扁平化设计
|
||||
* - 增大间距,避免页面太空
|
||||
* - 输入框带灰色背景填充
|
||||
* - 橙色圆角按钮
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
@@ -24,83 +26,45 @@ import {
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||
import { useAppColors, useResolvedColorScheme, type AppColors } from '../../theme';
|
||||
import { useAuthStore } from '../../stores';
|
||||
import * as hrefs from '../../navigation/hrefs';
|
||||
import { useResponsive, useResponsiveValue } from '../../hooks';
|
||||
import { useResponsive } from '../../hooks';
|
||||
import { showPrompt } from '../../services/promptService';
|
||||
|
||||
// 分栏布局断点
|
||||
const SPLIT_BREAKPOINT = 768;
|
||||
|
||||
|
||||
export const LoginScreen: React.FC = () => {
|
||||
const colors = useAppColors();
|
||||
const resolved = useResolvedColorScheme();
|
||||
const styles = useMemo(() => createLoginStyles(colors), [colors]);
|
||||
const router = useRouter();
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||
const storeError = useAuthStore((state) => state.error);
|
||||
const setStoreError = useAuthStore((state) => state.setError);
|
||||
|
||||
// 响应式布局
|
||||
const { isLandscape, width } = useResponsive();
|
||||
|
||||
// 是否使用分栏布局
|
||||
const isSplitLayout = width >= SPLIT_BREAKPOINT;
|
||||
const { isLandscape } = useResponsive();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||
|
||||
// 动画值
|
||||
const fadeAnim = useRef(new Animated.Value(0)).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;
|
||||
const slideAnim = useRef(new Animated.Value(20)).current;
|
||||
|
||||
// 启动入场动画
|
||||
useEffect(() => {
|
||||
Animated.sequence([
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 600,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 1,
|
||||
duration: 600,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: 500,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(inputFadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
@@ -117,20 +81,13 @@ export const LoginScreen: React.FC = () => {
|
||||
showPrompt({ title: '提示', message: '请输入密码', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
showPrompt({ title: '提示', message: '密码长度不能少于6位', type: 'warning' });
|
||||
if (!agreedToTerms) {
|
||||
showPrompt({ title: '提示', message: '请同意用户协议和隐私政策', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// store error 同步到本地
|
||||
useEffect(() => {
|
||||
if (storeError) {
|
||||
setErrorMsg(storeError);
|
||||
}
|
||||
}, [storeError]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.replace(hrefs.hrefHome());
|
||||
@@ -138,7 +95,6 @@ export const LoginScreen: React.FC = () => {
|
||||
}, [isAuthenticated, router]);
|
||||
|
||||
const clearError = () => {
|
||||
setErrorMsg(null);
|
||||
setStoreError(null);
|
||||
};
|
||||
|
||||
@@ -152,11 +108,11 @@ export const LoginScreen: React.FC = () => {
|
||||
const success = await login({ username, password });
|
||||
if (!success) {
|
||||
if (!useAuthStore.getState().error) {
|
||||
setErrorMsg('登录失败,请稍后重试');
|
||||
showPrompt({ title: '登录失败', message: '请检查用户名和密码', type: 'error' });
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
setErrorMsg(error.message || '登录失败,请检查用户名和密码');
|
||||
showPrompt({ title: '登录失败', message: error.message || '请检查用户名和密码', type: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -167,86 +123,46 @@ export const LoginScreen: React.FC = () => {
|
||||
router.push(hrefs.hrefAuthRegister());
|
||||
};
|
||||
|
||||
// 渲染左侧面板(Logo和品牌信息)- 优化大屏布局
|
||||
const renderLeftPanel = () => (
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<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
|
||||
style={[
|
||||
styles.leftPanel,
|
||||
isSplitLayout && styles.splitLeftPanel, // 大屏模式专用布局
|
||||
styles.content,
|
||||
{
|
||||
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 }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<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}>
|
||||
<Text style={styles.inputLabel}>用户名</Text>
|
||||
<View style={styles.inputWrapper}>
|
||||
<MaterialCommunityIcons
|
||||
name="account-outline"
|
||||
size={22}
|
||||
color={colors.primary.main}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="用户名 / 邮箱 / 手机号"
|
||||
placeholder="请输入用户名"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={username}
|
||||
onChangeText={(v) => { setUsername(v); clearError(); }}
|
||||
@@ -255,29 +171,15 @@ export const LoginScreen: React.FC = () => {
|
||||
returnKeyType="next"
|
||||
/>
|
||||
{username.length > 0 && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setUsername('')}
|
||||
style={styles.clearButton}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="close-circle"
|
||||
size={20}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<MaterialCommunityIcons name="check-circle" size={20} color={colors.primary.main} style={styles.checkIcon} />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 密码输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<Text style={styles.inputLabel}>密码</Text>
|
||||
<View style={styles.inputWrapper}>
|
||||
<MaterialCommunityIcons
|
||||
name="lock-outline"
|
||||
size={22}
|
||||
color={colors.primary.main}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="请输入密码"
|
||||
@@ -291,7 +193,7 @@ export const LoginScreen: React.FC = () => {
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowPassword(!showPassword)}
|
||||
style={styles.clearButton}
|
||||
style={styles.eyeButton}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
|
||||
@@ -310,343 +212,171 @@ export const LoginScreen: React.FC = () => {
|
||||
<Text style={styles.forgotPasswordText}>忘记密码?</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 内联错误提示 */}
|
||||
{errorMsg && (
|
||||
<View style={styles.errorBox}>
|
||||
{/* 服务条款勾选 */}
|
||||
<View style={styles.termsContainer}>
|
||||
<TouchableOpacity
|
||||
onPress={() => setAgreedToTerms(!agreedToTerms)}
|
||||
activeOpacity={0.8}
|
||||
style={styles.checkboxWrapper}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="alert-circle-outline"
|
||||
size={16}
|
||||
color={colors.error.dark}
|
||||
style={{ marginRight: 6, marginTop: 1 }}
|
||||
name={agreedToTerms ? 'checkbox-marked' : 'checkbox-blank-outline'}
|
||||
size={22}
|
||||
color={agreedToTerms ? colors.primary.main : colors.text.hint}
|
||||
/>
|
||||
<Text style={styles.errorText}>{errorMsg}</Text>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<TouchableOpacity
|
||||
style={[styles.loginButton, loading && styles.loginButtonDisabled]}
|
||||
onPress={handleLogin}
|
||||
disabled={loading}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={[colors.primary.main, colors.primary.light]}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={styles.loginButtonGradient}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#FFF" />
|
||||
<ActivityIndicator size="small" color={colors.text.inverse} />
|
||||
) : (
|
||||
<Text style={styles.loginButtonText}>登 录</Text>
|
||||
)}
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</View>
|
||||
|
||||
{/* 底部注册提示 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.footerSection,
|
||||
{ opacity: inputFadeAnim },
|
||||
]}
|
||||
>
|
||||
<View style={styles.footer}>
|
||||
<Text style={styles.footerText}>还没有账号?</Text>
|
||||
<TouchableOpacity onPress={handleGoToRegister}>
|
||||
<Text style={styles.registerLink}>立即注册</Text>
|
||||
</TouchableOpacity>
|
||||
</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>
|
||||
</KeyboardAvoidingView>
|
||||
</LinearGradient>
|
||||
</SafeAreaView>
|
||||
);
|
||||
|
||||
return isSplitLayout ? renderSplitLayout() : renderSingleLayout();
|
||||
};
|
||||
|
||||
function createLoginStyles(colors: AppColors) {
|
||||
return StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
gradient: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
keyboardView: {
|
||||
flex: 1,
|
||||
},
|
||||
// 单栏布局内容
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing['2xl'],
|
||||
},
|
||||
scrollContentCentered: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
paddingHorizontal: 28,
|
||||
paddingTop: 60,
|
||||
paddingBottom: 40,
|
||||
},
|
||||
scrollContentLandscape: {
|
||||
paddingVertical: spacing.lg,
|
||||
paddingTop: 30,
|
||||
},
|
||||
// 装饰性背景元素
|
||||
decorCircle1: {
|
||||
position: 'absolute',
|
||||
top: -100,
|
||||
right: -100,
|
||||
width: 300,
|
||||
height: 300,
|
||||
borderRadius: 150,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
content: {
|
||||
flex: 1,
|
||||
maxWidth: 400,
|
||||
width: '100%',
|
||||
alignSelf: 'center',
|
||||
},
|
||||
decorCircle2: {
|
||||
position: 'absolute',
|
||||
bottom: 100,
|
||||
left: -150,
|
||||
width: 250,
|
||||
height: 250,
|
||||
borderRadius: 125,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
|
||||
// 标题区域 - 增大间距
|
||||
titleSection: {
|
||||
marginBottom: 48,
|
||||
marginTop: 20,
|
||||
},
|
||||
// 头部区域
|
||||
headerSection: {
|
||||
alignItems: 'center',
|
||||
marginTop: spacing['3xl'],
|
||||
marginBottom: spacing['3xl'],
|
||||
},
|
||||
headerSectionLandscape: {
|
||||
marginTop: spacing.lg,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
logoContainer: {
|
||||
backgroundColor: 'rgba(255,255,255,0.25)',
|
||||
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: {
|
||||
fontSize: fontSizes.md,
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: spacing.xl,
|
||||
},
|
||||
// 大屏模式副标题样式 - 大幅放大
|
||||
splitSubtitle: {
|
||||
fontSize: 24, // 放大副标题
|
||||
marginBottom: spacing['6xl'], // 增加底部间距
|
||||
lineHeight: 36,
|
||||
paddingHorizontal: spacing['4xl'],
|
||||
letterSpacing: 1.5,
|
||||
fontWeight: '500',
|
||||
},
|
||||
// 表单卡片(基础样式,无阴影)
|
||||
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'],
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: '700',
|
||||
color: colors.text.primary,
|
||||
marginBottom: spacing.xl,
|
||||
textAlign: 'center',
|
||||
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: 32,
|
||||
},
|
||||
|
||||
// 输入框
|
||||
inputContainer: {
|
||||
marginBottom: spacing.lg,
|
||||
marginBottom: 24,
|
||||
},
|
||||
inputLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: colors.text.secondary,
|
||||
marginBottom: 10,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.divider,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: 14,
|
||||
paddingHorizontal: 18,
|
||||
height: 56,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
fontSize: 16,
|
||||
color: colors.text.primary,
|
||||
height: 56,
|
||||
},
|
||||
clearButton: {
|
||||
padding: spacing.xs,
|
||||
checkIcon: {
|
||||
marginLeft: 8,
|
||||
},
|
||||
eyeButton: {
|
||||
padding: 4,
|
||||
marginLeft: 4,
|
||||
},
|
||||
|
||||
// 忘记密码
|
||||
forgotPassword: {
|
||||
alignSelf: 'flex-end',
|
||||
marginBottom: spacing.md,
|
||||
marginBottom: 28,
|
||||
marginTop: -8,
|
||||
},
|
||||
forgotPasswordText: {
|
||||
fontSize: fontSizes.sm,
|
||||
fontSize: 14,
|
||||
color: colors.primary.main,
|
||||
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,
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
// 登录按钮
|
||||
loginButton: {
|
||||
borderRadius: borderRadius.lg,
|
||||
overflow: 'hidden',
|
||||
...shadows.md,
|
||||
},
|
||||
loginButtonGradient: {
|
||||
height: 52,
|
||||
height: 56,
|
||||
borderRadius: 14,
|
||||
backgroundColor: colors.primary.main,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
@@ -654,116 +384,59 @@ function createLoginStyles(colors: AppColors) {
|
||||
opacity: 0.7,
|
||||
},
|
||||
loginButtonText: {
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
fontSize: 17,
|
||||
fontWeight: '600',
|
||||
color: colors.text.inverse,
|
||||
},
|
||||
// 底部区域
|
||||
footerSection: {
|
||||
|
||||
// 底部
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingTop: spacing.xl,
|
||||
marginTop: 40,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
fontSize: 15,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
registerLink: {
|
||||
fontSize: fontSizes.md,
|
||||
fontSize: 15,
|
||||
color: colors.primary.main,
|
||||
fontWeight: '700',
|
||||
marginLeft: spacing.xs,
|
||||
textDecorationLine: 'underline',
|
||||
fontWeight: '600',
|
||||
marginLeft: 6,
|
||||
},
|
||||
// ========== 分栏布局样式 ==========
|
||||
splitContainer: {
|
||||
flex: 1,
|
||||
policyFooter: {
|
||||
alignItems: 'center',
|
||||
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,
|
||||
},
|
||||
leftPanelContainer: {
|
||||
flex: 1, // 50%
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
checkboxWrapper: {
|
||||
paddingTop: 2,
|
||||
},
|
||||
leftPanelSafeArea: {
|
||||
termsText: {
|
||||
fontSize: 14,
|
||||
color: colors.text.secondary,
|
||||
marginLeft: 10,
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing['4xl'], // 增加内边距,不贴边
|
||||
lineHeight: 22,
|
||||
},
|
||||
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%',
|
||||
// 移除这里的阴影,避免重复
|
||||
termsLink: {
|
||||
color: colors.primary.main,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
186
src/screens/auth/RegisterStep1Email.tsx
Normal file
186
src/screens/auth/RegisterStep1Email.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* 注册步骤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;
|
||||
201
src/screens/auth/RegisterStep2Verification.tsx
Normal file
201
src/screens/auth/RegisterStep2Verification.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
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;
|
||||
356
src/screens/auth/RegisterStep3Profile.tsx
Normal file
356
src/screens/auth/RegisterStep3Profile.tsx
Normal file
@@ -0,0 +1,356 @@
|
||||
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;
|
||||
445
src/screens/auth/VerificationFormScreen.tsx
Normal file
445
src/screens/auth/VerificationFormScreen.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
/**
|
||||
* 身份认证申请表单页面
|
||||
* 用户填写认证信息并提交申请
|
||||
*/
|
||||
|
||||
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;
|
||||
323
src/screens/auth/VerificationGuideScreen.tsx
Normal file
323
src/screens/auth/VerificationGuideScreen.tsx
Normal file
@@ -0,0 +1,323 @@
|
||||
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;
|
||||
265
src/screens/auth/WelcomeScreen.tsx
Normal file
265
src/screens/auth/WelcomeScreen.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* 欢迎页 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