Compare commits
39 Commits
master
...
9bbed8cf5e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
name: carrot-bbs-android-release-apk
|
||||||
path: android/app/build/outputs/apk/release/app-release.apk
|
path: android/app/build/outputs/apk/release/app-release.apk
|
||||||
|
|
||||||
|
- name: Resolve runtime version
|
||||||
|
id: runtime
|
||||||
|
run: |
|
||||||
|
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
|
||||||
|
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
|
||||||
|
|
||||||
|
- name: Upload APK to Updates Server
|
||||||
|
env:
|
||||||
|
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
|
||||||
|
UPDATES_SERVER_URL: https://updates.littlelan.cn
|
||||||
|
run: |
|
||||||
|
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
|
||||||
|
|
||||||
|
APK_PATH="android/app/build/outputs/apk/release/app-release.apk"
|
||||||
|
RUNTIME_VERSION="${{ steps.runtime.outputs.runtime_version }}"
|
||||||
|
|
||||||
|
echo "Uploading APK for runtime version: ${RUNTIME_VERSION}"
|
||||||
|
|
||||||
|
HTTP_CODE=$(curl -s -o response.json -w "%{http_code}" \
|
||||||
|
-X POST \
|
||||||
|
"${UPDATES_SERVER_URL}/admin/apk/upload?runtimeVersion=${RUNTIME_VERSION}" \
|
||||||
|
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
|
||||||
|
-H "Content-Type: application/vnd.android.package-archive" \
|
||||||
|
--data-binary @"${APK_PATH}")
|
||||||
|
|
||||||
|
echo "HTTP Response Code: ${HTTP_CODE}"
|
||||||
|
cat response.json
|
||||||
|
|
||||||
|
if [ "${HTTP_CODE}" -ne 200 ]; then
|
||||||
|
echo "Failed to upload APK"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "APK uploaded successfully!"
|
||||||
|
|
||||||
build-and-push-web:
|
build-and-push-web:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ RUN npx expo export --platform web --output-dir dist-web
|
|||||||
|
|
||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY --from=builder /app/dist-web /usr/share/nginx/html
|
COPY --from=builder /app/dist-web /usr/share/nginx/html
|
||||||
|
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
@@ -23,6 +23,29 @@ const devUpdatesUrl =
|
|||||||
|
|
||||||
const expo = appJson.expo;
|
const expo = appJson.expo;
|
||||||
|
|
||||||
|
// 检测是否为 Web 平台
|
||||||
|
const isWeb = process.env.EXPO_TARGET === 'web' || process.env.PLATFORM === 'web';
|
||||||
|
|
||||||
|
// 原生平台特有的插件(在 Web 端会报错)
|
||||||
|
const nativeOnlyPlugins = [
|
||||||
|
'expo-camera',
|
||||||
|
'expo-notifications',
|
||||||
|
'expo-background-fetch',
|
||||||
|
'expo-media-library',
|
||||||
|
'expo-image-picker',
|
||||||
|
'expo-video',
|
||||||
|
'expo-sqlite',
|
||||||
|
'./plugins/withMainActivityConfigChange',
|
||||||
|
];
|
||||||
|
|
||||||
|
// 过滤插件:Web 端排除原生插件
|
||||||
|
const filteredPlugins = isWeb
|
||||||
|
? expo.plugins.filter((plugin) => {
|
||||||
|
const pluginName = Array.isArray(plugin) ? plugin[0] : plugin;
|
||||||
|
return !nativeOnlyPlugins.includes(pluginName);
|
||||||
|
})
|
||||||
|
: expo.plugins;
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
...expo,
|
...expo,
|
||||||
name: isDevVariant ? `${expo.name} Dev` : expo.name,
|
name: isDevVariant ? `${expo.name} Dev` : expo.name,
|
||||||
@@ -40,10 +63,27 @@ module.exports = {
|
|||||||
...expo.android,
|
...expo.android,
|
||||||
package: 'skin.carrot.bbs',
|
package: 'skin.carrot.bbs',
|
||||||
},
|
},
|
||||||
|
// Web 端使用过滤后的插件
|
||||||
|
plugins: filteredPlugins,
|
||||||
extra: {
|
extra: {
|
||||||
...(expo.extra || {}),
|
...(expo.extra || {}),
|
||||||
appVariant: isDevVariant ? 'dev' : 'release',
|
appVariant: isDevVariant ? 'dev' : 'release',
|
||||||
apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl,
|
apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl,
|
||||||
updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
|
updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
|
||||||
},
|
},
|
||||||
|
// Web 端配置
|
||||||
|
...(isWeb && {
|
||||||
|
web: {
|
||||||
|
...expo.web,
|
||||||
|
build: {
|
||||||
|
...expo.web?.build,
|
||||||
|
// 配置 Web 端别名,避免加载原生模块
|
||||||
|
babel: {
|
||||||
|
dangerouslyAddModulePathsToTranspile: [
|
||||||
|
'react-native-webrtc',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
8
app.json
8
app.json
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "萝卜社区",
|
"name": "萝卜社区",
|
||||||
"slug": "qojo",
|
"slug": "qojo",
|
||||||
"version": "1.0.11",
|
"version": "1.0.12",
|
||||||
"orientation": "default",
|
"orientation": "default",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
@@ -15,11 +15,13 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
|
"NSMicrophoneUsageDescription": "允许萝卜社区访问您的麦克风以进行语音通话",
|
||||||
"UIBackgroundModes": [
|
"UIBackgroundModes": [
|
||||||
"fetch",
|
"fetch",
|
||||||
"remote-notification",
|
"remote-notification",
|
||||||
"fetch",
|
"fetch",
|
||||||
"remote-notification"
|
"remote-notification",
|
||||||
|
"audio"
|
||||||
],
|
],
|
||||||
"ITSAppUsesNonExemptEncryption": false
|
"ITSAppUsesNonExemptEncryption": false
|
||||||
},
|
},
|
||||||
@@ -37,6 +39,7 @@
|
|||||||
"versionCode": 6,
|
"versionCode": 6,
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"VIBRATE",
|
"VIBRATE",
|
||||||
|
"RECORD_AUDIO",
|
||||||
"RECEIVE_BOOT_COMPLETED",
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
"WAKE_LOCK",
|
"WAKE_LOCK",
|
||||||
"READ_EXTERNAL_STORAGE",
|
"READ_EXTERNAL_STORAGE",
|
||||||
@@ -59,6 +62,7 @@
|
|||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
"./plugins/withMainActivityConfigChange",
|
||||||
[
|
[
|
||||||
"expo-router",
|
"expo-router",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo, useCallback } from 'react';
|
||||||
import { Platform, useWindowDimensions } from 'react-native';
|
import { Platform, useWindowDimensions } from 'react-native';
|
||||||
import { Tabs, usePathname } from 'expo-router';
|
import { Tabs, usePathname } from 'expo-router';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
@@ -6,7 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|||||||
|
|
||||||
import { useAppColors, shadows } from '../../../src/theme';
|
import { useAppColors, shadows } from '../../../src/theme';
|
||||||
import { BREAKPOINTS } from '../../../src/hooks/useResponsive';
|
import { BREAKPOINTS } from '../../../src/hooks/useResponsive';
|
||||||
import { useHomeTabBarVisibilityStore, useTotalUnreadCount } from '../../../src/stores';
|
import { useHomeTabBarVisibilityStore, useTotalUnreadCount, useHomeTabPressStore } from '../../../src/stores';
|
||||||
|
|
||||||
const TAB_BAR_HEIGHT = 56;
|
const TAB_BAR_HEIGHT = 56;
|
||||||
const TAB_BAR_FLOATING_MARGIN = 12;
|
const TAB_BAR_FLOATING_MARGIN = 12;
|
||||||
@@ -19,10 +19,17 @@ export default function TabsLayout() {
|
|||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const unreadCount = useTotalUnreadCount();
|
const unreadCount = useTotalUnreadCount();
|
||||||
const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
|
const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
|
||||||
|
const triggerHomeTabPress = useHomeTabPressStore((s) => s.triggerPress);
|
||||||
const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop;
|
const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop;
|
||||||
|
|
||||||
const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/');
|
const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/');
|
||||||
|
|
||||||
|
const handleHomeTabPress = useCallback(() => {
|
||||||
|
if (pathname === '/home') {
|
||||||
|
triggerHomeTabPress();
|
||||||
|
}
|
||||||
|
}, [pathname, triggerHomeTabPress]);
|
||||||
|
|
||||||
const tabBarStyle = useMemo(() => {
|
const tabBarStyle = useMemo(() => {
|
||||||
if (hideTabBar) {
|
if (hideTabBar) {
|
||||||
return { display: 'none' as const, height: 0, overflow: 'hidden' as const };
|
return { display: 'none' as const, height: 0, overflow: 'hidden' as const };
|
||||||
@@ -81,6 +88,9 @@ export default function TabsLayout() {
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
|
listeners={{
|
||||||
|
tabPress: handleHomeTabPress,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<Tabs.Screen
|
<Tabs.Screen
|
||||||
name="apps"
|
name="apps"
|
||||||
|
|||||||
11
app/(app)/(tabs)/apps/materials/_layout.tsx
Normal file
11
app/(app)/(tabs)/apps/materials/_layout.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function MaterialsStackLayout() {
|
||||||
|
return (
|
||||||
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
|
<Stack.Screen name="index" />
|
||||||
|
<Stack.Screen name="subject" />
|
||||||
|
<Stack.Screen name="detail" />
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/apps/materials/detail.tsx
Normal file
5
app/(app)/(tabs)/apps/materials/detail.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { MaterialDetailScreen } from '../../../../../src/screens/material';
|
||||||
|
|
||||||
|
export default function MaterialDetailRoute() {
|
||||||
|
return <MaterialDetailScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/apps/materials/index.tsx
Normal file
5
app/(app)/(tabs)/apps/materials/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { MaterialsScreen } from '../../../../../src/screens/material';
|
||||||
|
|
||||||
|
export default function MaterialsRoute() {
|
||||||
|
return <MaterialsScreen />;
|
||||||
|
}
|
||||||
5
app/(app)/(tabs)/apps/materials/subject.tsx
Normal file
5
app/(app)/(tabs)/apps/materials/subject.tsx
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { SubjectMaterialsScreen } from '../../../../../src/screens/material';
|
||||||
|
|
||||||
|
export default function SubjectMaterialsRoute() {
|
||||||
|
return <SubjectMaterialsScreen />;
|
||||||
|
}
|
||||||
@@ -36,6 +36,14 @@ export default function ProfileStackLayout() {
|
|||||||
<Stack.Screen name="bookmarks" options={{ title: '收藏' }} />
|
<Stack.Screen name="bookmarks" options={{ title: '收藏' }} />
|
||||||
<Stack.Screen name="notification-settings" options={{ title: '通知设置' }} />
|
<Stack.Screen name="notification-settings" options={{ title: '通知设置' }} />
|
||||||
<Stack.Screen name="blocked-users" options={{ title: '黑名单' }} />
|
<Stack.Screen name="blocked-users" options={{ title: '黑名单' }} />
|
||||||
|
<Stack.Screen name="chat-settings" options={{ title: '聊天设置' }} />
|
||||||
|
<Stack.Screen name="about" options={{ title: '关于我们' }} />
|
||||||
|
<Stack.Screen name="terms" options={{ title: '用户协议' }} />
|
||||||
|
<Stack.Screen name="privacy" options={{ title: '隐私政策' }} />
|
||||||
|
<Stack.Screen name="verification" options={{ title: '身份认证' }} />
|
||||||
|
<Stack.Screen name="data-storage" options={{ title: '数据与存储' }} />
|
||||||
|
<Stack.Screen name="privacy-settings" options={{ title: '隐私设置' }} />
|
||||||
|
<Stack.Screen name="account-deletion" options={{ title: '注销账号' }} />
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 * as SystemUI from 'expo-system-ui';
|
||||||
|
|
||||||
import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences';
|
import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences';
|
||||||
|
import { api } from '../src/services/api';
|
||||||
|
import { wsService } from '../src/services/wsService';
|
||||||
import {
|
import {
|
||||||
ThemeBootstrap,
|
ThemeBootstrap,
|
||||||
useAppColors,
|
useAppColors,
|
||||||
@@ -21,6 +23,8 @@ import AppPromptBar from '../src/components/common/AppPromptBar';
|
|||||||
import AppDialogHost from '../src/components/common/AppDialogHost';
|
import AppDialogHost from '../src/components/common/AppDialogHost';
|
||||||
import { installAlertOverride } from '../src/services/alertOverride';
|
import { installAlertOverride } from '../src/services/alertOverride';
|
||||||
import { useAuthStore } from '../src/stores';
|
import { useAuthStore } from '../src/stores';
|
||||||
|
import { checkForAPKUpdate } from '../src/services/apkUpdateService';
|
||||||
|
import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call';
|
||||||
|
|
||||||
registerNotificationPresentationHandler();
|
registerNotificationPresentationHandler();
|
||||||
|
|
||||||
@@ -48,6 +52,25 @@ if (Platform.OS === 'web' && typeof document !== 'undefined') {
|
|||||||
}
|
}
|
||||||
*:focus { outline: none !important; }
|
*:focus { outline: none !important; }
|
||||||
*:focus-visible { outline: none !important; }
|
*:focus-visible { outline: none !important; }
|
||||||
|
/* 修复移动端 FlatList/ScrollView 滑动问题 */
|
||||||
|
html, body {
|
||||||
|
overscroll-behavior: none;
|
||||||
|
touch-action: pan-y;
|
||||||
|
}
|
||||||
|
/* React Native Web 生成的滚动容器 */
|
||||||
|
[class*="css-"] {
|
||||||
|
touch-action: pan-y !important;
|
||||||
|
-webkit-overflow-scrolling: touch !important;
|
||||||
|
}
|
||||||
|
/* 确保所有可滚动元素都可以垂直滑动 */
|
||||||
|
div[style*="overflow"] {
|
||||||
|
touch-action: pan-y !important;
|
||||||
|
-webkit-overflow-scrolling: touch !important;
|
||||||
|
}
|
||||||
|
/* 禁用水平滑动,只允许垂直滑动 */
|
||||||
|
* {
|
||||||
|
touch-action: pan-y pinch-zoom;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
}
|
}
|
||||||
@@ -137,21 +160,71 @@ function NotificationBootstrap() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function APKUpdateBootstrap() {
|
||||||
|
const hasChecked = useRef(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (hasChecked.current) return;
|
||||||
|
hasChecked.current = true;
|
||||||
|
|
||||||
|
// 延迟执行,避免与启动流程冲突
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
checkForAPKUpdate().catch((error) => {
|
||||||
|
console.error('APK update check failed:', error);
|
||||||
|
});
|
||||||
|
}, 3000);
|
||||||
|
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function ThemedStack() {
|
function ThemedStack() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const resolved = useResolvedColorScheme();
|
const resolved = useResolvedColorScheme();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.setExpoRouter(router);
|
||||||
|
wsService.setRouter(router);
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
|
||||||
<SystemChrome />
|
<SystemChrome />
|
||||||
<SessionGate>
|
<SessionGate>
|
||||||
<NotificationBootstrap />
|
<NotificationBootstrap />
|
||||||
|
<APKUpdateBootstrap />
|
||||||
<Stack screenOptions={{ headerShown: false }}>
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
<Stack.Screen name="index" options={{ headerShown: false }} />
|
<Stack.Screen name="index" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
|
||||||
<Stack.Screen name="(app)" options={{ headerShown: false }} />
|
<Stack.Screen name="(app)" options={{ headerShown: false }} />
|
||||||
|
<Stack.Screen
|
||||||
|
name="terms"
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
title: '用户协议',
|
||||||
|
headerStyle: { backgroundColor: colors.background.paper },
|
||||||
|
headerTintColor: colors.text.primary,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerBackVisible: false,
|
||||||
|
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Stack.Screen
|
||||||
|
name="privacy"
|
||||||
|
options={{
|
||||||
|
headerShown: true,
|
||||||
|
title: '隐私政策',
|
||||||
|
headerStyle: { backgroundColor: colors.background.paper },
|
||||||
|
headerTintColor: colors.text.primary,
|
||||||
|
headerShadowVisible: false,
|
||||||
|
headerBackVisible: false,
|
||||||
|
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Stack.Screen
|
<Stack.Screen
|
||||||
name="post/[postId]"
|
name="post/[postId]"
|
||||||
options={{
|
options={{
|
||||||
@@ -197,6 +270,9 @@ export default function RootLayout() {
|
|||||||
<ThemedStack />
|
<ThemedStack />
|
||||||
<AppPromptBar />
|
<AppPromptBar />
|
||||||
<AppDialogHost />
|
<AppDialogHost />
|
||||||
|
<IncomingCallModal />
|
||||||
|
<CallScreen />
|
||||||
|
<FloatingCallWindow />
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ThemedProviders>
|
</ThemedProviders>
|
||||||
</SafeAreaProvider>
|
</SafeAreaProvider>
|
||||||
|
|||||||
@@ -7,5 +7,5 @@ export default function Index() {
|
|||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
return <Redirect href="/home" />;
|
return <Redirect href="/home" />;
|
||||||
}
|
}
|
||||||
return <Redirect href="/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
|
// Add wasm asset support
|
||||||
config.resolver.assetExts.push('wasm');
|
config.resolver.assetExts.push('wasm');
|
||||||
|
|
||||||
|
// Fix for react-native-webrtc event-target-shim import warning
|
||||||
|
// The package doesn't properly export "./index" in its exports field
|
||||||
|
config.resolver.unstable_enablePackageExports = false;
|
||||||
|
|
||||||
// NOTE: enhanceMiddleware is marked as deprecated in Metro's types,
|
// NOTE: enhanceMiddleware is marked as deprecated in Metro's types,
|
||||||
// but there's currently no official alternative for custom dev server headers.
|
// but there's currently no official alternative for custom dev server headers.
|
||||||
// For production, configure COEP/COOP headers on your hosting platform.
|
// For production, configure COEP/COOP headers on your hosting platform.
|
||||||
|
|||||||
19
nginx.conf
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",
|
"name": "carrot_bbs",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "carrot_bbs",
|
"name": "carrot_bbs",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^15.1.1",
|
"@expo/vector-icons": "^15.1.1",
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
@@ -29,6 +29,7 @@
|
|||||||
"expo-haptics": "~55.0.8",
|
"expo-haptics": "~55.0.8",
|
||||||
"expo-image": "^55.0.5",
|
"expo-image": "^55.0.5",
|
||||||
"expo-image-picker": "^55.0.10",
|
"expo-image-picker": "^55.0.10",
|
||||||
|
"expo-intent-launcher": "~55.0.9",
|
||||||
"expo-linear-gradient": "^55.0.8",
|
"expo-linear-gradient": "^55.0.8",
|
||||||
"expo-media-library": "~55.0.9",
|
"expo-media-library": "~55.0.9",
|
||||||
"expo-notifications": "^55.0.10",
|
"expo-notifications": "^55.0.10",
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
"react-native-screens": "~4.23.0",
|
"react-native-screens": "~4.23.0",
|
||||||
"react-native-sse": "^1.2.1",
|
"react-native-sse": "^1.2.1",
|
||||||
"react-native-web": "^0.21.0",
|
"react-native-web": "^0.21.0",
|
||||||
|
"react-native-webrtc": "^124.0.7",
|
||||||
"react-native-webview": "13.16.0",
|
"react-native-webview": "13.16.0",
|
||||||
"react-native-worklets": "0.7.2",
|
"react-native-worklets": "0.7.2",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
@@ -5747,6 +5749,15 @@
|
|||||||
"expo": "*"
|
"expo": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/expo-intent-launcher": {
|
||||||
|
"version": "55.0.9",
|
||||||
|
"resolved": "https://registry.npmmirror.com/expo-intent-launcher/-/expo-intent-launcher-55.0.9.tgz",
|
||||||
|
"integrity": "sha512-s8k8dF8PfgzN0c+xzNTd9ceHh+/6mt2ncnMuqFaIIry2BeDGITbsgVijHr39eB5vS+KopCcOYYBYr+O1epx4TA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"expo": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expo-json-utils": {
|
"node_modules/expo-json-utils": {
|
||||||
"version": "55.0.0",
|
"version": "55.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/expo-json-utils/-/expo-json-utils-55.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/expo-json-utils/-/expo-json-utils-55.0.0.tgz",
|
||||||
@@ -9789,6 +9800,55 @@
|
|||||||
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-webrtc": {
|
||||||
|
"version": "124.0.7",
|
||||||
|
"resolved": "https://registry.npmmirror.com/react-native-webrtc/-/react-native-webrtc-124.0.7.tgz",
|
||||||
|
"integrity": "sha512-gnXPdbUS8IkKHq9WNaWptW/yy5s6nMyI6cNn90LXdobPVCgYSk6NA2uUGdT4c4J14BRgaFA95F+cR28tUPkMVA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-js": "1.5.1",
|
||||||
|
"debug": "4.3.4",
|
||||||
|
"event-target-shim": "6.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react-native": ">=0.60.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-native-webrtc/node_modules/debug": {
|
||||||
|
"version": "4.3.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.3.4.tgz",
|
||||||
|
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "2.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-native-webrtc/node_modules/event-target-shim": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/mysticatea"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-native-webrtc/node_modules/ms": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/react-native-webview": {
|
"node_modules/react-native-webview": {
|
||||||
"version": "13.16.0",
|
"version": "13.16.0",
|
||||||
"resolved": "https://registry.npmmirror.com/react-native-webview/-/react-native-webview-13.16.0.tgz",
|
"resolved": "https://registry.npmmirror.com/react-native-webview/-/react-native-webview-13.16.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "carrot_bbs",
|
"name": "carrot_bbs",
|
||||||
"version": "1.0.1",
|
"version": "1.0.2",
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
"expo-haptics": "~55.0.8",
|
"expo-haptics": "~55.0.8",
|
||||||
"expo-image": "^55.0.5",
|
"expo-image": "^55.0.5",
|
||||||
"expo-image-picker": "^55.0.10",
|
"expo-image-picker": "^55.0.10",
|
||||||
|
"expo-intent-launcher": "~55.0.9",
|
||||||
"expo-linear-gradient": "^55.0.8",
|
"expo-linear-gradient": "^55.0.8",
|
||||||
"expo-media-library": "~55.0.9",
|
"expo-media-library": "~55.0.9",
|
||||||
"expo-notifications": "^55.0.10",
|
"expo-notifications": "^55.0.10",
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
"react-native-screens": "~4.23.0",
|
"react-native-screens": "~4.23.0",
|
||||||
"react-native-sse": "^1.2.1",
|
"react-native-sse": "^1.2.1",
|
||||||
"react-native-web": "^0.21.0",
|
"react-native-web": "^0.21.0",
|
||||||
|
"react-native-webrtc": "^124.0.7",
|
||||||
"react-native-webview": "13.16.0",
|
"react-native-webview": "13.16.0",
|
||||||
"react-native-worklets": "0.7.2",
|
"react-native-worklets": "0.7.2",
|
||||||
"zod": "^4.3.6",
|
"zod": "^4.3.6",
|
||||||
|
|||||||
73
plugins/withMainActivityConfigChange.js
Normal file
73
plugins/withMainActivityConfigChange.js
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
const { withMainActivity } = require('@expo/config-plugins');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 MainActivity 中添加 onConfigurationChanged 方法
|
||||||
|
* 用于监听系统深色模式变化并通知 React Native
|
||||||
|
*/
|
||||||
|
const withMainActivityConfigChange = (config) => {
|
||||||
|
return withMainActivity(config, async (config) => {
|
||||||
|
const { contents } = config.modResults;
|
||||||
|
|
||||||
|
// 检查是否已经添加了 onConfigurationChanged
|
||||||
|
if (contents.includes('onConfigurationChanged')) {
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 需要添加的 imports
|
||||||
|
const importsToAdd = `import android.content.Intent
|
||||||
|
import android.content.res.Configuration`;
|
||||||
|
|
||||||
|
// 需要添加的方法
|
||||||
|
const methodToAdd = `
|
||||||
|
/**
|
||||||
|
* 监听系统配置变化(包括深色模式切换),并广播给 React Native
|
||||||
|
* 这对于 Appearance API 正确检测系统主题至关重要
|
||||||
|
*/
|
||||||
|
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||||
|
super.onConfigurationChanged(newConfig)
|
||||||
|
val intent = Intent("onConfigurationChanged")
|
||||||
|
intent.putExtra("newConfig", newConfig)
|
||||||
|
sendBroadcast(intent)
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
let newContents = contents;
|
||||||
|
|
||||||
|
// 添加 imports(在已有 imports 后面)
|
||||||
|
if (!newContents.includes('import android.content.Intent')) {
|
||||||
|
// 找到最后一个 import 语句
|
||||||
|
const importRegex = /import [^\n]+\n/g;
|
||||||
|
const imports = newContents.match(importRegex);
|
||||||
|
if (imports && imports.length > 0) {
|
||||||
|
const lastImport = imports[imports.length - 1];
|
||||||
|
const lastImportIndex = newContents.lastIndexOf(lastImport);
|
||||||
|
const insertPosition = lastImportIndex + lastImport.length;
|
||||||
|
newContents =
|
||||||
|
newContents.slice(0, insertPosition) +
|
||||||
|
importsToAdd +
|
||||||
|
'\n' +
|
||||||
|
newContents.slice(insertPosition);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在类体的开头添加 onConfigurationChanged 方法
|
||||||
|
// 找到类定义后的第一个方法或属性
|
||||||
|
const classBodyRegex = /class MainActivity\s*:\s*ReactActivity\s*\(\)\s*\{([\s\S]*)/;
|
||||||
|
const match = newContents.match(classBodyRegex);
|
||||||
|
if (match) {
|
||||||
|
// 找到 onCreate 方法之前插入
|
||||||
|
const onCreateIndex = newContents.indexOf('override fun onCreate');
|
||||||
|
if (onCreateIndex !== -1) {
|
||||||
|
newContents =
|
||||||
|
newContents.slice(0, onCreateIndex) +
|
||||||
|
methodToAdd +
|
||||||
|
newContents.slice(onCreateIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.modResults.contents = newContents;
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = withMainActivityConfigChange;
|
||||||
@@ -18,7 +18,7 @@ interface CommentItemProps {
|
|||||||
comment: Comment;
|
comment: Comment;
|
||||||
onUserPress: () => void;
|
onUserPress: () => void;
|
||||||
onReply: () => void;
|
onReply: () => void;
|
||||||
onLike: () => void;
|
onLike: (comment: Comment) => void; // 点赞回调,传入评论对象
|
||||||
floorNumber?: number; // 楼层号
|
floorNumber?: number; // 楼层号
|
||||||
isAuthor?: boolean; // 是否是楼主
|
isAuthor?: boolean; // 是否是楼主
|
||||||
replyToUser?: string; // 回复给哪位用户
|
replyToUser?: string; // 回复给哪位用户
|
||||||
@@ -29,6 +29,7 @@ interface CommentItemProps {
|
|||||||
onDelete?: (comment: Comment) => void; // 删除评论的回调
|
onDelete?: (comment: Comment) => void; // 删除评论的回调
|
||||||
onImagePress?: (images: ImageGridItem[], index: number) => void; // 点击图片查看大图
|
onImagePress?: (images: ImageGridItem[], index: number) => void; // 点击图片查看大图
|
||||||
currentUserId?: string; // 当前用户ID,用于判断子评论作者
|
currentUserId?: string; // 当前用户ID,用于判断子评论作者
|
||||||
|
onReport?: (comment: Comment) => void; // 举报评论的回调
|
||||||
}
|
}
|
||||||
|
|
||||||
function createCommentItemStyles(colors: AppColors) {
|
function createCommentItemStyles(colors: AppColors) {
|
||||||
@@ -214,6 +215,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
onDelete,
|
onDelete,
|
||||||
onImagePress,
|
onImagePress,
|
||||||
currentUserId,
|
currentUserId,
|
||||||
|
onReport,
|
||||||
}) => {
|
}) => {
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const styles = useMemo(() => createCommentItemStyles(colors), [colors]);
|
const styles = useMemo(() => createCommentItemStyles(colors), [colors]);
|
||||||
@@ -504,6 +506,25 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
回复
|
回复
|
||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
{/* 点赞按钮 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.subActionButton}
|
||||||
|
onPress={() => onLike?.(reply)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={reply.is_liked ? 'heart' : 'heart-outline'}
|
||||||
|
size={12}
|
||||||
|
color={reply.is_liked ? colors.error.main : colors.text.hint}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
variant="caption"
|
||||||
|
color={reply.is_liked ? colors.error.main : colors.text.hint}
|
||||||
|
style={styles.subActionText}
|
||||||
|
>
|
||||||
|
{reply.likes_count > 0 ? formatNumber(reply.likes_count) : '赞'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
{/* 删除按钮 - 子评论作者可见 */}
|
{/* 删除按钮 - 子评论作者可见 */}
|
||||||
{isSubReplyAuthor && (
|
{isSubReplyAuthor && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -521,6 +542,18 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)}
|
)}
|
||||||
|
{/* 举报按钮 - 对非子评论作者显示 */}
|
||||||
|
{!isSubReplyAuthor && onReport && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.subActionButton}
|
||||||
|
onPress={() => onReport(reply)}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name="flag-outline" size={12} color={colors.text.hint} />
|
||||||
|
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
|
||||||
|
举报
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -592,7 +625,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
{/* 操作按钮 - 更紧凑 */}
|
{/* 操作按钮 - 更紧凑 */}
|
||||||
<View style={styles.actions}>
|
<View style={styles.actions}>
|
||||||
{/* 点赞 */}
|
{/* 点赞 */}
|
||||||
<TouchableOpacity style={styles.actionButton} onPress={onLike}>
|
<TouchableOpacity style={styles.actionButton} onPress={() => onLike(comment)}>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name={comment.is_liked ? 'heart' : 'heart-outline'}
|
name={comment.is_liked ? 'heart' : 'heart-outline'}
|
||||||
size={14}
|
size={14}
|
||||||
@@ -615,6 +648,19 @@ const CommentItem: React.FC<CommentItemProps> = ({
|
|||||||
</Text>
|
</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{/* 举报按钮 - 只对非评论作者显示 */}
|
||||||
|
{!isCommentAuthor && onReport && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.actionButton}
|
||||||
|
onPress={() => onReport(comment)}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name="flag-outline" size={14} color={colors.text.hint} />
|
||||||
|
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
|
||||||
|
举报
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 删除按钮 - 只对评论作者显示 */}
|
{/* 删除按钮 - 只对评论作者显示 */}
|
||||||
{isCommentAuthor && (
|
{isCommentAuthor && (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ export type PostCardActionType =
|
|||||||
| 'unbookmark' // 取消收藏
|
| 'unbookmark' // 取消收藏
|
||||||
| 'share' // 分享
|
| 'share' // 分享
|
||||||
| 'imagePress' // 点击图片
|
| 'imagePress' // 点击图片
|
||||||
| 'delete'; // 删除
|
| 'delete' // 删除
|
||||||
|
| 'report'; // 举报
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Action payload 类型
|
* Action payload 类型
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Modal,
|
Modal,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { CameraView, useCameraPermissions } from 'expo-camera';
|
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
@@ -133,22 +133,81 @@ function createQrScannerStyles(colors: AppColors) {
|
|||||||
fontSize: fontSizes.md,
|
fontSize: fontSizes.md,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
},
|
},
|
||||||
|
webNotSupportedContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: spacing.xl,
|
||||||
|
backgroundColor: '#000',
|
||||||
|
},
|
||||||
|
webNotSupportedText: {
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
fontSize: fontSizes.md,
|
||||||
|
color: 'rgba(255,255,255,0.72)',
|
||||||
|
textAlign: 'center',
|
||||||
|
lineHeight: 24,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
// Web 端不支持扫码组件
|
||||||
|
const QRCodeScannerWeb: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||||
|
const themeColors = useAppColors();
|
||||||
|
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && Platform.OS === 'web') {
|
||||||
|
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||||
|
active?.blur?.();
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||||
|
<View style={styles.container}>
|
||||||
|
<View style={styles.header}>
|
||||||
|
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||||||
|
<MaterialCommunityIcons name="close" size={24} color="#fff" />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.headerTitle}>扫码登录</Text>
|
||||||
|
<View style={styles.placeholder} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.webNotSupportedContainer}>
|
||||||
|
<MaterialCommunityIcons name="web-off" size={64} color="rgba(255,255,255,0.5)" />
|
||||||
|
<Text style={styles.webNotSupportedText}>
|
||||||
|
扫码登录功能暂不支持网页端使用{'\n'}
|
||||||
|
请下载手机 App 体验完整功能
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity style={styles.permissionButton} onPress={onClose}>
|
||||||
|
<Text style={styles.permissionButtonText}>知道了</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 原生端扫码组件
|
||||||
|
const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const themeColors = useAppColors();
|
const themeColors = useAppColors();
|
||||||
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
|
||||||
const [permission, requestPermission] = useCameraPermissions();
|
|
||||||
|
// 动态导入 expo-camera,避免 Web 端加载
|
||||||
|
const [cameraModule, setCameraModule] = useState<typeof import('expo-camera') | null>(null);
|
||||||
|
const [permission, setPermission] = useState<any>(null);
|
||||||
const [scanned, setScanned] = useState(false);
|
const [scanned, setScanned] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible && Platform.OS !== 'web') {
|
||||||
setScanned(false);
|
import('expo-camera').then((module) => {
|
||||||
if (!permission?.granted) {
|
setCameraModule(module);
|
||||||
requestPermission();
|
const [perm, requestPerm] = module.useCameraPermissions();
|
||||||
|
setPermission(perm);
|
||||||
|
if (!perm?.granted) {
|
||||||
|
requestPerm();
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [visible]);
|
}, [visible]);
|
||||||
|
|
||||||
@@ -178,7 +237,7 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!permission?.granted) {
|
if (!cameraModule || !permission?.granted) {
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -192,7 +251,10 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
|||||||
<View style={styles.permissionContainer}>
|
<View style={styles.permissionContainer}>
|
||||||
<MaterialCommunityIcons name="camera-off" size={64} color="rgba(255,255,255,0.5)" />
|
<MaterialCommunityIcons name="camera-off" size={64} color="rgba(255,255,255,0.5)" />
|
||||||
<Text style={styles.permissionText}>需要相机权限才能扫码</Text>
|
<Text style={styles.permissionText}>需要相机权限才能扫码</Text>
|
||||||
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
|
<TouchableOpacity
|
||||||
|
style={styles.permissionButton}
|
||||||
|
onPress={() => cameraModule?.useCameraPermissions()[1]()}
|
||||||
|
>
|
||||||
<Text style={styles.permissionButtonText}>授予权限</Text>
|
<Text style={styles.permissionButtonText}>授予权限</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -201,6 +263,8 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { CameraView } = cameraModule;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
@@ -243,4 +307,9 @@ export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 根据平台导出不同组件
|
||||||
|
export const QRCodeScanner: React.FC<QRCodeScannerProps> = Platform.OS === 'web'
|
||||||
|
? QRCodeScannerWeb
|
||||||
|
: QRCodeScannerNative;
|
||||||
|
|
||||||
export default QRCodeScanner;
|
export default QRCodeScanner;
|
||||||
|
|||||||
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 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 && Platform.OS === 'web') {
|
||||||
|
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||||
|
active?.blur?.();
|
||||||
|
}
|
||||||
|
}, [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';
|
import React, { useMemo } from 'react';
|
||||||
@@ -9,7 +9,7 @@ import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
|||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
import { zhCN } from 'date-fns/locale';
|
import { zhCN } from 'date-fns/locale';
|
||||||
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
|
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
|
||||||
import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
|
import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
|
||||||
import Text from '../common/Text';
|
import Text from '../common/Text';
|
||||||
import Avatar from '../common/Avatar';
|
import Avatar from '../common/Avatar';
|
||||||
@@ -124,17 +124,13 @@ function createSystemMessageStyles(colors: AppColors) {
|
|||||||
container: {
|
container: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'flex-start',
|
||||||
padding: spacing.md,
|
padding: 16,
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: '#fff',
|
||||||
borderRadius: borderRadius.lg,
|
borderBottomWidth: 1,
|
||||||
marginHorizontal: spacing.md,
|
borderBottomColor: colors.divider || '#E5E5EA',
|
||||||
marginBottom: spacing.sm,
|
|
||||||
...shadows.sm,
|
|
||||||
},
|
},
|
||||||
unreadContainer: {
|
unreadContainer: {
|
||||||
backgroundColor: colors.primary.light + '08',
|
backgroundColor: '#FFF8F6',
|
||||||
borderLeftWidth: 3,
|
|
||||||
borderLeftColor: colors.primary.main,
|
|
||||||
},
|
},
|
||||||
unreadIndicator: {
|
unreadIndicator: {
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
@@ -143,19 +139,19 @@ function createSystemMessageStyles(colors: AppColors) {
|
|||||||
marginTop: -4,
|
marginTop: -4,
|
||||||
width: 8,
|
width: 8,
|
||||||
height: 8,
|
height: 8,
|
||||||
borderRadius: borderRadius.full,
|
borderRadius: 4,
|
||||||
backgroundColor: colors.primary.main,
|
backgroundColor: '#FF6B35',
|
||||||
},
|
},
|
||||||
iconContainer: {
|
iconContainer: {
|
||||||
marginRight: spacing.md,
|
marginRight: 14,
|
||||||
},
|
},
|
||||||
avatarWrapper: {
|
avatarWrapper: {
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
},
|
},
|
||||||
iconWrapper: {
|
iconWrapper: {
|
||||||
width: 48,
|
width: 44,
|
||||||
height: 48,
|
height: 44,
|
||||||
borderRadius: borderRadius.lg,
|
borderRadius: 12,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
},
|
},
|
||||||
@@ -163,13 +159,13 @@ function createSystemMessageStyles(colors: AppColors) {
|
|||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
bottom: -2,
|
bottom: -2,
|
||||||
right: -2,
|
right: -2,
|
||||||
width: 20,
|
width: 18,
|
||||||
height: 20,
|
height: 18,
|
||||||
borderRadius: borderRadius.full,
|
borderRadius: 9,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderWidth: 2,
|
borderWidth: 2,
|
||||||
borderColor: colors.background.paper,
|
borderColor: '#fff',
|
||||||
},
|
},
|
||||||
content: {
|
content: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
@@ -180,17 +176,17 @@ function createSystemMessageStyles(colors: AppColors) {
|
|||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginBottom: spacing.xs,
|
marginBottom: 4,
|
||||||
},
|
},
|
||||||
titleLeft: {
|
titleLeft: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
flex: 1,
|
flex: 1,
|
||||||
marginRight: spacing.sm,
|
marginRight: 8,
|
||||||
},
|
},
|
||||||
title: {
|
title: {
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
fontSize: fontSizes.md,
|
fontSize: 15,
|
||||||
color: colors.text.primary,
|
color: colors.text.primary,
|
||||||
},
|
},
|
||||||
unreadTitle: {
|
unreadTitle: {
|
||||||
@@ -200,10 +196,10 @@ function createSystemMessageStyles(colors: AppColors) {
|
|||||||
statusBadge: {
|
statusBadge: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingHorizontal: spacing.xs,
|
paddingHorizontal: 6,
|
||||||
paddingVertical: 2,
|
paddingVertical: 2,
|
||||||
borderRadius: borderRadius.sm,
|
borderRadius: 4,
|
||||||
marginLeft: spacing.xs,
|
marginLeft: 6,
|
||||||
},
|
},
|
||||||
statusText: {
|
statusText: {
|
||||||
fontSize: 10,
|
fontSize: 10,
|
||||||
@@ -211,55 +207,59 @@ function createSystemMessageStyles(colors: AppColors) {
|
|||||||
marginLeft: 2,
|
marginLeft: 2,
|
||||||
},
|
},
|
||||||
time: {
|
time: {
|
||||||
fontSize: fontSizes.sm,
|
fontSize: 13,
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
|
color: colors.text.hint,
|
||||||
},
|
},
|
||||||
messageContent: {
|
messageContent: {
|
||||||
lineHeight: 20,
|
lineHeight: 20,
|
||||||
fontSize: fontSizes.sm,
|
fontSize: 14,
|
||||||
|
color: colors.text.secondary,
|
||||||
},
|
},
|
||||||
extraInfo: {
|
extraInfo: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
marginTop: spacing.xs,
|
marginTop: 8,
|
||||||
backgroundColor: colors.primary.light + '12',
|
backgroundColor: '#F5F5F7',
|
||||||
paddingHorizontal: spacing.sm,
|
paddingHorizontal: 10,
|
||||||
paddingVertical: spacing.xs,
|
paddingVertical: 6,
|
||||||
borderRadius: borderRadius.md,
|
borderRadius: 8,
|
||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
},
|
},
|
||||||
extraText: {
|
extraText: {
|
||||||
marginLeft: spacing.xs,
|
marginLeft: 6,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
|
fontSize: 13,
|
||||||
},
|
},
|
||||||
actionsRow: {
|
actionsRow: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
marginTop: spacing.sm,
|
marginTop: 12,
|
||||||
gap: spacing.sm,
|
gap: 10,
|
||||||
},
|
},
|
||||||
actionBtn: {
|
actionBtn: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingVertical: spacing.xs,
|
paddingVertical: 8,
|
||||||
paddingHorizontal: spacing.md,
|
paddingHorizontal: 16,
|
||||||
borderRadius: borderRadius.md,
|
borderRadius: 10,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
gap: spacing.xs,
|
gap: 4,
|
||||||
},
|
},
|
||||||
rejectBtn: {
|
rejectBtn: {
|
||||||
borderColor: `${colors.error.main}55`,
|
borderColor: colors.error.main + '40',
|
||||||
backgroundColor: `${colors.error.main}18`,
|
backgroundColor: colors.error.light + '20',
|
||||||
},
|
},
|
||||||
approveBtn: {
|
approveBtn: {
|
||||||
borderColor: `${colors.success.main}55`,
|
borderColor: colors.success.main + '40',
|
||||||
backgroundColor: `${colors.success.main}18`,
|
backgroundColor: colors.success.light + '20',
|
||||||
},
|
},
|
||||||
actionBtnText: {
|
actionBtnText: {
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
|
fontSize: 13,
|
||||||
},
|
},
|
||||||
arrowContainer: {
|
arrowContainer: {
|
||||||
marginLeft: spacing.sm,
|
marginLeft: 8,
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
height: 20,
|
height: 20,
|
||||||
@@ -318,6 +318,13 @@ const getSystemMessageTitle = (message: SystemMessageResponse): string => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 胡萝卜橙主题色
|
||||||
|
const THEME_COLORS = {
|
||||||
|
primary: '#FF6B35',
|
||||||
|
primaryLight: '#FF8C5A',
|
||||||
|
primaryDark: '#E55A2B',
|
||||||
|
};
|
||||||
|
|
||||||
const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
||||||
message,
|
message,
|
||||||
onPress,
|
onPress,
|
||||||
|
|||||||
@@ -135,26 +135,33 @@ function createTabBarStyles(colors: AppColors) {
|
|||||||
modernContainer: {
|
modernContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
backgroundColor: colors.background.paper,
|
backgroundColor: colors.background.paper,
|
||||||
borderRadius: borderRadius.xl,
|
borderRadius: borderRadius['2xl'],
|
||||||
marginHorizontal: spacing.lg,
|
marginHorizontal: spacing.lg,
|
||||||
marginVertical: spacing.md,
|
marginVertical: spacing.sm,
|
||||||
padding: spacing.xs,
|
padding: spacing.xs,
|
||||||
shadowColor: colors.chat.shadow,
|
shadowColor: colors.chat.shadow,
|
||||||
shadowOffset: { width: 0, height: 2 },
|
shadowOffset: { width: 0, height: 4 },
|
||||||
shadowOpacity: 0.08,
|
shadowOpacity: 0.1,
|
||||||
shadowRadius: 8,
|
shadowRadius: 16,
|
||||||
elevation: 3,
|
elevation: 6,
|
||||||
},
|
},
|
||||||
modernTab: {
|
modernTab: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
paddingVertical: spacing.sm,
|
paddingVertical: spacing.md,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
borderRadius: borderRadius.lg,
|
borderRadius: borderRadius.lg,
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
},
|
},
|
||||||
modernTabActive: {
|
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: {
|
modernTabContent: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -165,7 +172,7 @@ function createTabBarStyles(colors: AppColors) {
|
|||||||
marginRight: spacing.xs,
|
marginRight: spacing.xs,
|
||||||
},
|
},
|
||||||
modernTabText: {
|
modernTabText: {
|
||||||
fontWeight: '500',
|
fontWeight: '600',
|
||||||
fontSize: fontSizes.md,
|
fontSize: fontSizes.md,
|
||||||
},
|
},
|
||||||
modernTabTextActive: {
|
modernTabTextActive: {
|
||||||
@@ -206,7 +213,7 @@ const TabBar: React.FC<TabBarProps> = ({
|
|||||||
key={index}
|
key={index}
|
||||||
style={[styles.modernTab, isActive && styles.modernTabActive]}
|
style={[styles.modernTab, isActive && styles.modernTabActive]}
|
||||||
onPress={() => onTabChange(index)}
|
onPress={() => onTabChange(index)}
|
||||||
activeOpacity={0.85}
|
activeOpacity={0.7}
|
||||||
>
|
>
|
||||||
<View style={styles.modernTabContent}>
|
<View style={styles.modernTabContent}>
|
||||||
{icon && (
|
{icon && (
|
||||||
|
|||||||
@@ -22,3 +22,4 @@ export { default as TabBar } from './TabBar';
|
|||||||
export { default as VoteCard } from './VoteCard';
|
export { default as VoteCard } from './VoteCard';
|
||||||
export { default as VoteEditor } from './VoteEditor';
|
export { default as VoteEditor } from './VoteEditor';
|
||||||
export { default as VotePreview } from './VotePreview';
|
export { default as VotePreview } from './VotePreview';
|
||||||
|
export { default as ReportDialog } from './ReportDialog';
|
||||||
|
|||||||
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;
|
||||||
281
src/components/call/IncomingCallModal.tsx
Normal file
281
src/components/call/IncomingCallModal.tsx
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
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';
|
||||||
|
|
||||||
|
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) {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||||
|
active?.blur?.();
|
||||||
|
}
|
||||||
|
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,6 +15,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Pressable,
|
Pressable,
|
||||||
Dimensions,
|
Dimensions,
|
||||||
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import {
|
import {
|
||||||
useResponsive,
|
useResponsive,
|
||||||
@@ -159,6 +160,10 @@ export function AdaptiveLayout({
|
|||||||
// 抽屉动画
|
// 抽屉动画
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isDrawerOpen) {
|
if (isDrawerOpen) {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||||
|
active?.blur?.();
|
||||||
|
}
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
Animated.timing(slideAnim, {
|
Animated.timing(slideAnim, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
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 type { AlertButton } from 'react-native';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
@@ -93,6 +93,12 @@ function createDialogHostStyles(colors: AppColors) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const blurActiveElementOnWeb = () => {
|
||||||
|
if (Platform.OS !== 'web') return;
|
||||||
|
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||||
|
active?.blur?.();
|
||||||
|
};
|
||||||
|
|
||||||
const AppDialogHost: React.FC = () => {
|
const AppDialogHost: React.FC = () => {
|
||||||
const [dialog, setDialog] = useState<DialogPayload | null>(null);
|
const [dialog, setDialog] = useState<DialogPayload | null>(null);
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
@@ -105,6 +111,12 @@ const AppDialogHost: React.FC = () => {
|
|||||||
return unbind;
|
return unbind;
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dialog) {
|
||||||
|
blurActiveElementOnWeb();
|
||||||
|
}
|
||||||
|
}, [dialog]);
|
||||||
|
|
||||||
const actions = useMemo<AlertButton[]>(() => {
|
const actions = useMemo<AlertButton[]>(() => {
|
||||||
if (!dialog?.actions?.length) return [{ text: '确定' }];
|
if (!dialog?.actions?.length) return [{ text: '确定' }];
|
||||||
return dialog.actions;
|
return dialog.actions;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
Alert,
|
Alert,
|
||||||
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { Image as ExpoImage } from 'expo-image';
|
import { Image as ExpoImage } from 'expo-image';
|
||||||
import {
|
import {
|
||||||
@@ -126,6 +127,10 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
|||||||
// 打开/关闭时重置状态
|
// 打开/关闭时重置状态
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||||
|
active?.blur?.();
|
||||||
|
}
|
||||||
setCurrentIndex(initialIndex);
|
setCurrentIndex(initialIndex);
|
||||||
setShowControls(true);
|
setShowControls(true);
|
||||||
setError(false);
|
setError(false);
|
||||||
|
|||||||
@@ -132,10 +132,10 @@ function createImageGridStyles(colors: AppColors) {
|
|||||||
aspectRatio: 1,
|
aspectRatio: 1,
|
||||||
},
|
},
|
||||||
gridItem2: {
|
gridItem2: {
|
||||||
width: '49%',
|
width: '48.5%',
|
||||||
},
|
},
|
||||||
gridItem3: {
|
gridItem3: {
|
||||||
width: '32.5%',
|
width: '31.8%',
|
||||||
},
|
},
|
||||||
masonryContainer: {
|
masonryContainer: {
|
||||||
flexDirection: 'row',
|
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 React from 'react';
|
||||||
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle } from 'react-native';
|
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle, Platform } from 'react-native';
|
||||||
import { useAppColors, fontSizes } from '../../theme';
|
import { useAppColors, fontSizes, fontWeights } from '../../theme';
|
||||||
|
|
||||||
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
|
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
|
||||||
|
|
||||||
@@ -16,38 +16,45 @@ interface CustomTextProps extends Omit<TextProps, 'style'> {
|
|||||||
numberOfLines?: number;
|
numberOfLines?: number;
|
||||||
onPress?: () => void;
|
onPress?: () => void;
|
||||||
style?: TextStyle | TextStyle[];
|
style?: TextStyle | TextStyle[];
|
||||||
|
weight?: 'regular' | 'medium' | 'semibold' | 'bold';
|
||||||
}
|
}
|
||||||
|
|
||||||
const variantStyles: Record<TextVariant, object> = {
|
const variantStyles: Record<TextVariant, object> = {
|
||||||
h1: {
|
h1: {
|
||||||
fontSize: fontSizes['4xl'],
|
fontSize: fontSizes['4xl'],
|
||||||
fontWeight: '700',
|
fontWeight: fontWeights.bold,
|
||||||
lineHeight: fontSizes['4xl'] * 1.4,
|
lineHeight: fontSizes['4xl'] * 1.3,
|
||||||
|
letterSpacing: -0.5,
|
||||||
},
|
},
|
||||||
h2: {
|
h2: {
|
||||||
fontSize: fontSizes['3xl'],
|
fontSize: fontSizes['3xl'],
|
||||||
fontWeight: '600',
|
fontWeight: fontWeights.semibold,
|
||||||
lineHeight: fontSizes['3xl'] * 1.4,
|
lineHeight: fontSizes['3xl'] * 1.3,
|
||||||
|
letterSpacing: -0.3,
|
||||||
},
|
},
|
||||||
h3: {
|
h3: {
|
||||||
fontSize: fontSizes['2xl'],
|
fontSize: fontSizes['2xl'],
|
||||||
fontWeight: '600',
|
fontWeight: fontWeights.semibold,
|
||||||
lineHeight: fontSizes['2xl'] * 1.3,
|
lineHeight: fontSizes['2xl'] * 1.3,
|
||||||
|
letterSpacing: -0.2,
|
||||||
},
|
},
|
||||||
body: {
|
body: {
|
||||||
fontSize: fontSizes.md,
|
fontSize: fontSizes.md,
|
||||||
fontWeight: '400',
|
fontWeight: fontWeights.regular,
|
||||||
lineHeight: fontSizes.md * 1.5,
|
lineHeight: fontSizes.md * 1.6,
|
||||||
|
letterSpacing: 0.1,
|
||||||
},
|
},
|
||||||
caption: {
|
caption: {
|
||||||
fontSize: fontSizes.sm,
|
fontSize: fontSizes.sm,
|
||||||
fontWeight: '400',
|
fontWeight: fontWeights.regular,
|
||||||
lineHeight: fontSizes.sm * 1.4,
|
lineHeight: fontSizes.sm * 1.5,
|
||||||
|
letterSpacing: 0.2,
|
||||||
},
|
},
|
||||||
label: {
|
label: {
|
||||||
fontSize: fontSizes.xs,
|
fontSize: fontSizes.xs,
|
||||||
fontWeight: '500',
|
fontWeight: fontWeights.medium,
|
||||||
lineHeight: fontSizes.xs * 1.4,
|
lineHeight: fontSizes.xs * 1.4,
|
||||||
|
letterSpacing: 0.3,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -55,6 +62,7 @@ const Text: React.FC<CustomTextProps> = ({
|
|||||||
children,
|
children,
|
||||||
variant = 'body',
|
variant = 'body',
|
||||||
color,
|
color,
|
||||||
|
weight,
|
||||||
numberOfLines,
|
numberOfLines,
|
||||||
onPress,
|
onPress,
|
||||||
style,
|
style,
|
||||||
@@ -64,6 +72,7 @@ const Text: React.FC<CustomTextProps> = ({
|
|||||||
const textStyle = [
|
const textStyle = [
|
||||||
styles.base,
|
styles.base,
|
||||||
variantStyles[variant],
|
variantStyles[variant],
|
||||||
|
weight ? { fontWeight: fontWeights[weight] } : null,
|
||||||
color ? { color } : { color: colors.text.primary },
|
color ? { color } : { color: colors.text.primary },
|
||||||
style,
|
style,
|
||||||
];
|
];
|
||||||
|
|||||||
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
|
* 全屏模态播放视频,基于 expo-video
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useCallback } from 'react';
|
import React, { useCallback, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
Modal,
|
Modal,
|
||||||
View,
|
View,
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
Text,
|
Text,
|
||||||
StatusBar,
|
StatusBar,
|
||||||
|
Platform,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { VideoView, useVideoPlayer } from 'expo-video';
|
import { VideoView, useVideoPlayer } from 'expo-video';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
@@ -79,6 +80,13 @@ export const VideoPlayerModal: React.FC<VideoPlayerModalProps> = ({
|
|||||||
url,
|
url,
|
||||||
onClose,
|
onClose,
|
||||||
}) => {
|
}) => {
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible && Platform.OS === 'web') {
|
||||||
|
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||||
|
active?.blur?.();
|
||||||
|
}
|
||||||
|
}, [visible]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
visible={visible}
|
visible={visible}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export { default as ResponsiveGrid } from './ResponsiveGrid';
|
|||||||
export { default as ResponsiveStack, HStack, VStack } from './ResponsiveStack';
|
export { default as ResponsiveStack, HStack, VStack } from './ResponsiveStack';
|
||||||
export { default as AdaptiveLayout, SidebarLayout } from './AdaptiveLayout';
|
export { default as AdaptiveLayout, SidebarLayout } from './AdaptiveLayout';
|
||||||
export { default as AppBackButton } from './AppBackButton';
|
export { default as AppBackButton } from './AppBackButton';
|
||||||
|
export { default as PagerView } from './PagerView';
|
||||||
|
|
||||||
// 图片相关组件
|
// 图片相关组件
|
||||||
export { default as SmartImage } from './SmartImage';
|
export { default as SmartImage } from './SmartImage';
|
||||||
@@ -32,3 +33,8 @@ export type { ImageGalleryProps, GalleryImageItem } from './ImageGallery';
|
|||||||
export type { ResponsiveGridProps } from './ResponsiveGrid';
|
export type { ResponsiveGridProps } from './ResponsiveGrid';
|
||||||
export type { ResponsiveStackProps, StackDirection, StackAlignment, StackJustify } from './ResponsiveStack';
|
export type { ResponsiveStackProps, StackDirection, StackAlignment, StackJustify } from './ResponsiveStack';
|
||||||
export type { AdaptiveLayoutProps, SidebarLayoutProps } from './AdaptiveLayout';
|
export type { AdaptiveLayoutProps, SidebarLayoutProps } from './AdaptiveLayout';
|
||||||
|
|
||||||
|
// 注册流程相关组件
|
||||||
|
export { default as VerificationCodeInput } from './VerificationCodeInput';
|
||||||
|
export { default as StepIndicator, RegisterStepIndicator } from './StepIndicator';
|
||||||
|
export type { Step, StepIndicatorProps } from './StepIndicator';
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
* 纯业务逻辑,无UI依赖
|
* 纯业务逻辑,无UI依赖
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { messageRepository } from '../../data/repositories/MessageRepository';
|
import { messageRepository, userCacheRepository, CachedMessage } from '@/database';
|
||||||
import { sseClient, SSEEventType } from '../../data/datasources/SSEClient';
|
import { wsClient, WSEventType } from '../../data/datasources/WSClient';
|
||||||
import {
|
import {
|
||||||
Message,
|
Message,
|
||||||
Conversation,
|
Conversation,
|
||||||
@@ -18,6 +18,16 @@ import {
|
|||||||
import { messageService } from '../../services/messageService';
|
import { messageService } from '../../services/messageService';
|
||||||
import { api } from '../../services/api';
|
import { api } from '../../services/api';
|
||||||
|
|
||||||
|
const cachedMessageToMessage = (cached: CachedMessage): Message => ({
|
||||||
|
id: cached.id,
|
||||||
|
conversationId: cached.conversationId,
|
||||||
|
senderId: cached.senderId,
|
||||||
|
seq: cached.seq,
|
||||||
|
segments: cached.segments || [],
|
||||||
|
createdAt: cached.createdAt,
|
||||||
|
status: cached.status as Message['status'],
|
||||||
|
});
|
||||||
|
|
||||||
// 事件类型定义
|
// 事件类型定义
|
||||||
export type MessageUseCaseEventType =
|
export type MessageUseCaseEventType =
|
||||||
| 'message_received'
|
| 'message_received'
|
||||||
@@ -62,7 +72,7 @@ class ProcessMessageUseCase {
|
|||||||
*/
|
*/
|
||||||
initialize(currentUserId: string | null): void {
|
initialize(currentUserId: string | null): void {
|
||||||
this.currentUserId = currentUserId;
|
this.currentUserId = currentUserId;
|
||||||
this.setupSSEListeners();
|
this.setupWSListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,49 +91,49 @@ class ProcessMessageUseCase {
|
|||||||
/**
|
/**
|
||||||
* 设置SSE监听器
|
* 设置SSE监听器
|
||||||
*/
|
*/
|
||||||
private setupSSEListeners(): void {
|
private setupWSListeners(): void {
|
||||||
// 监听私聊消息
|
// 监听私聊消息
|
||||||
const unsubChat = sseClient.on('chat', (message) => {
|
const unsubChat = wsClient.on('chat', (message) => {
|
||||||
this.handleNewMessage(message);
|
this.handleNewMessage(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊消息
|
// 监听群聊消息
|
||||||
const unsubGroupMessage = sseClient.on('group_message', (message) => {
|
const unsubGroupMessage = wsClient.on('group_message', (message) => {
|
||||||
this.handleNewMessage(message);
|
this.handleNewMessage(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听私聊已读回执
|
// 监听私聊已读回执
|
||||||
const unsubRead = sseClient.on('read', (message) => {
|
const unsubRead = wsClient.on('read', (message) => {
|
||||||
this.handleReadReceipt(message);
|
this.handleReadReceipt(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊已读回执
|
// 监听群聊已读回执
|
||||||
const unsubGroupRead = sseClient.on('group_read', (message) => {
|
const unsubGroupRead = wsClient.on('group_read', (message) => {
|
||||||
this.handleGroupReadReceipt(message);
|
this.handleGroupReadReceipt(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听私聊消息撤回
|
// 监听私聊消息撤回
|
||||||
const unsubRecall = sseClient.on('recall', (message) => {
|
const unsubRecall = wsClient.on('recall', (message) => {
|
||||||
this.handleRecallMessage(message);
|
this.handleRecallMessage(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊消息撤回
|
// 监听群聊消息撤回
|
||||||
const unsubGroupRecall = sseClient.on('group_recall', (message) => {
|
const unsubGroupRecall = wsClient.on('group_recall', (message) => {
|
||||||
this.handleGroupRecallMessage(message);
|
this.handleGroupRecallMessage(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊输入状态
|
// 监听群聊输入状态
|
||||||
const unsubGroupTyping = sseClient.on('group_typing', (message) => {
|
const unsubGroupTyping = wsClient.on('group_typing', (message) => {
|
||||||
this.handleGroupTyping(message);
|
this.handleGroupTyping(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群通知
|
// 监听群通知
|
||||||
const unsubGroupNotice = sseClient.on('group_notice', (message) => {
|
const unsubGroupNotice = wsClient.on('group_notice', (message) => {
|
||||||
this.handleGroupNotice(message);
|
this.handleGroupNotice(message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听连接状态
|
// 监听连接状态
|
||||||
const unsubConnected = sseClient.on('connected', () => {
|
const unsubConnected = wsClient.on('connected', () => {
|
||||||
this.notifySubscribers({
|
this.notifySubscribers({
|
||||||
type: 'connection_changed',
|
type: 'connection_changed',
|
||||||
payload: { connected: true },
|
payload: { connected: true },
|
||||||
@@ -131,7 +141,7 @@ class ProcessMessageUseCase {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubDisconnected = sseClient.on('disconnected', () => {
|
const unsubDisconnected = wsClient.on('disconnected', () => {
|
||||||
this.notifySubscribers({
|
this.notifySubscribers({
|
||||||
type: 'connection_changed',
|
type: 'connection_changed',
|
||||||
payload: { connected: false },
|
payload: { connected: false },
|
||||||
@@ -182,7 +192,16 @@ class ProcessMessageUseCase {
|
|||||||
|
|
||||||
// 异步保存到本地数据库
|
// 异步保存到本地数据库
|
||||||
const isRead = _isAck || sender_id === this.currentUserId;
|
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);
|
console.error('[ProcessMessageUseCase] 保存消息失败:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -227,7 +246,7 @@ class ProcessMessageUseCase {
|
|||||||
*/
|
*/
|
||||||
private async getSenderInfo(userId: string): Promise<any | null> {
|
private async getSenderInfo(userId: string): Promise<any | null> {
|
||||||
// 先检查本地缓存
|
// 先检查本地缓存
|
||||||
const cachedUser = await messageRepository.getUserCache(userId);
|
const cachedUser = await userCacheRepository.get(userId);
|
||||||
if (cachedUser) {
|
if (cachedUser) {
|
||||||
return cachedUser;
|
return cachedUser;
|
||||||
}
|
}
|
||||||
@@ -255,9 +274,9 @@ class ProcessMessageUseCase {
|
|||||||
*/
|
*/
|
||||||
private async fetchUserInfo(userId: string): Promise<any | null> {
|
private async fetchUserInfo(userId: string): Promise<any | null> {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/users/${userId}`);
|
const response = await api.get<any>(`/users/${userId}`);
|
||||||
if (response.code === 0 && response.data) {
|
if (response.code === 0 && response.data) {
|
||||||
await messageRepository.saveUserCache(response.data);
|
await userCacheRepository.save(response.data as any);
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -322,8 +341,8 @@ class ProcessMessageUseCase {
|
|||||||
|
|
||||||
// 更新本地数据库状态
|
// 更新本地数据库状态
|
||||||
messageRepository
|
messageRepository
|
||||||
.updateMessageStatus(message_id, 'recalled', true)
|
.updateStatus(message_id, 'recalled', true)
|
||||||
.catch((error) => {
|
.catch((error: unknown) => {
|
||||||
console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error);
|
console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -349,8 +368,8 @@ class ProcessMessageUseCase {
|
|||||||
|
|
||||||
// 更新本地数据库状态
|
// 更新本地数据库状态
|
||||||
messageRepository
|
messageRepository
|
||||||
.updateMessageStatus(message_id, 'recalled', true)
|
.updateStatus(message_id, 'recalled', true)
|
||||||
.catch((error) => {
|
.catch((error: unknown) => {
|
||||||
console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error);
|
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;
|
return message;
|
||||||
}
|
}
|
||||||
@@ -522,7 +550,8 @@ class ProcessMessageUseCase {
|
|||||||
* 获取本地消息
|
* 获取本地消息
|
||||||
*/
|
*/
|
||||||
async getLocalMessages(conversationId: string, limit: number = 20): Promise<Message[]> {
|
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
|
limit: number = 20
|
||||||
): Promise<Message[]> {
|
): Promise<Message[]> {
|
||||||
// 先从本地获取
|
// 先从本地获取
|
||||||
const localMessages = await messageRepository.getMessagesBeforeSeq(
|
const localMessages = await messageRepository.getBeforeSeq(
|
||||||
conversationId,
|
conversationId,
|
||||||
beforeSeq,
|
beforeSeq,
|
||||||
limit
|
limit
|
||||||
);
|
);
|
||||||
|
|
||||||
if (localMessages.length >= 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;
|
return serverMessages;
|
||||||
}
|
}
|
||||||
@@ -575,7 +613,7 @@ class ProcessMessageUseCase {
|
|||||||
console.error('[ProcessMessageUseCase] 获取历史消息失败:', error);
|
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;
|
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连接管理
|
* WSClient - WebSocket连接管理
|
||||||
* 只负责SSE连接管理,提供事件驱动架构
|
* 只负责WebSocket连接管理,提供事件驱动架构
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import {
|
import {
|
||||||
sseService,
|
wsService,
|
||||||
WSChatMessage,
|
WSChatMessage,
|
||||||
WSGroupChatMessage,
|
WSGroupChatMessage,
|
||||||
WSReadMessage,
|
WSReadMessage,
|
||||||
@@ -14,13 +14,13 @@ import {
|
|||||||
WSGroupTypingMessage,
|
WSGroupTypingMessage,
|
||||||
WSGroupNoticeMessage,
|
WSGroupNoticeMessage,
|
||||||
WSMessageType,
|
WSMessageType,
|
||||||
} from '../../services/sseService';
|
} from '../../services/wsService';
|
||||||
|
|
||||||
// 事件处理器类型
|
// 事件处理器类型
|
||||||
type EventHandler<T> = (data: T) => void;
|
type EventHandler<T> = (data: T) => void;
|
||||||
|
|
||||||
// SSE事件类型
|
// WS事件类型
|
||||||
export interface SSEEvents {
|
export interface WSEvents {
|
||||||
'chat': WSChatMessage;
|
'chat': WSChatMessage;
|
||||||
'group_message': WSGroupChatMessage;
|
'group_message': WSGroupChatMessage;
|
||||||
'read': WSReadMessage;
|
'read': WSReadMessage;
|
||||||
@@ -34,65 +34,65 @@ export interface SSEEvents {
|
|||||||
'error': Error;
|
'error': Error;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SSEEventType = keyof SSEEvents;
|
export type WSEventType = keyof WSEvents;
|
||||||
|
|
||||||
class SSEClient {
|
class WSClient {
|
||||||
private handlers: Map<SSEEventType, Set<EventHandler<any>>> = new Map();
|
private handlers: Map<WSEventType, Set<EventHandler<any>>> = new Map();
|
||||||
private unsubscribeFns: Array<() => void> = [];
|
private unsubscribeFns: Array<() => void> = [];
|
||||||
private isInitialized = false;
|
private isInitialized = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化SSE监听
|
* 初始化WebSocket监听
|
||||||
*/
|
*/
|
||||||
initialize(): void {
|
initialize(): void {
|
||||||
if (this.isInitialized) return;
|
if (this.isInitialized) return;
|
||||||
|
|
||||||
// 监听私聊消息
|
// 监听私聊消息
|
||||||
const unsubChat = sseService.on('chat', (message) => {
|
const unsubChat = wsService.on('chat', (message) => {
|
||||||
this.emit('chat', message);
|
this.emit('chat', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊消息
|
// 监听群聊消息
|
||||||
const unsubGroupMessage = sseService.on('group_message', (message) => {
|
const unsubGroupMessage = wsService.on('group_message', (message) => {
|
||||||
this.emit('group_message', message);
|
this.emit('group_message', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听私聊已读回执
|
// 监听私聊已读回执
|
||||||
const unsubRead = sseService.on('read', (message) => {
|
const unsubRead = wsService.on('read', (message) => {
|
||||||
this.emit('read', message);
|
this.emit('read', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊已读回执
|
// 监听群聊已读回执
|
||||||
const unsubGroupRead = sseService.on('group_read', (message) => {
|
const unsubGroupRead = wsService.on('group_read', (message) => {
|
||||||
this.emit('group_read', message);
|
this.emit('group_read', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听私聊消息撤回
|
// 监听私聊消息撤回
|
||||||
const unsubRecall = sseService.on('recall', (message) => {
|
const unsubRecall = wsService.on('recall', (message) => {
|
||||||
this.emit('recall', message);
|
this.emit('recall', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊消息撤回
|
// 监听群聊消息撤回
|
||||||
const unsubGroupRecall = sseService.on('group_recall', (message) => {
|
const unsubGroupRecall = wsService.on('group_recall', (message) => {
|
||||||
this.emit('group_recall', message);
|
this.emit('group_recall', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群聊输入状态
|
// 监听群聊输入状态
|
||||||
const unsubGroupTyping = sseService.on('group_typing', (message) => {
|
const unsubGroupTyping = wsService.on('group_typing', (message) => {
|
||||||
this.emit('group_typing', message);
|
this.emit('group_typing', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听群通知
|
// 监听群通知
|
||||||
const unsubGroupNotice = sseService.on('group_notice', (message) => {
|
const unsubGroupNotice = wsService.on('group_notice', (message) => {
|
||||||
this.emit('group_notice', message);
|
this.emit('group_notice', message);
|
||||||
});
|
});
|
||||||
|
|
||||||
// 监听连接状态
|
// 监听连接状态
|
||||||
const unsubConnect = sseService.onConnect(() => {
|
const unsubConnect = wsService.onConnect(() => {
|
||||||
this.emit('connected', undefined);
|
this.emit('connected', undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubDisconnect = sseService.onDisconnect(() => {
|
const unsubDisconnect = wsService.onDisconnect(() => {
|
||||||
this.emit('disconnected', undefined);
|
this.emit('disconnected', undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ class SSEClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 销毁SSE监听
|
* 销毁WebSocket监听
|
||||||
*/
|
*/
|
||||||
destroy(): void {
|
destroy(): void {
|
||||||
this.unsubscribeFns.forEach((fn) => fn());
|
this.unsubscribeFns.forEach((fn) => fn());
|
||||||
@@ -125,9 +125,9 @@ class SSEClient {
|
|||||||
/**
|
/**
|
||||||
* 订阅事件
|
* 订阅事件
|
||||||
*/
|
*/
|
||||||
on<T extends SSEEventType>(
|
on<T extends WSEventType>(
|
||||||
event: T,
|
event: T,
|
||||||
handler: EventHandler<SSEEvents[T]>
|
handler: EventHandler<WSEvents[T]>
|
||||||
): () => void {
|
): () => void {
|
||||||
if (!this.handlers.has(event)) {
|
if (!this.handlers.has(event)) {
|
||||||
this.handlers.set(event, new Set());
|
this.handlers.set(event, new Set());
|
||||||
@@ -142,9 +142,9 @@ class SSEClient {
|
|||||||
/**
|
/**
|
||||||
* 触发事件
|
* 触发事件
|
||||||
*/
|
*/
|
||||||
private emit<T extends SSEEventType>(
|
private emit<T extends WSEventType>(
|
||||||
event: T,
|
event: T,
|
||||||
data: SSEEvents[T]
|
data: WSEvents[T]
|
||||||
): void {
|
): void {
|
||||||
const handlers = this.handlers.get(event);
|
const handlers = this.handlers.get(event);
|
||||||
if (handlers) {
|
if (handlers) {
|
||||||
@@ -152,7 +152,7 @@ class SSEClient {
|
|||||||
try {
|
try {
|
||||||
handler(data);
|
handler(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[SSEClient] 事件处理器执行失败: ${event}`, error);
|
console.error(`[WSClient] 事件处理器执行失败: ${event}`, error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -162,23 +162,23 @@ class SSEClient {
|
|||||||
* 检查连接状态
|
* 检查连接状态
|
||||||
*/
|
*/
|
||||||
isConnected(): boolean {
|
isConnected(): boolean {
|
||||||
return sseService.isConnected();
|
return wsService.isConnected();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动SSE连接
|
* 启动WebSocket连接
|
||||||
*/
|
*/
|
||||||
async connect(): Promise<boolean> {
|
async connect(): Promise<boolean> {
|
||||||
return sseService.start();
|
return wsService.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 断开SSE连接
|
* 断开WebSocket连接
|
||||||
*/
|
*/
|
||||||
disconnect(): void {
|
disconnect(): void {
|
||||||
sseService.stop();
|
wsService.stop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const sseClient = new SSEClient();
|
export const wsClient = new WSClient();
|
||||||
export default sseClient;
|
export default wsClient;
|
||||||
@@ -5,5 +5,5 @@
|
|||||||
|
|
||||||
export * from './interfaces';
|
export * from './interfaces';
|
||||||
export * from './ApiDataSource';
|
export * from './ApiDataSource';
|
||||||
export * from './LocalDataSource';
|
export { LocalDataSource, localDataSource } from '@/database';
|
||||||
export * from './CacheDataSource';
|
export * from './CacheDataSource';
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export interface ILocalDataSource {
|
|||||||
run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }>;
|
run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }>;
|
||||||
transaction(operations: () => Promise<void>): Promise<void>;
|
transaction(operations: () => Promise<void>): Promise<void>;
|
||||||
batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void>;
|
batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void>;
|
||||||
|
enqueueWrite<T>(operation: () => Promise<T>): Promise<T>;
|
||||||
|
initialize(userId?: string): Promise<void>;
|
||||||
|
switchDatabase(userId?: string | null): Promise<void>;
|
||||||
|
closeDatabase(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 缓存数据源接口
|
// 缓存数据源接口
|
||||||
|
|||||||
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 { ApiDataSource, apiDataSource } from '../datasources/ApiDataSource';
|
||||||
import { LocalDataSource, localDataSource } from '../datasources/LocalDataSource';
|
import { localDataSource } from '@/database';
|
||||||
import { PostMapper } from '../mappers/PostMapper';
|
import { PostMapper } from '../mappers/PostMapper';
|
||||||
import type { Post, PostImage, PostStatus } from '../../core/entities/Post';
|
import type { Post, PostImage, PostStatus } from '../../core/entities/Post';
|
||||||
import { createPost } from '../../core/entities/Post';
|
import { createPost } from '../../core/entities/Post';
|
||||||
@@ -23,8 +23,8 @@ import type {
|
|||||||
* API帖子响应类型
|
* API帖子响应类型
|
||||||
*/
|
*/
|
||||||
interface PostApiResponse {
|
interface PostApiResponse {
|
||||||
id: number;
|
id: string;
|
||||||
user_id: number;
|
user_id: string;
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
images?: Array<{ url: string; width?: number; height?: number; thumbnail_url?: string }>;
|
images?: Array<{ url: string; width?: number; height?: number; thumbnail_url?: string }>;
|
||||||
@@ -44,7 +44,7 @@ interface PostApiResponse {
|
|||||||
content_edited_at?: string;
|
content_edited_at?: string;
|
||||||
channel?: { id: string; name: string } | null;
|
channel?: { id: string; name: string } | null;
|
||||||
author?: {
|
author?: {
|
||||||
id: number;
|
id: string;
|
||||||
username: string;
|
username: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
avatar?: string;
|
avatar?: string;
|
||||||
@@ -79,13 +79,12 @@ interface CachedPost {
|
|||||||
|
|
||||||
export class PostRepository implements IPostRepository {
|
export class PostRepository implements IPostRepository {
|
||||||
private api: ApiDataSource;
|
private api: ApiDataSource;
|
||||||
private localDb: LocalDataSource;
|
private localDb = localDataSource;
|
||||||
private memoryCache: Map<string, { post: Post; timestamp: number }> = new Map();
|
private memoryCache: Map<string, { post: Post; timestamp: number }> = new Map();
|
||||||
private readonly CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存过期时间
|
private readonly CACHE_TTL = 5 * 60 * 1000;
|
||||||
|
|
||||||
constructor(api: ApiDataSource, localDb: LocalDataSource) {
|
constructor(api: ApiDataSource) {
|
||||||
this.api = api;
|
this.api = api;
|
||||||
this.localDb = localDb;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 私有辅助方法 ====================
|
// ==================== 私有辅助方法 ====================
|
||||||
@@ -180,7 +179,6 @@ export class PostRepository implements IPostRepository {
|
|||||||
*/
|
*/
|
||||||
private async getFromLocalCache(id: string): Promise<Post | null> {
|
private async getFromLocalCache(id: string): Promise<Post | null> {
|
||||||
try {
|
try {
|
||||||
await this.localDb.initialize();
|
|
||||||
const result = await this.localDb.getFirst<CachedPost>(
|
const result = await this.localDb.getFirst<CachedPost>(
|
||||||
'SELECT * FROM posts_cache WHERE id = ?',
|
'SELECT * FROM posts_cache WHERE id = ?',
|
||||||
[id]
|
[id]
|
||||||
@@ -201,7 +199,6 @@ export class PostRepository implements IPostRepository {
|
|||||||
*/
|
*/
|
||||||
private async saveToLocalCache(post: Post): Promise<void> {
|
private async saveToLocalCache(post: Post): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.localDb.initialize();
|
|
||||||
await this.localDb.enqueueWrite(async () => {
|
await this.localDb.enqueueWrite(async () => {
|
||||||
await this.localDb.run(
|
await this.localDb.run(
|
||||||
`INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
|
`INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
|
||||||
@@ -218,7 +215,6 @@ export class PostRepository implements IPostRepository {
|
|||||||
*/
|
*/
|
||||||
private async clearLocalCache(id: string): Promise<void> {
|
private async clearLocalCache(id: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
await this.localDb.initialize();
|
|
||||||
await this.localDb.enqueueWrite(async () => {
|
await this.localDb.enqueueWrite(async () => {
|
||||||
await this.localDb.run('DELETE FROM posts_cache WHERE id = ?', [id]);
|
await this.localDb.run('DELETE FROM posts_cache WHERE id = ?', [id]);
|
||||||
});
|
});
|
||||||
@@ -555,7 +551,6 @@ export class PostRepository implements IPostRepository {
|
|||||||
async clearAllCache(): Promise<void> {
|
async clearAllCache(): Promise<void> {
|
||||||
this.memoryCache.clear();
|
this.memoryCache.clear();
|
||||||
try {
|
try {
|
||||||
await this.localDb.initialize();
|
|
||||||
await this.localDb.enqueueWrite(async () => {
|
await this.localDb.enqueueWrite(async () => {
|
||||||
await this.localDb.run('DELETE FROM posts_cache');
|
await this.localDb.run('DELETE FROM posts_cache');
|
||||||
});
|
});
|
||||||
@@ -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;
|
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';
|
||||||
@@ -14,6 +14,7 @@ import { postManager } from '../stores/postManager';
|
|||||||
import { groupManager } from '../stores/groupManager';
|
import { groupManager } from '../stores/groupManager';
|
||||||
import { userManager } from '../stores/userManager';
|
import { userManager } from '../stores/userManager';
|
||||||
import { messageManager } from '../stores/messageManager';
|
import { messageManager } from '../stores/messageManager';
|
||||||
|
import type { PostListTab } from '../stores/postListSources';
|
||||||
|
|
||||||
// ==================== 预取配置 ====================
|
// ==================== 预取配置 ====================
|
||||||
|
|
||||||
@@ -134,7 +135,7 @@ const prefetchService = new PrefetchService();
|
|||||||
* 预取帖子数据
|
* 预取帖子数据
|
||||||
* @param types 帖子类型数组
|
* @param types 帖子类型数组
|
||||||
*/
|
*/
|
||||||
function prefetchPosts(types: string[] = ['latest', 'hot']): void {
|
function prefetchPosts(types: PostListTab[] = ['latest', 'hot']): void {
|
||||||
types.forEach((type, index) => {
|
types.forEach((type, index) => {
|
||||||
prefetchService.schedule({
|
prefetchService.schedule({
|
||||||
key: `posts:${type}:1`,
|
key: `posts:${type}:1`,
|
||||||
@@ -152,10 +153,7 @@ function prefetchConversations(): void {
|
|||||||
key: 'conversations:list',
|
key: 'conversations:list',
|
||||||
executor: async () => {
|
executor: async () => {
|
||||||
await messageManager.initialize();
|
await messageManager.initialize();
|
||||||
await messageManager.requestConversationListRefresh('prefetch', {
|
await messageManager.refreshConversations(true, 'prefetch');
|
||||||
force: true,
|
|
||||||
allowDefer: true,
|
|
||||||
});
|
|
||||||
return messageManager.getConversations();
|
return messageManager.getConversations();
|
||||||
},
|
},
|
||||||
priority: Priority.HIGH,
|
priority: Priority.HIGH,
|
||||||
@@ -279,7 +277,7 @@ export function usePrefetch() {
|
|||||||
const hasInitialPrefetched = useRef(false);
|
const hasInitialPrefetched = useRef(false);
|
||||||
|
|
||||||
/** 预取帖子 */
|
/** 预取帖子 */
|
||||||
const prefetchPostsData = useCallback((types?: string[]) => {
|
const prefetchPostsData = useCallback((types?: PostListTab[]) => {
|
||||||
prefetchPosts(types);
|
prefetchPosts(types);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
36
src/infrastructure/cache/MediaCacheManager.ts
vendored
36
src/infrastructure/cache/MediaCacheManager.ts
vendored
@@ -4,8 +4,9 @@
|
|||||||
* @description 管理图片、视频、音频缓存,支持多种清理策略
|
* @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 AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
import {
|
import {
|
||||||
MediaType,
|
MediaType,
|
||||||
CleanupTrigger,
|
CleanupTrigger,
|
||||||
@@ -27,7 +28,7 @@ const CACHE_RECORD_PREFIX = 'media_cache_record_';
|
|||||||
* 获取缓存目录路径
|
* 获取缓存目录路径
|
||||||
*/
|
*/
|
||||||
function getCacheDir(): string {
|
function getCacheDir(): string {
|
||||||
const cachePath = Paths.cache;
|
const cachePath = Paths.cache.uri;
|
||||||
return `${cachePath}media_cache/`;
|
return `${cachePath}media_cache/`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,13 +103,15 @@ export class MediaCacheManager {
|
|||||||
// 加载缓存记录
|
// 加载缓存记录
|
||||||
await this.loadCacheRecords();
|
await this.loadCacheRecords();
|
||||||
|
|
||||||
// 启动时清理
|
// 启动时清理 (非web平台)
|
||||||
if (this.policy.cleanupOnStart) {
|
if (this.policy.cleanupOnStart && Platform.OS !== 'web') {
|
||||||
await this.cleanup(CleanupTrigger.STARTUP);
|
await this.cleanup(CleanupTrigger.STARTUP);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动定期清理
|
// 启动定期清理 (非web平台)
|
||||||
|
if (Platform.OS !== 'web') {
|
||||||
this.startPeriodicCleanup();
|
this.startPeriodicCleanup();
|
||||||
|
}
|
||||||
|
|
||||||
this.isInitialized = true;
|
this.isInitialized = true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -121,12 +124,19 @@ export class MediaCacheManager {
|
|||||||
* 确保缓存目录存在
|
* 确保缓存目录存在
|
||||||
*/
|
*/
|
||||||
private async ensureDirectories(): Promise<void> {
|
private async ensureDirectories(): Promise<void> {
|
||||||
|
if (Platform.OS === 'web') return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const dirs = [getImageDir(), getVideoDir(), getAudioDir()];
|
const cacheDir = new Directory(Paths.cache, 'media_cache');
|
||||||
for (const dir of dirs) {
|
if (!await cacheDir.exists) {
|
||||||
const dirFile = new File(dir);
|
await cacheDir.create();
|
||||||
if (!await dirFile.exists) {
|
}
|
||||||
await dirFile.create();
|
|
||||||
|
const subDirs = ['images', 'videos', 'audios'];
|
||||||
|
for (const subDir of subDirs) {
|
||||||
|
const dir = new Directory(cacheDir, subDir);
|
||||||
|
if (!await dir.exists) {
|
||||||
|
await dir.create();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -168,6 +178,8 @@ export class MediaCacheManager {
|
|||||||
* 检查文件是否存在
|
* 检查文件是否存在
|
||||||
*/
|
*/
|
||||||
private async checkFileExists(path: string): Promise<boolean> {
|
private async checkFileExists(path: string): Promise<boolean> {
|
||||||
|
if (Platform.OS === 'web') return false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const file = new File(path);
|
const file = new File(path);
|
||||||
return await file.exists;
|
return await file.exists;
|
||||||
@@ -199,6 +211,10 @@ export class MediaCacheManager {
|
|||||||
size?: number;
|
size?: number;
|
||||||
} = {}
|
} = {}
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
return uri;
|
||||||
|
}
|
||||||
|
|
||||||
const { conversationId, messageId, size = 0 } = options;
|
const { conversationId, messageId, size = 0 } = options;
|
||||||
|
|
||||||
// 生成唯一键
|
// 生成唯一键
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ export function hrefProfileBlocked(): string {
|
|||||||
return '/profile/blocked-users';
|
return '/profile/blocked-users';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hrefProfileChatSettings(): string {
|
||||||
|
return '/profile/chat-settings';
|
||||||
|
}
|
||||||
|
|
||||||
export function hrefProfileMyPosts(): string {
|
export function hrefProfileMyPosts(): string {
|
||||||
return '/profile/my-posts';
|
return '/profile/my-posts';
|
||||||
}
|
}
|
||||||
@@ -155,3 +159,62 @@ export function hrefAuthRegister(): string {
|
|||||||
export function hrefAuthForgot(): string {
|
export function hrefAuthForgot(): string {
|
||||||
return '/forgot-password';
|
return '/forgot-password';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function hrefAuthWelcome(): string {
|
||||||
|
return '/welcome';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Materials (学习资料) ====================
|
||||||
|
|
||||||
|
export function hrefMaterials(): string {
|
||||||
|
return '/apps/materials';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hrefMaterialSubject(subjectId: string): string {
|
||||||
|
return `/apps/materials/subject?subjectId=${encodeURIComponent(subjectId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hrefMaterialDetail(materialId: string): string {
|
||||||
|
return `/apps/materials/detail?materialId=${encodeURIComponent(materialId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== Profile Settings (设置相关) ====================
|
||||||
|
|
||||||
|
export function hrefProfileAbout(): string {
|
||||||
|
return '/profile/about';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hrefProfileTerms(): string {
|
||||||
|
return '/terms';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hrefProfilePrivacy(): string {
|
||||||
|
return '/privacy';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hrefProfileVerification(): string {
|
||||||
|
return '/profile/verification';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hrefVerificationGuide(): string {
|
||||||
|
return '/verification-guide';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hrefVerificationForm(identity?: string): string {
|
||||||
|
if (identity) {
|
||||||
|
return `/verification-form?identity=${encodeURIComponent(identity)}`;
|
||||||
|
}
|
||||||
|
return '/verification-form';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hrefProfileDataStorage(): string {
|
||||||
|
return '/profile/data-storage';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hrefProfilePrivacySettings(): string {
|
||||||
|
return '/profile/privacy-settings';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hrefProfileDeletion(): string {
|
||||||
|
return '/profile/account-deletion';
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
type AppColors,
|
type AppColors,
|
||||||
} from '../../theme';
|
} from '../../theme';
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
import { Text, ResponsiveContainer } from '../../components/common';
|
import { Text } from '../../components/common';
|
||||||
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
|
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
|
||||||
|
|
||||||
type AppItem = {
|
type AppItem = {
|
||||||
@@ -37,8 +37,18 @@ const APP_ENTRIES: AppItem[] = [
|
|||||||
icon: 'calendar-week',
|
icon: 'calendar-week',
|
||||||
href: hrefs.hrefSchedule(),
|
href: hrefs.hrefSchedule(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'materials',
|
||||||
|
title: '学习资料',
|
||||||
|
subtitle: '按学科分类的学习资源网盘',
|
||||||
|
icon: 'folder-multiple',
|
||||||
|
href: hrefs.hrefMaterials(),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 应用卡片最大宽度设置
|
||||||
|
const APP_CARD_MAX_WIDTH = 720;
|
||||||
|
|
||||||
export const AppsScreen: React.FC = () => {
|
export const AppsScreen: React.FC = () => {
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const resolvedScheme = useResolvedColorScheme();
|
const resolvedScheme = useResolvedColorScheme();
|
||||||
@@ -56,6 +66,9 @@ export const AppsScreen: React.FC = () => {
|
|||||||
shadowOpacity: 0.06,
|
shadowOpacity: 0.06,
|
||||||
shadowRadius: 8,
|
shadowRadius: 8,
|
||||||
elevation: 2,
|
elevation: 2,
|
||||||
|
maxWidth: APP_CARD_MAX_WIDTH,
|
||||||
|
alignSelf: 'center' as const,
|
||||||
|
width: '100%' as const,
|
||||||
}),
|
}),
|
||||||
[colors]
|
[colors]
|
||||||
);
|
);
|
||||||
@@ -129,17 +142,6 @@ export const AppsScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{isWideScreen ? (
|
|
||||||
<ResponsiveContainer maxWidth={720}>
|
|
||||||
<ScrollView
|
|
||||||
style={styles.scroll}
|
|
||||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
>
|
|
||||||
{renderTiles()}
|
|
||||||
</ScrollView>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scroll}
|
style={styles.scroll}
|
||||||
contentContainerStyle={[
|
contentContainerStyle={[
|
||||||
@@ -150,7 +152,6 @@ export const AppsScreen: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{renderTiles()}
|
{renderTiles()}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
)}
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
/**
|
||||||
|
* 忘记密码页 ForgotPasswordScreen(简洁表单样式)
|
||||||
|
* 胡萝卜BBS - 找回密码
|
||||||
|
*
|
||||||
|
* 设计风格:
|
||||||
|
* - 纯白背景,扁平化设计
|
||||||
|
* - 增大间距
|
||||||
|
* - 输入框带灰色背景填充
|
||||||
|
* - 橙色圆角按钮
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useEffect, useMemo, useState, useRef } from 'react';
|
||||||
import {
|
import {
|
||||||
View,
|
View,
|
||||||
Text,
|
Text,
|
||||||
@@ -9,123 +20,28 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
|
Animated,
|
||||||
|
StatusBar,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { useAppColors, type AppColors } from '../../theme';
|
||||||
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
|
|
||||||
import { authService, resolveAuthApiError } from '../../services/authService';
|
import { authService, resolveAuthApiError } from '../../services/authService';
|
||||||
import { showPrompt } from '../../services/promptService';
|
import { showPrompt } from '../../services/promptService';
|
||||||
|
|
||||||
function createForgotPasswordStyles(colors: AppColors) {
|
// 胡萝卜橙主题色
|
||||||
return StyleSheet.create({
|
const THEME_COLORS = {
|
||||||
container: { flex: 1 },
|
primary: '#FF6B35',
|
||||||
gradient: { flex: 1 },
|
primaryLight: '#FF8C5A',
|
||||||
keyboardView: { flex: 1 },
|
primaryDark: '#E55A2B',
|
||||||
scrollContent: {
|
};
|
||||||
flexGrow: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
paddingHorizontal: spacing.xl,
|
|
||||||
paddingVertical: spacing['2xl'],
|
|
||||||
},
|
|
||||||
formCard: {
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
borderRadius: borderRadius['2xl'],
|
|
||||||
padding: spacing.xl,
|
|
||||||
...shadows.md,
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
fontSize: fontSizes['2xl'],
|
|
||||||
fontWeight: '700',
|
|
||||||
color: colors.text.primary,
|
|
||||||
textAlign: 'center',
|
|
||||||
marginBottom: spacing.xs,
|
|
||||||
},
|
|
||||||
subtitle: {
|
|
||||||
fontSize: fontSizes.sm,
|
|
||||||
color: colors.text.secondary,
|
|
||||||
textAlign: 'center',
|
|
||||||
marginBottom: spacing.lg,
|
|
||||||
},
|
|
||||||
inputWrapper: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
backgroundColor: colors.background.default,
|
|
||||||
borderRadius: borderRadius.lg,
|
|
||||||
borderWidth: 1.5,
|
|
||||||
borderColor: colors.divider,
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
height: 52,
|
|
||||||
marginBottom: spacing.md,
|
|
||||||
},
|
|
||||||
inputIcon: {
|
|
||||||
marginRight: spacing.sm,
|
|
||||||
},
|
|
||||||
input: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
color: colors.text.primary,
|
|
||||||
},
|
|
||||||
codeRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: spacing.sm,
|
|
||||||
marginBottom: spacing.md,
|
|
||||||
},
|
|
||||||
codeInput: {
|
|
||||||
flex: 1,
|
|
||||||
marginBottom: 0,
|
|
||||||
},
|
|
||||||
sendCodeButton: {
|
|
||||||
height: 52,
|
|
||||||
minWidth: 110,
|
|
||||||
borderRadius: borderRadius.lg,
|
|
||||||
backgroundColor: colors.primary.main,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
},
|
|
||||||
sendCodeButtonDisabled: {
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
sendCodeButtonText: {
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: fontSizes.sm,
|
|
||||||
fontWeight: '600',
|
|
||||||
},
|
|
||||||
submitButton: {
|
|
||||||
height: 50,
|
|
||||||
borderRadius: borderRadius.lg,
|
|
||||||
backgroundColor: colors.primary.main,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
marginTop: spacing.sm,
|
|
||||||
},
|
|
||||||
submitButtonDisabled: {
|
|
||||||
opacity: 0.6,
|
|
||||||
},
|
|
||||||
submitButtonText: {
|
|
||||||
color: '#fff',
|
|
||||||
fontSize: fontSizes.lg,
|
|
||||||
fontWeight: '700',
|
|
||||||
},
|
|
||||||
backButton: {
|
|
||||||
alignItems: 'center',
|
|
||||||
marginTop: spacing.md,
|
|
||||||
},
|
|
||||||
backButtonText: {
|
|
||||||
color: colors.primary.main,
|
|
||||||
fontSize: fontSizes.md,
|
|
||||||
fontWeight: '500',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ForgotPasswordScreen: React.FC = () => {
|
export const ForgotPasswordScreen: React.FC = () => {
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
const styles = useMemo(() => createForgotPasswordStyles(colors), [colors]);
|
const styles = useMemo(() => createForgotPasswordStyles(colors), [colors]);
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
const [verificationCode, setVerificationCode] = useState('');
|
const [verificationCode, setVerificationCode] = useState('');
|
||||||
const [newPassword, setNewPassword] = useState('');
|
const [newPassword, setNewPassword] = useState('');
|
||||||
@@ -136,6 +52,26 @@ export const ForgotPasswordScreen: React.FC = () => {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [countdown, setCountdown] = useState(0);
|
const [countdown, setCountdown] = useState(0);
|
||||||
|
|
||||||
|
// 动画值
|
||||||
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
const slideAnim = useRef(new Animated.Value(20)).current;
|
||||||
|
|
||||||
|
// 启动入场动画
|
||||||
|
useEffect(() => {
|
||||||
|
Animated.parallel([
|
||||||
|
Animated.timing(fadeAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 400,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(slideAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 400,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
]).start();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (countdown <= 0) {
|
if (countdown <= 0) {
|
||||||
return;
|
return;
|
||||||
@@ -179,8 +115,20 @@ export const ForgotPasswordScreen: React.FC = () => {
|
|||||||
showPrompt({ title: '提示', message: '请输入验证码', type: 'warning' });
|
showPrompt({ title: '提示', message: '请输入验证码', type: 'warning' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (newPassword.length < 6) {
|
if (newPassword.length < 8) {
|
||||||
showPrompt({ title: '提示', message: '新密码至少 6 位', type: 'warning' });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (newPassword !== confirmPassword) {
|
if (newPassword !== confirmPassword) {
|
||||||
@@ -208,32 +156,61 @@ export const ForgotPasswordScreen: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView style={styles.container}>
|
<SafeAreaView style={styles.container}>
|
||||||
<LinearGradient colors={['#FF6B35', '#FF8F66', '#FFB088']} style={styles.gradient}>
|
<StatusBar barStyle="dark-content" backgroundColor="#fff" />
|
||||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.keyboardView}>
|
<KeyboardAvoidingView
|
||||||
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
<View style={styles.formCard}>
|
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.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}>
|
<View style={styles.inputWrapper}>
|
||||||
<MaterialCommunityIcons name="email-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
|
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="邮箱"
|
placeholder="请输入邮箱地址"
|
||||||
placeholderTextColor={colors.text.hint}
|
placeholderTextColor={colors.text.hint}
|
||||||
value={email}
|
value={email}
|
||||||
onChangeText={setEmail}
|
onChangeText={setEmail}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
keyboardType="email-address"
|
keyboardType="email-address"
|
||||||
/>
|
/>
|
||||||
|
{email.length > 0 && validateEmail(email) && (
|
||||||
|
<MaterialCommunityIcons name="check-circle" size={20} color={THEME_COLORS.primary} style={styles.checkIcon} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{/* 验证码 */}
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<Text style={styles.inputLabel}>验证码</Text>
|
||||||
<View style={styles.codeRow}>
|
<View style={styles.codeRow}>
|
||||||
<View style={[styles.inputWrapper, styles.codeInput]}>
|
<View style={[styles.inputWrapper, styles.codeInputWrapper]}>
|
||||||
<MaterialCommunityIcons name="shield-key-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
|
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="验证码"
|
placeholder="6位验证码"
|
||||||
placeholderTextColor={colors.text.hint}
|
placeholderTextColor={colors.text.hint}
|
||||||
value={verificationCode}
|
value={verificationCode}
|
||||||
onChangeText={setVerificationCode}
|
onChangeText={setVerificationCode}
|
||||||
@@ -245,64 +222,242 @@ export const ForgotPasswordScreen: React.FC = () => {
|
|||||||
style={[styles.sendCodeButton, (sendingCode || countdown > 0) && styles.sendCodeButtonDisabled]}
|
style={[styles.sendCodeButton, (sendingCode || countdown > 0) && styles.sendCodeButtonDisabled]}
|
||||||
onPress={handleSendCode}
|
onPress={handleSendCode}
|
||||||
disabled={sendingCode || countdown > 0}
|
disabled={sendingCode || countdown > 0}
|
||||||
|
activeOpacity={0.8}
|
||||||
>
|
>
|
||||||
{sendingCode ? (
|
{sendingCode ? (
|
||||||
<ActivityIndicator size="small" color="#fff" />
|
<ActivityIndicator size="small" color={THEME_COLORS.primary} />
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.sendCodeButtonText}>{countdown > 0 ? `${countdown}s` : '发送验证码'}</Text>
|
<Text style={styles.sendCodeButtonText}>{countdown > 0 ? `${countdown}s` : '获取验证码'}</Text>
|
||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 新密码输入框 */}
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<Text style={styles.inputLabel}>新密码</Text>
|
||||||
<View style={styles.inputWrapper}>
|
<View style={styles.inputWrapper}>
|
||||||
<MaterialCommunityIcons name="lock-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
|
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="新密码(至少6位)"
|
placeholder="至少6位"
|
||||||
placeholderTextColor={colors.text.hint}
|
placeholderTextColor={colors.text.hint}
|
||||||
value={newPassword}
|
value={newPassword}
|
||||||
onChangeText={setNewPassword}
|
onChangeText={setNewPassword}
|
||||||
secureTextEntry={!showPassword}
|
secureTextEntry={!showPassword}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity onPress={() => setShowPassword((v) => !v)}>
|
<TouchableOpacity
|
||||||
<MaterialCommunityIcons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={20} color={colors.text.hint} />
|
onPress={() => setShowPassword((v) => !v)}
|
||||||
|
style={styles.eyeButton}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
|
||||||
|
size={20}
|
||||||
|
color={colors.text.hint}
|
||||||
|
/>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 确认密码输入框 */}
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<Text style={styles.inputLabel}>确认密码</Text>
|
||||||
<View style={styles.inputWrapper}>
|
<View style={styles.inputWrapper}>
|
||||||
<MaterialCommunityIcons name="lock-check-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
|
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="确认新密码"
|
placeholder="再次输入新密码"
|
||||||
placeholderTextColor={colors.text.hint}
|
placeholderTextColor={colors.text.hint}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChangeText={setConfirmPassword}
|
onChangeText={setConfirmPassword}
|
||||||
secureTextEntry={!showConfirmPassword}
|
secureTextEntry={!showConfirmPassword}
|
||||||
autoCapitalize="none"
|
autoCapitalize="none"
|
||||||
|
returnKeyType="done"
|
||||||
|
onSubmitEditing={handleResetPassword}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowConfirmPassword((v) => !v)}
|
||||||
|
style={styles.eyeButton}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name={showConfirmPassword ? 'eye-off-outline' : 'eye-outline'}
|
||||||
|
size={20}
|
||||||
|
color={colors.text.hint}
|
||||||
/>
|
/>
|
||||||
<TouchableOpacity onPress={() => setShowConfirmPassword((v) => !v)}>
|
|
||||||
<MaterialCommunityIcons name={showConfirmPassword ? 'eye-off-outline' : 'eye-outline'} size={20} color={colors.text.hint} />
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 重置密码按钮 */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.submitButton, loading && styles.submitButtonDisabled]}
|
style={[styles.submitButton, loading && styles.submitButtonDisabled]}
|
||||||
onPress={handleResetPassword}
|
onPress={handleResetPassword}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
|
activeOpacity={0.9}
|
||||||
>
|
>
|
||||||
{loading ? <ActivityIndicator size="small" color="#fff" /> : <Text style={styles.submitButtonText}>重置密码</Text>}
|
{loading ? (
|
||||||
|
<ActivityIndicator size="small" color="#fff" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.submitButtonText}>重置密码</Text>
|
||||||
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
|
{/* 返回登录 */}
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.backButton}
|
||||||
|
onPress={() => router.back()}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons name="arrow-left" size={18} color={THEME_COLORS.primary} style={{ marginRight: 4 }} />
|
||||||
<Text style={styles.backButtonText}>返回登录</Text>
|
<Text style={styles.backButtonText}>返回登录</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
</Animated.View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</LinearGradient>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function createForgotPasswordStyles(colors: AppColors) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
},
|
||||||
|
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: THEME_COLORS.primary,
|
||||||
|
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: '#F5F5F7',
|
||||||
|
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: 'rgba(255, 107, 53, 0.1)',
|
||||||
|
borderRadius: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minWidth: 100,
|
||||||
|
},
|
||||||
|
sendCodeButtonDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
sendCodeButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: THEME_COLORS.primary,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 提交按钮
|
||||||
|
submitButton: {
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: THEME_COLORS.primary,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
submitButtonDisabled: {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
submitButtonText: {
|
||||||
|
color: '#fff',
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
|
||||||
|
// 返回按钮
|
||||||
|
backButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginTop: 24,
|
||||||
|
},
|
||||||
|
backButtonText: {
|
||||||
|
color: THEME_COLORS.primary,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default ForgotPasswordScreen;
|
export default ForgotPasswordScreen;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
/**
|
/**
|
||||||
* 登录页 LoginScreen(响应式适配 - 分栏布局)
|
* 登录页 LoginScreen(简洁表单样式)
|
||||||
* 胡萝卜BBS - 用户登录
|
* 胡萝卜BBS - 用户登录
|
||||||
*
|
*
|
||||||
* 布局规则:
|
* 设计风格:
|
||||||
* - 屏幕宽度 < 768px:单栏布局,橙色渐变背景
|
* - 纯白背景,扁平化设计
|
||||||
* - 屏幕宽度 >= 768px:分栏布局,左侧橙色右侧中性灰
|
* - 增大间距,避免页面太空
|
||||||
|
* - 输入框带灰色背景填充
|
||||||
|
* - 橙色圆角按钮
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
import React, { useState, useEffect, useRef, useMemo } from 'react';
|
||||||
@@ -24,15 +26,18 @@ import {
|
|||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { useAppColors, type AppColors } from '../../theme';
|
||||||
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
|
|
||||||
import { useAuthStore } from '../../stores';
|
import { useAuthStore } from '../../stores';
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
import { useResponsive, useResponsiveValue } from '../../hooks';
|
import { useResponsive } from '../../hooks';
|
||||||
import { showPrompt } from '../../services/promptService';
|
import { showPrompt } from '../../services/promptService';
|
||||||
|
|
||||||
// 分栏布局断点
|
// 胡萝卜橙主题色
|
||||||
const SPLIT_BREAKPOINT = 768;
|
const THEME_COLORS = {
|
||||||
|
primary: '#FF6B35',
|
||||||
|
primaryLight: '#FF8C5A',
|
||||||
|
primaryDark: '#E55A2B',
|
||||||
|
};
|
||||||
|
|
||||||
export const LoginScreen: React.FC = () => {
|
export const LoginScreen: React.FC = () => {
|
||||||
const colors = useAppColors();
|
const colors = useAppColors();
|
||||||
@@ -40,67 +45,30 @@ export const LoginScreen: React.FC = () => {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const login = useAuthStore((state) => state.login);
|
const login = useAuthStore((state) => state.login);
|
||||||
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
|
||||||
const storeError = useAuthStore((state) => state.error);
|
|
||||||
const setStoreError = useAuthStore((state) => state.setError);
|
const setStoreError = useAuthStore((state) => state.setError);
|
||||||
|
|
||||||
// 响应式布局
|
const { isLandscape } = useResponsive();
|
||||||
const { isLandscape, width } = useResponsive();
|
|
||||||
|
|
||||||
// 是否使用分栏布局
|
|
||||||
const isSplitLayout = width >= SPLIT_BREAKPOINT;
|
|
||||||
|
|
||||||
const [username, setUsername] = useState('');
|
const [username, setUsername] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
const [agreedToTerms, setAgreedToTerms] = useState(false);
|
||||||
|
|
||||||
// 动画值
|
// 动画值
|
||||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||||
const slideAnim = useRef(new Animated.Value(50)).current;
|
const slideAnim = useRef(new Animated.Value(20)).current;
|
||||||
const scaleAnim = useRef(new Animated.Value(0.9)).current;
|
|
||||||
const inputFadeAnim = useRef(new Animated.Value(0)).current;
|
|
||||||
|
|
||||||
// 响应式值 - 大屏模式单独放大Logo尺寸,更显大气
|
|
||||||
const logoSize = useResponsiveValue({
|
|
||||||
xs: 64, sm: 72, md: 80,
|
|
||||||
lg: isSplitLayout ? 140 : 96, // 大屏模式Logo图标进一步放大
|
|
||||||
xl: isSplitLayout ? 160 : 110
|
|
||||||
});
|
|
||||||
const logoContainerSize = useResponsiveValue({
|
|
||||||
xs: 110, sm: 120, md: 130,
|
|
||||||
lg: isSplitLayout ? 200 : 150, // 大屏模式Logo容器进一步放大
|
|
||||||
xl: isSplitLayout ? 220 : 170
|
|
||||||
});
|
|
||||||
const appNameSize = useResponsiveValue({
|
|
||||||
xs: 28, sm: 30, md: 32,
|
|
||||||
lg: isSplitLayout ? 60 : 36, // 大屏模式标题文字大幅放大
|
|
||||||
xl: isSplitLayout ? 72 : 40
|
|
||||||
});
|
|
||||||
const formMaxWidth = isSplitLayout ? 420 : undefined;
|
|
||||||
|
|
||||||
// 启动入场动画
|
// 启动入场动画
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Animated.sequence([
|
|
||||||
Animated.parallel([
|
Animated.parallel([
|
||||||
Animated.timing(fadeAnim, {
|
Animated.timing(fadeAnim, {
|
||||||
toValue: 1,
|
toValue: 1,
|
||||||
duration: 600,
|
duration: 400,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}),
|
}),
|
||||||
Animated.timing(scaleAnim, {
|
|
||||||
toValue: 1,
|
|
||||||
duration: 600,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
Animated.timing(slideAnim, {
|
Animated.timing(slideAnim, {
|
||||||
toValue: 0,
|
toValue: 0,
|
||||||
duration: 500,
|
|
||||||
useNativeDriver: true,
|
|
||||||
}),
|
|
||||||
Animated.timing(inputFadeAnim, {
|
|
||||||
toValue: 1,
|
|
||||||
duration: 400,
|
duration: 400,
|
||||||
useNativeDriver: true,
|
useNativeDriver: true,
|
||||||
}),
|
}),
|
||||||
@@ -117,20 +85,13 @@ export const LoginScreen: React.FC = () => {
|
|||||||
showPrompt({ title: '提示', message: '请输入密码', type: 'warning' });
|
showPrompt({ title: '提示', message: '请输入密码', type: 'warning' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (password.length < 6) {
|
if (!agreedToTerms) {
|
||||||
showPrompt({ title: '提示', message: '密码长度不能少于6位', type: 'warning' });
|
showPrompt({ title: '提示', message: '请同意用户协议和隐私政策', type: 'warning' });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
// store error 同步到本地
|
|
||||||
useEffect(() => {
|
|
||||||
if (storeError) {
|
|
||||||
setErrorMsg(storeError);
|
|
||||||
}
|
|
||||||
}, [storeError]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
router.replace(hrefs.hrefHome());
|
router.replace(hrefs.hrefHome());
|
||||||
@@ -138,7 +99,6 @@ export const LoginScreen: React.FC = () => {
|
|||||||
}, [isAuthenticated, router]);
|
}, [isAuthenticated, router]);
|
||||||
|
|
||||||
const clearError = () => {
|
const clearError = () => {
|
||||||
setErrorMsg(null);
|
|
||||||
setStoreError(null);
|
setStoreError(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -152,11 +112,11 @@ export const LoginScreen: React.FC = () => {
|
|||||||
const success = await login({ username, password });
|
const success = await login({ username, password });
|
||||||
if (!success) {
|
if (!success) {
|
||||||
if (!useAuthStore.getState().error) {
|
if (!useAuthStore.getState().error) {
|
||||||
setErrorMsg('登录失败,请稍后重试');
|
showPrompt({ title: '登录失败', message: '请检查用户名和密码', type: 'error' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setErrorMsg(error.message || '登录失败,请检查用户名和密码');
|
showPrompt({ title: '登录失败', message: error.message || '请检查用户名和密码', type: 'error' });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -167,86 +127,46 @@ export const LoginScreen: React.FC = () => {
|
|||||||
router.push(hrefs.hrefAuthRegister());
|
router.push(hrefs.hrefAuthRegister());
|
||||||
};
|
};
|
||||||
|
|
||||||
// 渲染左侧面板(Logo和品牌信息)- 优化大屏布局
|
return (
|
||||||
const renderLeftPanel = () => (
|
<SafeAreaView style={styles.container}>
|
||||||
|
<StatusBar barStyle="dark-content" backgroundColor="#fff" />
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
style={styles.keyboardView}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={[
|
||||||
|
styles.scrollContent,
|
||||||
|
isLandscape && styles.scrollContentLandscape,
|
||||||
|
]}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
<Animated.View
|
<Animated.View
|
||||||
style={[
|
style={[
|
||||||
styles.leftPanel,
|
styles.content,
|
||||||
isSplitLayout && styles.splitLeftPanel, // 大屏模式专用布局
|
|
||||||
{
|
{
|
||||||
opacity: fadeAnim,
|
opacity: fadeAnim,
|
||||||
transform: [{ scale: scaleAnim }],
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
{/* 胡萝卜图标 - 靠上显示 */}
|
|
||||||
<View style={[
|
|
||||||
styles.logoContainer,
|
|
||||||
styles.logoContainerTop,
|
|
||||||
isSplitLayout && styles.splitLogoContainer, // 大屏模式Logo容器样式
|
|
||||||
{ width: logoContainerSize, height: logoContainerSize, borderRadius: logoContainerSize / 2 }
|
|
||||||
]}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="carrot"
|
|
||||||
size={logoSize}
|
|
||||||
color="#FFF"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
{/* 品牌名称 - 优化大屏排版 */}
|
|
||||||
<Text style={[styles.appName, isSplitLayout && styles.splitAppName]}>萝卜社区</Text>
|
|
||||||
<Text style={[styles.subtitle, isSplitLayout && styles.splitSubtitle]}>发现有趣的内容,结识志同道合的朋友</Text>
|
|
||||||
|
|
||||||
{/* 装饰元素 - 大屏模式改为水平排列 + 大幅放大 */}
|
|
||||||
<View style={[styles.leftPanelDecorBottom, isSplitLayout && styles.splitLeftPanelDecorBottom]}>
|
|
||||||
{isSplitLayout ? (
|
|
||||||
// 大屏模式:水平排列 + 大幅放大图标
|
|
||||||
<View style={styles.splitDecorRow}>
|
|
||||||
<MaterialCommunityIcons name="chat-outline" size={80} color="rgba(255,255,255,0.4)" style={styles.splitDecorIcon} />
|
|
||||||
<MaterialCommunityIcons name="account-group-outline" size={90} color="rgba(255,255,255,0.45)" style={styles.splitDecorIcon} />
|
|
||||||
<MaterialCommunityIcons name="bell-outline" size={85} color="rgba(255,255,255,0.35)" style={styles.splitDecorIcon} />
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
// 小屏模式:保持原有横向排列
|
|
||||||
<View style={styles.decorRow}>
|
|
||||||
<MaterialCommunityIcons name="chat-outline" size={48} color="rgba(255,255,255,0.35)" />
|
|
||||||
<MaterialCommunityIcons name="account-group-outline" size={44} color="rgba(255,255,255,0.3)" />
|
|
||||||
<MaterialCommunityIcons name="bell-outline" size={40} color="rgba(255,255,255,0.25)" />
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</Animated.View>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 渲染表单内容
|
|
||||||
const renderFormContent = () => (
|
|
||||||
<View style={isSplitLayout ? styles.splitFormWrapper : styles.singleFormWrapper}>
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
styles.formCard,
|
|
||||||
// 核心修改:大屏模式使用专用的阴影样式,小屏用原有阴影
|
|
||||||
isSplitLayout ? styles.splitFormCardShadow : styles.normalFormCardShadow,
|
|
||||||
formMaxWidth && { maxWidth: formMaxWidth, width: '100%' },
|
|
||||||
{
|
|
||||||
opacity: inputFadeAnim,
|
|
||||||
transform: [{ translateY: slideAnim }],
|
transform: [{ translateY: slideAnim }],
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Text style={styles.formTitle}>欢迎回来</Text>
|
{/* 标题区域 - 增大间距 */}
|
||||||
|
<View style={styles.titleSection}>
|
||||||
|
<Text style={styles.title}>欢迎回来</Text>
|
||||||
|
<View style={styles.underline} />
|
||||||
|
<Text style={styles.subtitle}>请登录您的账号</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* 表单区域 */}
|
||||||
|
<View style={styles.formSection}>
|
||||||
{/* 用户名输入框 */}
|
{/* 用户名输入框 */}
|
||||||
<View style={styles.inputContainer}>
|
<View style={styles.inputContainer}>
|
||||||
|
<Text style={styles.inputLabel}>用户名</Text>
|
||||||
<View style={styles.inputWrapper}>
|
<View style={styles.inputWrapper}>
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="account-outline"
|
|
||||||
size={22}
|
|
||||||
color={colors.primary.main}
|
|
||||||
style={styles.inputIcon}
|
|
||||||
/>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="用户名 / 邮箱 / 手机号"
|
placeholder="请输入用户名"
|
||||||
placeholderTextColor={colors.text.hint}
|
placeholderTextColor={colors.text.hint}
|
||||||
value={username}
|
value={username}
|
||||||
onChangeText={(v) => { setUsername(v); clearError(); }}
|
onChangeText={(v) => { setUsername(v); clearError(); }}
|
||||||
@@ -255,29 +175,15 @@ export const LoginScreen: React.FC = () => {
|
|||||||
returnKeyType="next"
|
returnKeyType="next"
|
||||||
/>
|
/>
|
||||||
{username.length > 0 && (
|
{username.length > 0 && (
|
||||||
<TouchableOpacity
|
<MaterialCommunityIcons name="check-circle" size={20} color={THEME_COLORS.primary} style={styles.checkIcon} />
|
||||||
onPress={() => setUsername('')}
|
|
||||||
style={styles.clearButton}
|
|
||||||
>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="close-circle"
|
|
||||||
size={20}
|
|
||||||
color={colors.text.hint}
|
|
||||||
/>
|
|
||||||
</TouchableOpacity>
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 密码输入框 */}
|
{/* 密码输入框 */}
|
||||||
<View style={styles.inputContainer}>
|
<View style={styles.inputContainer}>
|
||||||
|
<Text style={styles.inputLabel}>密码</Text>
|
||||||
<View style={styles.inputWrapper}>
|
<View style={styles.inputWrapper}>
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="lock-outline"
|
|
||||||
size={22}
|
|
||||||
color={colors.primary.main}
|
|
||||||
style={styles.inputIcon}
|
|
||||||
/>
|
|
||||||
<TextInput
|
<TextInput
|
||||||
style={styles.input}
|
style={styles.input}
|
||||||
placeholder="请输入密码"
|
placeholder="请输入密码"
|
||||||
@@ -291,7 +197,7 @@ export const LoginScreen: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
onPress={() => setShowPassword(!showPassword)}
|
onPress={() => setShowPassword(!showPassword)}
|
||||||
style={styles.clearButton}
|
style={styles.eyeButton}
|
||||||
>
|
>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
|
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
|
||||||
@@ -310,343 +216,171 @@ export const LoginScreen: React.FC = () => {
|
|||||||
<Text style={styles.forgotPasswordText}>忘记密码?</Text>
|
<Text style={styles.forgotPasswordText}>忘记密码?</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
{/* 内联错误提示 */}
|
{/* 服务条款勾选 */}
|
||||||
{errorMsg && (
|
<View style={styles.termsContainer}>
|
||||||
<View style={styles.errorBox}>
|
<TouchableOpacity
|
||||||
|
onPress={() => setAgreedToTerms(!agreedToTerms)}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
style={styles.checkboxWrapper}
|
||||||
|
>
|
||||||
<MaterialCommunityIcons
|
<MaterialCommunityIcons
|
||||||
name="alert-circle-outline"
|
name={agreedToTerms ? 'checkbox-marked' : 'checkbox-blank-outline'}
|
||||||
size={16}
|
size={22}
|
||||||
color={colors.error.dark}
|
color={agreedToTerms ? THEME_COLORS.primary : colors.text.hint}
|
||||||
style={{ marginRight: 6, marginTop: 1 }}
|
|
||||||
/>
|
/>
|
||||||
<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>
|
</View>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 登录按钮 */}
|
{/* 登录按钮 */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={[styles.loginButton, loading && styles.loginButtonDisabled]}
|
style={[styles.loginButton, loading && styles.loginButtonDisabled]}
|
||||||
onPress={handleLogin}
|
onPress={handleLogin}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.9}
|
||||||
>
|
|
||||||
<LinearGradient
|
|
||||||
colors={[colors.primary.main, colors.primary.light]}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 0 }}
|
|
||||||
style={styles.loginButtonGradient}
|
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ActivityIndicator size="small" color="#FFF" />
|
<ActivityIndicator size="small" color="#FFF" />
|
||||||
) : (
|
) : (
|
||||||
<Text style={styles.loginButtonText}>登 录</Text>
|
<Text style={styles.loginButtonText}>登 录</Text>
|
||||||
)}
|
)}
|
||||||
</LinearGradient>
|
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</Animated.View>
|
</View>
|
||||||
|
|
||||||
{/* 底部注册提示 */}
|
{/* 底部注册提示 */}
|
||||||
<Animated.View
|
<View style={styles.footer}>
|
||||||
style={[
|
|
||||||
styles.footerSection,
|
|
||||||
{ opacity: inputFadeAnim },
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Text style={styles.footerText}>还没有账号?</Text>
|
<Text style={styles.footerText}>还没有账号?</Text>
|
||||||
<TouchableOpacity onPress={handleGoToRegister}>
|
<TouchableOpacity onPress={handleGoToRegister}>
|
||||||
<Text style={styles.registerLink}>立即注册</Text>
|
<Text style={styles.registerLink}>立即注册</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 渲染分栏布局
|
|
||||||
const renderSplitLayout = () => (
|
|
||||||
<View style={styles.splitContainer}>
|
|
||||||
{/* 左侧橙色面板 */}
|
|
||||||
<LinearGradient
|
|
||||||
colors={[colors.primary.main, colors.primary.light, '#FFB088']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 1 }}
|
|
||||||
style={styles.leftPanelContainer}
|
|
||||||
>
|
|
||||||
{/* 装饰性背景元素 */}
|
|
||||||
<View style={styles.decorCircle1} />
|
|
||||||
<View style={styles.decorCircle2} />
|
|
||||||
|
|
||||||
<StatusBar barStyle="light-content" />
|
|
||||||
<SafeAreaView style={styles.leftPanelSafeArea}>
|
|
||||||
{renderLeftPanel()}
|
|
||||||
</SafeAreaView>
|
|
||||||
</LinearGradient>
|
|
||||||
|
|
||||||
{/* 右侧中性灰面板 */}
|
|
||||||
<View style={styles.rightPanelContainer}>
|
|
||||||
<StatusBar barStyle="dark-content" />
|
|
||||||
<SafeAreaView style={styles.rightPanelSafeArea}>
|
|
||||||
{/* 核心修复:用View包裹,确保垂直+水平居中 */}
|
|
||||||
<View style={styles.splitRightContentWrapper}>
|
|
||||||
{renderFormContent()}
|
|
||||||
</View>
|
|
||||||
</SafeAreaView>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
|
|
||||||
// 渲染单栏布局
|
|
||||||
const renderSingleLayout = () => (
|
|
||||||
<SafeAreaView style={styles.container}>
|
|
||||||
<StatusBar barStyle="light-content" />
|
|
||||||
<LinearGradient
|
|
||||||
colors={[colors.primary.main, colors.primary.light, '#FFB088']}
|
|
||||||
start={{ x: 0, y: 0 }}
|
|
||||||
end={{ x: 1, y: 1 }}
|
|
||||||
style={styles.gradient}
|
|
||||||
>
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
|
||||||
style={styles.keyboardView}
|
|
||||||
>
|
|
||||||
<ScrollView
|
|
||||||
contentContainerStyle={[
|
|
||||||
styles.scrollContent,
|
|
||||||
isLandscape && styles.scrollContentLandscape,
|
|
||||||
]}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
>
|
|
||||||
{/* 装饰性背景元素 */}
|
|
||||||
<View style={styles.decorCircle1} />
|
|
||||||
<View style={styles.decorCircle2} />
|
|
||||||
|
|
||||||
{/* 头部 */}
|
|
||||||
<Animated.View
|
|
||||||
style={[
|
|
||||||
styles.headerSection,
|
|
||||||
isLandscape && styles.headerSectionLandscape,
|
|
||||||
{
|
|
||||||
opacity: fadeAnim,
|
|
||||||
transform: [{ scale: scaleAnim }],
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<View style={[
|
|
||||||
styles.logoContainer,
|
|
||||||
{ width: logoContainerSize, height: logoContainerSize, borderRadius: logoContainerSize / 2 }
|
|
||||||
]}>
|
|
||||||
<MaterialCommunityIcons
|
|
||||||
name="carrot"
|
|
||||||
size={logoSize}
|
|
||||||
color="#FFF"
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
<Text style={[styles.appName, { fontSize: appNameSize }]}>萝卜社区</Text>
|
|
||||||
<Text style={styles.subtitle}>发现有趣的内容,结识志同道合的朋友</Text>
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
{/* 表单 */}
|
|
||||||
{renderFormContent()}
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
</LinearGradient>
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
|
|
||||||
return isSplitLayout ? renderSplitLayout() : renderSingleLayout();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function createLoginStyles(colors: AppColors) {
|
function createLoginStyles(colors: AppColors) {
|
||||||
return StyleSheet.create({
|
return StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
backgroundColor: '#fff',
|
||||||
gradient: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
},
|
||||||
keyboardView: {
|
keyboardView: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
// 单栏布局内容
|
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
paddingHorizontal: spacing.xl,
|
paddingHorizontal: 28,
|
||||||
paddingVertical: spacing['2xl'],
|
paddingTop: 60,
|
||||||
},
|
paddingBottom: 40,
|
||||||
scrollContentCentered: {
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
flex: 1,
|
|
||||||
},
|
},
|
||||||
scrollContentLandscape: {
|
scrollContentLandscape: {
|
||||||
paddingVertical: spacing.lg,
|
paddingTop: 30,
|
||||||
},
|
},
|
||||||
// 装饰性背景元素
|
content: {
|
||||||
decorCircle1: {
|
flex: 1,
|
||||||
position: 'absolute',
|
maxWidth: 400,
|
||||||
top: -100,
|
width: '100%',
|
||||||
right: -100,
|
alignSelf: 'center',
|
||||||
width: 300,
|
|
||||||
height: 300,
|
|
||||||
borderRadius: 150,
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
|
||||||
},
|
},
|
||||||
decorCircle2: {
|
|
||||||
position: 'absolute',
|
// 标题区域 - 增大间距
|
||||||
bottom: 100,
|
titleSection: {
|
||||||
left: -150,
|
marginBottom: 48,
|
||||||
width: 250,
|
marginTop: 20,
|
||||||
height: 250,
|
|
||||||
borderRadius: 125,
|
|
||||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
|
||||||
},
|
},
|
||||||
// 头部区域
|
title: {
|
||||||
headerSection: {
|
fontSize: 32,
|
||||||
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'],
|
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
color: colors.text.primary,
|
color: colors.text.primary,
|
||||||
marginBottom: spacing.xl,
|
lineHeight: 40,
|
||||||
textAlign: 'center',
|
|
||||||
},
|
},
|
||||||
|
underline: {
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
backgroundColor: THEME_COLORS.primary,
|
||||||
|
borderRadius: 2,
|
||||||
|
marginTop: 12,
|
||||||
|
marginBottom: 16,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 表单区域
|
||||||
|
formSection: {
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 输入框
|
||||||
inputContainer: {
|
inputContainer: {
|
||||||
marginBottom: spacing.lg,
|
marginBottom: 24,
|
||||||
|
},
|
||||||
|
inputLabel: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: colors.text.secondary,
|
||||||
|
marginBottom: 10,
|
||||||
},
|
},
|
||||||
inputWrapper: {
|
inputWrapper: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: colors.background.default,
|
backgroundColor: '#F5F5F7',
|
||||||
borderRadius: borderRadius.lg,
|
borderRadius: 14,
|
||||||
borderWidth: 1.5,
|
paddingHorizontal: 18,
|
||||||
borderColor: colors.divider,
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
height: 56,
|
height: 56,
|
||||||
},
|
},
|
||||||
inputIcon: {
|
|
||||||
marginRight: spacing.sm,
|
|
||||||
},
|
|
||||||
input: {
|
input: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: fontSizes.md,
|
fontSize: 16,
|
||||||
color: colors.text.primary,
|
color: colors.text.primary,
|
||||||
|
height: 56,
|
||||||
},
|
},
|
||||||
clearButton: {
|
checkIcon: {
|
||||||
padding: spacing.xs,
|
marginLeft: 8,
|
||||||
},
|
},
|
||||||
|
eyeButton: {
|
||||||
|
padding: 4,
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
|
||||||
|
// 忘记密码
|
||||||
forgotPassword: {
|
forgotPassword: {
|
||||||
alignSelf: 'flex-end',
|
alignSelf: 'flex-end',
|
||||||
marginBottom: spacing.md,
|
marginBottom: 28,
|
||||||
|
marginTop: -8,
|
||||||
},
|
},
|
||||||
forgotPasswordText: {
|
forgotPasswordText: {
|
||||||
fontSize: fontSizes.sm,
|
fontSize: 14,
|
||||||
color: colors.primary.main,
|
color: THEME_COLORS.primary,
|
||||||
fontWeight: '600',
|
fontWeight: '500',
|
||||||
},
|
|
||||||
errorBox: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'flex-start',
|
|
||||||
backgroundColor: colors.accent.light,
|
|
||||||
borderRadius: borderRadius.md,
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
paddingVertical: spacing.sm,
|
|
||||||
marginBottom: spacing.md,
|
|
||||||
borderLeftWidth: 3,
|
|
||||||
borderLeftColor: colors.accent.main,
|
|
||||||
},
|
|
||||||
errorText: {
|
|
||||||
flex: 1,
|
|
||||||
fontSize: fontSizes.sm,
|
|
||||||
color: colors.accent.dark,
|
|
||||||
lineHeight: 20,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 登录按钮
|
||||||
loginButton: {
|
loginButton: {
|
||||||
borderRadius: borderRadius.lg,
|
height: 56,
|
||||||
overflow: 'hidden',
|
borderRadius: 14,
|
||||||
...shadows.md,
|
backgroundColor: THEME_COLORS.primary,
|
||||||
},
|
|
||||||
loginButtonGradient: {
|
|
||||||
height: 52,
|
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
@@ -654,116 +388,59 @@ function createLoginStyles(colors: AppColors) {
|
|||||||
opacity: 0.7,
|
opacity: 0.7,
|
||||||
},
|
},
|
||||||
loginButtonText: {
|
loginButtonText: {
|
||||||
fontSize: fontSizes.lg,
|
fontSize: 17,
|
||||||
fontWeight: '700',
|
fontWeight: '600',
|
||||||
color: '#FFFFFF',
|
color: '#FFFFFF',
|
||||||
},
|
},
|
||||||
// 底部区域
|
|
||||||
footerSection: {
|
// 底部
|
||||||
|
footer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
paddingTop: spacing.xl,
|
marginTop: 40,
|
||||||
},
|
},
|
||||||
footerText: {
|
footerText: {
|
||||||
fontSize: fontSizes.md,
|
fontSize: 15,
|
||||||
color: colors.text.primary,
|
color: colors.text.secondary,
|
||||||
},
|
},
|
||||||
registerLink: {
|
registerLink: {
|
||||||
fontSize: fontSizes.md,
|
fontSize: 15,
|
||||||
color: colors.primary.main,
|
color: THEME_COLORS.primary,
|
||||||
fontWeight: '700',
|
fontWeight: '600',
|
||||||
marginLeft: spacing.xs,
|
marginLeft: 6,
|
||||||
textDecorationLine: 'underline',
|
|
||||||
},
|
},
|
||||||
// ========== 分栏布局样式 ==========
|
policyFooter: {
|
||||||
splitContainer: {
|
alignItems: 'center',
|
||||||
flex: 1,
|
marginTop: 24,
|
||||||
|
},
|
||||||
|
policyText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.text.hint,
|
||||||
|
},
|
||||||
|
policyLink: {
|
||||||
|
color: THEME_COLORS.primary,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
termsContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
marginBottom: 20,
|
||||||
|
marginTop: 4,
|
||||||
},
|
},
|
||||||
leftPanelContainer: {
|
checkboxWrapper: {
|
||||||
flex: 1, // 50%
|
paddingTop: 2,
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
overflow: 'hidden',
|
|
||||||
},
|
},
|
||||||
leftPanelSafeArea: {
|
termsText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.text.secondary,
|
||||||
|
marginLeft: 10,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
justifyContent: 'center',
|
lineHeight: 22,
|
||||||
alignItems: 'center',
|
|
||||||
paddingHorizontal: spacing['4xl'], // 增加内边距,不贴边
|
|
||||||
},
|
},
|
||||||
leftPanel: {
|
termsLink: {
|
||||||
alignItems: 'center',
|
color: THEME_COLORS.primary,
|
||||||
paddingHorizontal: spacing['2xl'],
|
fontWeight: '500',
|
||||||
},
|
|
||||||
// 大屏模式左侧面板布局
|
|
||||||
splitLeftPanel: {
|
|
||||||
width: '100%',
|
|
||||||
justifyContent: 'center',
|
|
||||||
paddingVertical: spacing['6xl'],
|
|
||||||
},
|
|
||||||
leftPanelDecor: {
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: spacing['4xl'],
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
leftPanelDecorBottom: {
|
|
||||||
marginTop: spacing['2xl'],
|
|
||||||
},
|
|
||||||
// 大屏模式装饰元素布局
|
|
||||||
splitLeftPanelDecorBottom: {
|
|
||||||
marginTop: spacing['6xl'],
|
|
||||||
width: '100%',
|
|
||||||
alignItems: 'center',
|
|
||||||
},
|
|
||||||
// 大屏模式装饰元素水平排列
|
|
||||||
splitDecorRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-around',
|
|
||||||
width: '100%',
|
|
||||||
gap: spacing['2xl'], // 增加图标间距
|
|
||||||
},
|
|
||||||
// 大屏模式装饰图标样式
|
|
||||||
splitDecorIcon: {
|
|
||||||
transform: [{ scale: 1.1 }],
|
|
||||||
opacity: 0.9,
|
|
||||||
},
|
|
||||||
decorRow: {
|
|
||||||
flexDirection: 'row',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'space-around',
|
|
||||||
width: '80%',
|
|
||||||
},
|
|
||||||
rightPanelContainer: {
|
|
||||||
flex: 1, // 50%
|
|
||||||
backgroundColor: colors.background.paper,
|
|
||||||
},
|
|
||||||
rightPanelSafeArea: {
|
|
||||||
flex: 1,
|
|
||||||
// 移除左右内边距,交给子容器控制
|
|
||||||
},
|
|
||||||
// 分栏布局右侧内容包裹器(核心修复)
|
|
||||||
splitRightContentWrapper: {
|
|
||||||
flex: 1,
|
|
||||||
justifyContent: 'center',
|
|
||||||
alignItems: 'center',
|
|
||||||
// 新增:左右内边距,保证表单和边缘有间距
|
|
||||||
paddingHorizontal: spacing['2xl'],
|
|
||||||
},
|
|
||||||
// 表单内容包裹器(区分大屏/小屏)
|
|
||||||
splitFormWrapper: {
|
|
||||||
// 关键修改:设置最大宽度 + 自适应宽度,实现水平居中
|
|
||||||
maxWidth: 420,
|
|
||||||
width: '100%',
|
|
||||||
},
|
|
||||||
singleFormWrapper: {
|
|
||||||
flexGrow: 1,
|
|
||||||
width: '100%',
|
|
||||||
// 移除这里的阴影,避免重复
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
192
src/screens/auth/RegisterStep1Email.tsx
Normal file
192
src/screens/auth/RegisterStep1Email.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
/**
|
||||||
|
* 注册步骤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';
|
||||||
|
|
||||||
|
const THEME_COLORS = {
|
||||||
|
primary: '#FF6B35',
|
||||||
|
primaryLight: '#FF8C5A',
|
||||||
|
primaryDark: '#E55A2B',
|
||||||
|
};
|
||||||
|
|
||||||
|
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={THEME_COLORS.primary}
|
||||||
|
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: '#F5F5F7',
|
||||||
|
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: '#FF6B35',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
actionButtonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
actionButtonText: {
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RegisterStep1Email;
|
||||||
212
src/screens/auth/RegisterStep2Verification.tsx
Normal file
212
src/screens/auth/RegisterStep2Verification.tsx
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/**
|
||||||
|
* 注册步骤2:填写验证码
|
||||||
|
* 使用 VerificationCodeInput 组件
|
||||||
|
*/
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
const THEME_COLORS = {
|
||||||
|
primary: '#FF6B35',
|
||||||
|
primaryLight: '#FF8C5A',
|
||||||
|
primaryDark: '#E55A2B',
|
||||||
|
};
|
||||||
|
|
||||||
|
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: THEME_COLORS.primary,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#FF6B35',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 320,
|
||||||
|
},
|
||||||
|
actionButtonDisabled: {
|
||||||
|
opacity: 0.6,
|
||||||
|
},
|
||||||
|
actionButtonText: {
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginTop: 20,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
backButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.text?.secondary || '#666',
|
||||||
|
marginLeft: 6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RegisterStep2Verification;
|
||||||
367
src/screens/auth/RegisterStep3Profile.tsx
Normal file
367
src/screens/auth/RegisterStep3Profile.tsx
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
/**
|
||||||
|
* 注册步骤3:设置用户信息
|
||||||
|
* 包含用户名、昵称、密码等
|
||||||
|
*/
|
||||||
|
|
||||||
|
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';
|
||||||
|
|
||||||
|
const THEME_COLORS = {
|
||||||
|
primary: '#FF6B35',
|
||||||
|
primaryLight: '#FF8C5A',
|
||||||
|
primaryDark: '#E55A2B',
|
||||||
|
};
|
||||||
|
|
||||||
|
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 ? THEME_COLORS.primary : 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: '#F5F5F7',
|
||||||
|
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: '#FF6B35',
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
actionButton: {
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 14,
|
||||||
|
backgroundColor: '#FF6B35',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
actionButtonDisabled: {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
actionButtonText: {
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
marginTop: 16,
|
||||||
|
paddingVertical: 8,
|
||||||
|
},
|
||||||
|
backButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: colors.text?.secondary || '#666',
|
||||||
|
marginLeft: 6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RegisterStep3Profile;
|
||||||
451
src/screens/auth/VerificationFormScreen.tsx
Normal file
451
src/screens/auth/VerificationFormScreen.tsx
Normal file
@@ -0,0 +1,451 @@
|
|||||||
|
/**
|
||||||
|
* 身份认证申请表单页面
|
||||||
|
* 用户填写认证信息并提交申请
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 THEME_COLORS = {
|
||||||
|
primary: '#FF6B35',
|
||||||
|
primaryLight: '#FF8C5A',
|
||||||
|
primaryDark: '#E55A2B',
|
||||||
|
};
|
||||||
|
|
||||||
|
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={THEME_COLORS.primary}
|
||||||
|
/>
|
||||||
|
<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="#FFF" />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.submitButtonText}>提交认证申请</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</ScrollView>
|
||||||
|
</SafeAreaView>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
function createStyles(colors: AppColors) {
|
||||||
|
return StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#fff',
|
||||||
|
},
|
||||||
|
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: '#FFF5F0',
|
||||||
|
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: THEME_COLORS.primary,
|
||||||
|
},
|
||||||
|
hintText: {
|
||||||
|
fontSize: 13,
|
||||||
|
color: colors.text?.hint || '#999',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
inputWrapper: {
|
||||||
|
backgroundColor: '#F5F5F7',
|
||||||
|
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: '#F5F5F7',
|
||||||
|
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: THEME_COLORS.primary,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
submitButtonDisabled: {
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
submitButtonText: {
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default VerificationFormScreen;
|
||||||
335
src/screens/auth/VerificationGuideScreen.tsx
Normal file
335
src/screens/auth/VerificationGuideScreen.tsx
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
/**
|
||||||
|
* 注册后身份认证引导页面
|
||||||
|
* 可跳过的认证界面,引导用户完成身份认证
|
||||||
|
*/
|
||||||
|
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
TouchableOpacity,
|
||||||
|
ScrollView,
|
||||||
|
Image,
|
||||||
|
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 THEME_COLORS = {
|
||||||
|
primary: '#FF6B35',
|
||||||
|
primaryLight: '#FF8C5A',
|
||||||
|
primaryDark: '#E55A2B',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 身份类型选项
|
||||||
|
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 ? THEME_COLORS.primary : 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={THEME_COLORS.primary}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</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={THEME_COLORS.primary}
|
||||||
|
/>
|
||||||
|
</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={THEME_COLORS.primary}
|
||||||
|
/>
|
||||||
|
<Text style={styles.benefitText}>获得认证标识</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.benefitItem}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="lock-open-outline"
|
||||||
|
size={20}
|
||||||
|
color={THEME_COLORS.primary}
|
||||||
|
/>
|
||||||
|
<Text style={styles.benefitText}>解锁专属频道</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.benefitItem}>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="account-group-outline"
|
||||||
|
size={20}
|
||||||
|
color={THEME_COLORS.primary}
|
||||||
|
/>
|
||||||
|
<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: '#fff',
|
||||||
|
},
|
||||||
|
scrollContent: {
|
||||||
|
flexGrow: 1,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingTop: 40,
|
||||||
|
paddingBottom: 40,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 32,
|
||||||
|
},
|
||||||
|
iconCircle: {
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
borderRadius: 50,
|
||||||
|
backgroundColor: '#FFF5F0',
|
||||||
|
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: '#F5F5F7',
|
||||||
|
borderRadius: 14,
|
||||||
|
marginBottom: 12,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
},
|
||||||
|
identityCardSelected: {
|
||||||
|
backgroundColor: '#FFF5F0',
|
||||||
|
borderColor: THEME_COLORS.primary,
|
||||||
|
},
|
||||||
|
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: THEME_COLORS.primary,
|
||||||
|
},
|
||||||
|
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: THEME_COLORS.primary,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
continueButtonDisabled: {
|
||||||
|
backgroundColor: '#ccc',
|
||||||
|
},
|
||||||
|
continueButtonText: {
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: '#fff',
|
||||||
|
},
|
||||||
|
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;
|
||||||
@@ -6,3 +6,12 @@
|
|||||||
export { LoginScreen } from './LoginScreen';
|
export { LoginScreen } from './LoginScreen';
|
||||||
export { RegisterScreen } from './RegisterScreen';
|
export { RegisterScreen } from './RegisterScreen';
|
||||||
export { ForgotPasswordScreen } from './ForgotPasswordScreen';
|
export { ForgotPasswordScreen } from './ForgotPasswordScreen';
|
||||||
|
export { WelcomeScreen } from './WelcomeScreen';
|
||||||
|
export { VerificationGuideScreen } from './VerificationGuideScreen';
|
||||||
|
export { VerificationFormScreen } from './VerificationFormScreen';
|
||||||
|
|
||||||
|
// 注册步骤组件
|
||||||
|
export { RegisterStep1Email } from './RegisterStep1Email';
|
||||||
|
export { RegisterStep2Verification } from './RegisterStep2Verification';
|
||||||
|
export { RegisterStep3Profile } from './RegisterStep3Profile';
|
||||||
|
export type { RegisterFormData, RegisterStepProps, RegisterStep } from './types';
|
||||||
|
|||||||
41
src/screens/auth/types.ts
Normal file
41
src/screens/auth/types.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/**
|
||||||
|
* 注册流程类型定义
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { AppColors } from '../../theme';
|
||||||
|
|
||||||
|
export interface RegisterFormData {
|
||||||
|
email: string;
|
||||||
|
verificationCode: string;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
phone: string;
|
||||||
|
password: string;
|
||||||
|
confirmPassword: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegisterStepProps {
|
||||||
|
formData: RegisterFormData;
|
||||||
|
updateFormData: (data: Partial<RegisterFormData>) => void;
|
||||||
|
onNext: () => void;
|
||||||
|
onPrev?: () => void;
|
||||||
|
colors?: AppColors;
|
||||||
|
// 状态
|
||||||
|
loading?: boolean;
|
||||||
|
sendingCode?: boolean;
|
||||||
|
countdown?: number;
|
||||||
|
// 回调
|
||||||
|
onSendCode?: (email: string) => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RegisterStep = 0 | 1 | 2;
|
||||||
|
|
||||||
|
export interface StepConfig {
|
||||||
|
id: RegisterStep;
|
||||||
|
title: string;
|
||||||
|
component: React.ComponentType<RegisterStepProps>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保文件被识别为模块
|
||||||
|
export {};
|
||||||
|
|
||||||
@@ -388,7 +388,7 @@ export const CreatePostScreen: React.FC<CreatePostScreenProps> = (props) => {
|
|||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
images: imageUrls,
|
images: imageUrls,
|
||||||
channel_id: selectedChannelId || undefined,
|
channel_id: selectedChannelId || undefined,
|
||||||
vote_options: validOptions,
|
vote_options: validOptions.map(opt => ({ content: opt })),
|
||||||
});
|
});
|
||||||
showPrompt({
|
showPrompt({
|
||||||
type: 'info',
|
type: 'info',
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ import {
|
|||||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
import { useRouter, useFocusEffect } from 'expo-router';
|
import { useRouter, useFocusEffect } from 'expo-router';
|
||||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
import { PagerView } from '../../components/common';
|
||||||
import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme';
|
import { useAppColors, useResolvedColorScheme, spacing, borderRadius, shadows, type AppColors } from '../../theme';
|
||||||
import { Post } from '../../types';
|
import { Post } from '../../types';
|
||||||
import { useUserStore, useHomeTabBarVisibilityStore } from '../../stores';
|
import { useUserStore, useHomeTabBarVisibilityStore, useHomeTabPressStore } from '../../stores';
|
||||||
import { useCurrentUser } from '../../stores/authStore';
|
import { useCurrentUser } from '../../stores/authStore';
|
||||||
import { channelService, postService } from '../../services';
|
import { channelService, postService } from '../../services';
|
||||||
import { PostCard, TabBar, SearchBar } from '../../components/business';
|
import { PostCard, TabBar, SearchBar } from '../../components/business';
|
||||||
@@ -42,8 +42,6 @@ import * as hrefs from '../../navigation/hrefs';
|
|||||||
const TABS = ['关注', '最新', '热门'];
|
const TABS = ['关注', '最新', '热门'];
|
||||||
const TAB_ICONS = ['account-heart-outline', 'clock-outline', 'fire'];
|
const TAB_ICONS = ['account-heart-outline', 'clock-outline', 'fire'];
|
||||||
const DEFAULT_PAGE_SIZE = 20;
|
const DEFAULT_PAGE_SIZE = 20;
|
||||||
const SWIPE_TRANSLATION_THRESHOLD = 40;
|
|
||||||
const SWIPE_COOLDOWN_MS = 300;
|
|
||||||
const MOBILE_TAB_BAR_HEIGHT = 64;
|
const MOBILE_TAB_BAR_HEIGHT = 64;
|
||||||
const MOBILE_TAB_FLOATING_MARGIN = 12;
|
const MOBILE_TAB_FLOATING_MARGIN = 12;
|
||||||
const MOBILE_FAB_GAP = 12;
|
const MOBILE_FAB_GAP = 12;
|
||||||
@@ -67,7 +65,7 @@ function createHomeStyles(colors: AppColors) {
|
|||||||
},
|
},
|
||||||
searchWrapper: {
|
searchWrapper: {
|
||||||
paddingTop: spacing.lg,
|
paddingTop: spacing.lg,
|
||||||
paddingBottom: spacing.sm,
|
paddingBottom: spacing.xs,
|
||||||
shadowColor: 'transparent',
|
shadowColor: 'transparent',
|
||||||
shadowOffset: { width: 0, height: 0 },
|
shadowOffset: { width: 0, height: 0 },
|
||||||
shadowOpacity: 0,
|
shadowOpacity: 0,
|
||||||
@@ -75,8 +73,8 @@ function createHomeStyles(colors: AppColors) {
|
|||||||
elevation: 0,
|
elevation: 0,
|
||||||
},
|
},
|
||||||
homeTabBar: {
|
homeTabBar: {
|
||||||
marginTop: spacing.xs,
|
marginTop: spacing.sm,
|
||||||
marginBottom: spacing.sm,
|
marginBottom: spacing.md,
|
||||||
},
|
},
|
||||||
viewToggleBtn: {
|
viewToggleBtn: {
|
||||||
width: 44,
|
width: 44,
|
||||||
@@ -204,17 +202,31 @@ export const HomeScreen: React.FC = () => {
|
|||||||
// 发帖弹窗状态
|
// 发帖弹窗状态
|
||||||
const [showCreatePost, setShowCreatePost] = useState(false);
|
const [showCreatePost, setShowCreatePost] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showCreatePost && Platform.OS === 'web') {
|
||||||
|
const active = (globalThis as any)?.document?.activeElement as { blur?: () => void } | undefined;
|
||||||
|
active?.blur?.();
|
||||||
|
}
|
||||||
|
}, [showCreatePost]);
|
||||||
|
|
||||||
// 用于跟踪当前页面显示的帖子 ID,以便从 store 同步状态
|
// 用于跟踪当前页面显示的帖子 ID,以便从 store 同步状态
|
||||||
const postIdsRef = useRef<Set<string>>(new Set());
|
const postIdsRef = useRef<Set<string>>(new Set());
|
||||||
const lastSwipeAtRef = useRef(0);
|
|
||||||
|
|
||||||
const isLoadingMoreRef = useRef(false);
|
const isLoadingMoreRef = useRef(false);
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const pagerRef = useRef<any>(null);
|
||||||
|
|
||||||
const setBottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.setBottomTabBarHiddenByScroll);
|
const setBottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.setBottomTabBarHiddenByScroll);
|
||||||
const bottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
|
const bottomTabBarHiddenByScroll = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
|
||||||
|
const homeTabPressCount = useHomeTabPressStore((s) => s.pressCount);
|
||||||
const homeListScrollYRef = useRef(0);
|
const homeListScrollYRef = useRef(0);
|
||||||
const tabBarIdleRestoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const tabBarIdleRestoreTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// FlatList 和 ScrollView 的 ref,用于滚动到顶部
|
||||||
|
const flatListRef = useRef<FlatList<Post> | null>(null);
|
||||||
|
const scrollViewRef = useRef<ScrollView | null>(null);
|
||||||
|
|
||||||
const clearTabBarIdleRestoreTimer = useCallback(() => {
|
const clearTabBarIdleRestoreTimer = useCallback(() => {
|
||||||
if (tabBarIdleRestoreTimerRef.current != null) {
|
if (tabBarIdleRestoreTimerRef.current != null) {
|
||||||
clearTimeout(tabBarIdleRestoreTimerRef.current);
|
clearTimeout(tabBarIdleRestoreTimerRef.current);
|
||||||
@@ -269,6 +281,19 @@ export const HomeScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [showSearch, clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll]);
|
}, [showSearch, clearTabBarIdleRestoreTimer, setBottomTabBarHiddenByScroll]);
|
||||||
|
|
||||||
|
// 监听首页 Tab 点击事件,滚动到顶部
|
||||||
|
useEffect(() => {
|
||||||
|
if (homeTabPressCount > 0) {
|
||||||
|
// 滚动 FlatList 到顶部
|
||||||
|
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||||
|
// 滚动 ScrollView 到顶部(网格模式)
|
||||||
|
scrollViewRef.current?.scrollTo({ y: 0, animated: true });
|
||||||
|
// 重置底部 Tab 栏状态
|
||||||
|
setBottomTabBarHiddenByScroll(false);
|
||||||
|
homeListScrollYRef.current = 0;
|
||||||
|
}
|
||||||
|
}, [homeTabPressCount, setBottomTabBarHiddenByScroll]);
|
||||||
|
|
||||||
/** 横向胶囊条滚动位置:切换频道刷新列表时不重置 */
|
/** 横向胶囊条滚动位置:切换频道刷新列表时不重置 */
|
||||||
const capsuleHScrollRef = useRef<ScrollView | null>(null);
|
const capsuleHScrollRef = useRef<ScrollView | null>(null);
|
||||||
const capsuleScrollXRef = useRef(0);
|
const capsuleScrollXRef = useRef(0);
|
||||||
@@ -480,12 +505,22 @@ export const HomeScreen: React.FC = () => {
|
|||||||
setViewMode(prev => prev === 'list' ? 'grid' : 'list');
|
setViewMode(prev => prev === 'list' ? 'grid' : 'list');
|
||||||
};
|
};
|
||||||
|
|
||||||
// 切换Tab(手势/点击共用)
|
// 切换Tab(点击 TabBar 或手势滑动)
|
||||||
const changeTab = useCallback((nextIndex: number) => {
|
const changeTab = useCallback((nextIndex: number) => {
|
||||||
if (nextIndex < 0 || nextIndex >= TABS.length || nextIndex === activeIndex) {
|
if (nextIndex < 0 || nextIndex >= TABS.length || nextIndex === activeIndex) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setActiveIndex(nextIndex);
|
setActiveIndex(nextIndex);
|
||||||
|
if (Platform.OS !== 'web') {
|
||||||
|
pagerRef.current?.setPage(nextIndex);
|
||||||
|
}
|
||||||
|
}, [activeIndex]);
|
||||||
|
|
||||||
|
const onPagerPageSelected = useCallback((e: { nativeEvent: { position: number } }) => {
|
||||||
|
const nextIndex = e.nativeEvent.position;
|
||||||
|
if (nextIndex !== activeIndex) {
|
||||||
|
setActiveIndex(nextIndex);
|
||||||
|
}
|
||||||
}, [activeIndex]);
|
}, [activeIndex]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -500,35 +535,6 @@ export const HomeScreen: React.FC = () => {
|
|||||||
loadChannels();
|
loadChannels();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSwipeTabChange = useCallback((translationX: number) => {
|
|
||||||
setActiveIndex(prev => (
|
|
||||||
translationX < 0
|
|
||||||
? Math.min(prev + 1, TABS.length - 1)
|
|
||||||
: Math.max(prev - 1, 0)
|
|
||||||
));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const swipeGesture = useMemo(() => (
|
|
||||||
Gesture.Pan()
|
|
||||||
.runOnJS(true)
|
|
||||||
.activeOffsetX([-15, 15])
|
|
||||||
.failOffsetY([-20, 20])
|
|
||||||
.shouldCancelWhenOutside(true)
|
|
||||||
.onEnd((event) => {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastSwipeAtRef.current < SWIPE_COOLDOWN_MS) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Math.abs(event.translationX) < SWIPE_TRANSLATION_THRESHOLD) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
lastSwipeAtRef.current = now;
|
|
||||||
handleSwipeTabChange(event.translationX);
|
|
||||||
})
|
|
||||||
), [handleSwipeTabChange]);
|
|
||||||
|
|
||||||
// 跳转到搜索页(使用内嵌模式,不再依赖导航)
|
// 跳转到搜索页(使用内嵌模式,不再依赖导航)
|
||||||
const handleSearchPress = () => {
|
const handleSearchPress = () => {
|
||||||
setShowSearch(true);
|
setShowSearch(true);
|
||||||
@@ -581,7 +587,7 @@ export const HomeScreen: React.FC = () => {
|
|||||||
} catch (shareError) {
|
} catch (shareError) {
|
||||||
console.error('上报分享次数失败:', shareError);
|
console.error('上报分享次数失败:', shareError);
|
||||||
}
|
}
|
||||||
const postUrl = `https://browser.littlelan.cn/posts/${encodeURIComponent(post.id)}`;
|
const postUrl = `https://browser.littlelan.cn/post/${encodeURIComponent(post.id)}`;
|
||||||
Clipboard.setString(postUrl);
|
Clipboard.setString(postUrl);
|
||||||
Alert.alert('已复制', '帖子链接已复制到剪贴板');
|
Alert.alert('已复制', '帖子链接已复制到剪贴板');
|
||||||
};
|
};
|
||||||
@@ -819,6 +825,7 @@ export const HomeScreen: React.FC = () => {
|
|||||||
// 移动端使用瀑布流布局(2列)
|
// 移动端使用瀑布流布局(2列)
|
||||||
return (
|
return (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
|
ref={scrollViewRef}
|
||||||
style={styles.waterfallScroll}
|
style={styles.waterfallScroll}
|
||||||
contentContainerStyle={[
|
contentContainerStyle={[
|
||||||
styles.waterfallContainer,
|
styles.waterfallContainer,
|
||||||
@@ -899,6 +906,7 @@ export const HomeScreen: React.FC = () => {
|
|||||||
// 移动端和宽屏都使用单列 FlatList,宽屏下居中显示
|
// 移动端和宽屏都使用单列 FlatList,宽屏下居中显示
|
||||||
return (
|
return (
|
||||||
<FlatList
|
<FlatList
|
||||||
|
ref={flatListRef}
|
||||||
data={displayPosts}
|
data={displayPosts}
|
||||||
renderItem={renderPostList}
|
renderItem={renderPostList}
|
||||||
keyExtractor={keyExtractor}
|
keyExtractor={keyExtractor}
|
||||||
@@ -987,32 +995,52 @@ export const HomeScreen: React.FC = () => {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 帖子列表 */}
|
{/* 帖子列表 */}
|
||||||
{isMobile ? (
|
{Platform.OS === 'web' ? (
|
||||||
// 移动端:使用 GestureDetector 支持手势切换 Tab
|
|
||||||
<GestureDetector gesture={swipeGesture}>
|
|
||||||
<View style={styles.contentContainer}>
|
<View style={styles.contentContainer}>
|
||||||
{isInitialLoading ? (
|
{isInitialLoading ? (
|
||||||
<Loading fullScreen />
|
<Loading fullScreen />
|
||||||
) : viewMode === 'list' ? (
|
) : viewMode === 'list' ? (
|
||||||
renderListContent()
|
renderListContent()
|
||||||
) : (
|
) : (
|
||||||
// 网格模式:使用响应式网格
|
|
||||||
renderResponsiveGrid()
|
renderResponsiveGrid()
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</GestureDetector>
|
|
||||||
) : (
|
) : (
|
||||||
// 桌面端/平板端:不使用 GestureDetector,避免拦截侧边栏点击
|
<PagerView
|
||||||
<View style={styles.contentContainer}>
|
ref={pagerRef}
|
||||||
{isInitialLoading ? (
|
style={styles.contentContainer}
|
||||||
|
initialPage={activeIndex}
|
||||||
|
onPageSelected={onPagerPageSelected}
|
||||||
|
overdrag
|
||||||
|
>
|
||||||
|
<View key="follow" collapsable={false} style={styles.contentContainer}>
|
||||||
|
{isInitialLoading && activeIndex === 0 ? (
|
||||||
<Loading fullScreen />
|
<Loading fullScreen />
|
||||||
) : viewMode === 'list' ? (
|
) : activeIndex === 0 ? (
|
||||||
renderListContent()
|
viewMode === 'list' ? renderListContent() : renderResponsiveGrid()
|
||||||
) : (
|
) : (
|
||||||
// 网格模式:使用响应式网格
|
<View style={styles.contentContainer} />
|
||||||
renderResponsiveGrid()
|
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
<View key="latest" collapsable={false} style={styles.contentContainer}>
|
||||||
|
{isInitialLoading && activeIndex === 1 ? (
|
||||||
|
<Loading fullScreen />
|
||||||
|
) : activeIndex === 1 ? (
|
||||||
|
viewMode === 'list' ? renderListContent() : renderResponsiveGrid()
|
||||||
|
) : (
|
||||||
|
<View style={styles.contentContainer} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View key="hot" collapsable={false} style={styles.contentContainer}>
|
||||||
|
{isInitialLoading && activeIndex === 2 ? (
|
||||||
|
<Loading fullScreen />
|
||||||
|
) : activeIndex === 2 ? (
|
||||||
|
viewMode === 'list' ? renderListContent() : renderResponsiveGrid()
|
||||||
|
) : (
|
||||||
|
<View style={styles.contentContainer} />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</PagerView>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 漂浮发帖按钮 */}
|
{/* 漂浮发帖按钮 */}
|
||||||
|
|||||||
@@ -33,13 +33,13 @@ import {
|
|||||||
useAppColors,
|
useAppColors,
|
||||||
type AppColors,
|
type AppColors,
|
||||||
} from '../../theme';
|
} from '../../theme';
|
||||||
import { Post, Comment, VoteResultDTO } from '../../types';
|
import { Post, Comment, VoteResultDTO, VoteOptionDTO } from '../../types';
|
||||||
import { useUserStore } from '../../stores';
|
import { useUserStore } from '../../stores';
|
||||||
import { useCurrentUser } from '../../stores/authStore';
|
import { useCurrentUser } from '../../stores/authStore';
|
||||||
import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services';
|
import { postService, commentService, uploadService, authService, showPrompt, voteService } from '../../services';
|
||||||
import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase';
|
import { processPostUseCase } from '../../core/usecases/ProcessPostUseCase';
|
||||||
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
import { useCursorPagination } from '../../hooks/useCursorPagination';
|
||||||
import { CommentItem, VoteCard } from '../../components/business';
|
import { CommentItem, VoteCard, ReportDialog } from '../../components/business';
|
||||||
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common';
|
import { Avatar, Button, Loading, EmptyState, Text, ImageGallery, ImageGrid, ImageGridItem, AdaptiveLayout, AppBackButton } from '../../components/common';
|
||||||
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive';
|
import { useResponsive, useResponsiveValue, useResponsiveSpacing } from '../../hooks/useResponsive';
|
||||||
import * as hrefs from '../../navigation/hrefs';
|
import * as hrefs from '../../navigation/hrefs';
|
||||||
@@ -177,6 +177,10 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
const flatListRef = useRef<FlatList>(null);
|
const flatListRef = useRef<FlatList>(null);
|
||||||
const hasRecordedView = useRef(false); // 是否已记录浏览量
|
const hasRecordedView = useRef(false); // 是否已记录浏览量
|
||||||
|
|
||||||
|
// 举报相关状态
|
||||||
|
const [reportDialogVisible, setReportDialogVisible] = useState(false);
|
||||||
|
const [reportTarget, setReportTarget] = useState<{ type: 'post' | 'comment'; id: string } | null>(null);
|
||||||
|
|
||||||
// 投票相关状态
|
// 投票相关状态
|
||||||
const [voteResult, setVoteResult] = useState<VoteResultDTO | null>(null);
|
const [voteResult, setVoteResult] = useState<VoteResultDTO | null>(null);
|
||||||
const [isVoteLoading, setIsVoteLoading] = useState(false);
|
const [isVoteLoading, setIsVoteLoading] = useState(false);
|
||||||
@@ -532,7 +536,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('上报分享次数失败:', error);
|
console.error('上报分享次数失败:', error);
|
||||||
}
|
}
|
||||||
const postUrl = `https://browser.littlelan.cn/posts/${encodeURIComponent(post.id)}`;
|
const postUrl = `https://browser.littlelan.cn/post/${encodeURIComponent(post.id)}`;
|
||||||
Clipboard.setString(postUrl);
|
Clipboard.setString(postUrl);
|
||||||
Alert.alert('已复制', '帖子链接已复制到剪贴板');
|
Alert.alert('已复制', '帖子链接已复制到剪贴板');
|
||||||
}, [post?.id]);
|
}, [post?.id]);
|
||||||
@@ -545,14 +549,14 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
const oldVoteResult = { ...voteResult };
|
const oldVoteResult = { ...voteResult };
|
||||||
|
|
||||||
// 乐观更新
|
// 乐观更新
|
||||||
setVoteResult(prev => {
|
setVoteResult((prev: VoteResultDTO | null) => {
|
||||||
if (!prev) return null;
|
if (!prev) return null;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
has_voted: true,
|
has_voted: true,
|
||||||
voted_option_id: optionId,
|
voted_option_id: optionId,
|
||||||
total_votes: prev.total_votes + 1,
|
total_votes: prev.total_votes + 1,
|
||||||
options: prev.options.map(opt =>
|
options: prev.options.map((opt: VoteOptionDTO) =>
|
||||||
opt.id === optionId
|
opt.id === optionId
|
||||||
? { ...opt, votes_count: opt.votes_count + 1 }
|
? { ...opt, votes_count: opt.votes_count + 1 }
|
||||||
: opt
|
: opt
|
||||||
@@ -587,14 +591,14 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
const oldVoteResult = { ...voteResult };
|
const oldVoteResult = { ...voteResult };
|
||||||
|
|
||||||
// 乐观更新
|
// 乐观更新
|
||||||
setVoteResult(prev => {
|
setVoteResult((prev: VoteResultDTO | null) => {
|
||||||
if (!prev) return null;
|
if (!prev) return null;
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
has_voted: false,
|
has_voted: false,
|
||||||
voted_option_id: undefined,
|
voted_option_id: undefined,
|
||||||
total_votes: Math.max(0, prev.total_votes - 1),
|
total_votes: Math.max(0, prev.total_votes - 1),
|
||||||
options: prev.options.map(opt =>
|
options: prev.options.map((opt: VoteOptionDTO) =>
|
||||||
opt.id === votedOptionId
|
opt.id === votedOptionId
|
||||||
? { ...opt, votes_count: Math.max(0, opt.votes_count - 1) }
|
? { ...opt, votes_count: Math.max(0, opt.votes_count - 1) }
|
||||||
: opt
|
: opt
|
||||||
@@ -895,61 +899,48 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 点赞评论(包括回复)
|
// 点赞评论(包括回复)- 优化版
|
||||||
const handleLikeComment = async (comment: Comment) => {
|
const handleLikeComment = async (comment: Comment) => {
|
||||||
// 先保存旧状态用于回滚
|
const { id, is_liked, likes_count } = comment;
|
||||||
const oldIsLiked = comment.is_liked;
|
|
||||||
const oldLikesCount = comment.likes_count;
|
|
||||||
|
|
||||||
// 乐观更新本地状态 - 辅助函数用于更新评论或回复
|
// 乐观更新:切换点赞状态
|
||||||
const updateCommentLike = (c: Comment): Comment => {
|
const newIsLiked = !is_liked;
|
||||||
// 如果是目标评论/回复
|
const newLikesCount = newIsLiked ? likes_count + 1 : Math.max(0, likes_count - 1);
|
||||||
if (c.id === comment.id) {
|
|
||||||
return {
|
// 递归更新评论树中的点赞状态
|
||||||
...c,
|
const updateLikeInTree = (c: Comment): Comment => {
|
||||||
is_liked: !c.is_liked,
|
if (c.id === id) {
|
||||||
likes_count: c.is_liked ? c.likes_count - 1 : c.likes_count + 1
|
return { ...c, is_liked: newIsLiked, likes_count: newLikesCount };
|
||||||
};
|
|
||||||
}
|
}
|
||||||
// 如果有回复,递归查找
|
if (c.replies?.length) {
|
||||||
if (c.replies && c.replies.length > 0) {
|
return { ...c, replies: c.replies.map(r => updateLikeInTree(r)) };
|
||||||
return {
|
|
||||||
...c,
|
|
||||||
replies: c.replies.map(r => updateCommentLike(r))
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return c;
|
return c;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新本地状态
|
setComments(prev => prev.map(c => updateLikeInTree(c)));
|
||||||
setComments(prev => prev.map(c => updateCommentLike(c)));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (oldIsLiked) {
|
const success = newIsLiked
|
||||||
await commentService.unlikeComment(comment.id);
|
? await commentService.likeComment(id)
|
||||||
} else {
|
: await commentService.unlikeComment(id);
|
||||||
await commentService.likeComment(comment.id);
|
|
||||||
|
if (!success) {
|
||||||
|
throw new Error('点赞操作失败');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('评论点赞操作失败:', error);
|
console.error('评论点赞操作失败:', error);
|
||||||
// 失败时回滚状态
|
// 回滚状态
|
||||||
const rollbackCommentLike = (c: Comment): Comment => {
|
const rollbackLikeInTree = (c: Comment): Comment => {
|
||||||
if (c.id === comment.id) {
|
if (c.id === id) {
|
||||||
return {
|
return { ...c, is_liked, likes_count };
|
||||||
...c,
|
|
||||||
is_liked: oldIsLiked,
|
|
||||||
likes_count: oldLikesCount
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (c.replies && c.replies.length > 0) {
|
if (c.replies?.length) {
|
||||||
return {
|
return { ...c, replies: c.replies.map(r => rollbackLikeInTree(r)) };
|
||||||
...c,
|
|
||||||
replies: c.replies.map(r => rollbackCommentLike(r))
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
return c;
|
return c;
|
||||||
};
|
};
|
||||||
setComments(prev => prev.map(c => rollbackCommentLike(c)));
|
setComments(prev => prev.map(c => rollbackLikeInTree(c)));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1256,6 +1247,25 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{/* 举报按钮 - 只对非帖子作者显示 */}
|
||||||
|
{currentUser?.id !== post.author?.id && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.reportButtonInline}
|
||||||
|
onPress={() => {
|
||||||
|
setReportTarget({ type: 'post', id: post.id });
|
||||||
|
setReportDialogVisible(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MaterialCommunityIcons
|
||||||
|
name="flag-outline"
|
||||||
|
size={14}
|
||||||
|
color={colors.text.hint}
|
||||||
|
/>
|
||||||
|
<Text variant="caption" color={colors.text.hint} style={styles.reportButtonText}>
|
||||||
|
举报
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* 底部操作栏 - QQ频道风格 */}
|
{/* 底部操作栏 - QQ频道风格 */}
|
||||||
@@ -1360,6 +1370,12 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 处理举报评论
|
||||||
|
const handleReportComment = useCallback((comment: Comment) => {
|
||||||
|
setReportTarget({ type: 'comment', id: comment.id });
|
||||||
|
setReportDialogVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 渲染评论 - 带楼层号
|
// 渲染评论 - 带楼层号
|
||||||
const commentKeyExtractor = useCallback((item: Comment) => item.id, []);
|
const commentKeyExtractor = useCallback((item: Comment) => item.id, []);
|
||||||
|
|
||||||
@@ -1373,7 +1389,7 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<CommentItem
|
<CommentItem
|
||||||
comment={item}
|
comment={item}
|
||||||
onLike={() => handleLikeComment(item)}
|
onLike={handleLikeComment}
|
||||||
onReply={() => handleReply(item)}
|
onReply={() => handleReply(item)}
|
||||||
onUserPress={() => authorId && handleUserPress(authorId)}
|
onUserPress={() => authorId && handleUserPress(authorId)}
|
||||||
floorNumber={index + 1}
|
floorNumber={index + 1}
|
||||||
@@ -1385,9 +1401,10 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
onDelete={handleDeleteComment}
|
onDelete={handleDeleteComment}
|
||||||
onImagePress={handleImagePress}
|
onImagePress={handleImagePress}
|
||||||
currentUserId={currentUser?.id}
|
currentUserId={currentUser?.id}
|
||||||
|
onReport={handleReportComment}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, [currentUser?.id, handleDeleteComment, handleImagePress, handleLikeComment, handleLoadMoreReplies, post?.user_id]);
|
}, [currentUser?.id, handleDeleteComment, handleImagePress, handleLikeComment, handleLoadMoreReplies, post?.user_id, handleReportComment]);
|
||||||
|
|
||||||
// 渲染空评论 - 现代化设计
|
// 渲染空评论 - 现代化设计
|
||||||
const renderEmptyComments = useCallback(() => (
|
const renderEmptyComments = useCallback(() => (
|
||||||
@@ -1709,6 +1726,20 @@ export const PostDetailScreen: React.FC = () => {
|
|||||||
onClose={() => setShowImageModal(false)}
|
onClose={() => setShowImageModal(false)}
|
||||||
enableSave
|
enableSave
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 举报对话框 */}
|
||||||
|
<ReportDialog
|
||||||
|
visible={reportDialogVisible}
|
||||||
|
targetType={reportTarget?.type || 'post'}
|
||||||
|
targetId={reportTarget?.id || ''}
|
||||||
|
onClose={() => {
|
||||||
|
setReportDialogVisible(false);
|
||||||
|
setReportTarget(null);
|
||||||
|
}}
|
||||||
|
onSuccess={() => {
|
||||||
|
setReportTarget(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1875,6 +1906,16 @@ function createPostDetailStyles(colors: AppColors) {
|
|||||||
marginLeft: 2,
|
marginLeft: 2,
|
||||||
fontSize: fontSizes.sm,
|
fontSize: fontSizes.sm,
|
||||||
},
|
},
|
||||||
|
reportButtonInline: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginLeft: spacing.sm,
|
||||||
|
padding: spacing.xs,
|
||||||
|
},
|
||||||
|
reportButtonText: {
|
||||||
|
marginLeft: 2,
|
||||||
|
fontSize: fontSizes.sm,
|
||||||
|
},
|
||||||
imagesContainer: {
|
imagesContainer: {
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user