2 Commits

Author SHA1 Message Date
2014be28c4 Merge pull request 'dev' (#5) from dev into master
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 39s
Frontend CI / build-android-apk (push) Has been cancelled
Frontend CI / ota-android (push) Has been cancelled
Reviewed-on: #5
2026-03-25 20:54:09 +08:00
cf466a3959 Merge pull request 'dev' (#3) from dev into master
All checks were successful
Frontend CI / ota-android (push) Successful in 13m19s
Frontend CI / build-android-apk (push) Successful in 56m46s
Frontend CI / build-and-push-web (push) Successful in 5m46s
Reviewed-on: #3
2026-03-19 01:00:14 +08:00
226 changed files with 10144 additions and 26503 deletions

View File

@@ -272,42 +272,6 @@ jobs:
name: carrot-bbs-android-release-apk
path: android/app/build/outputs/apk/release/app-release.apk
- name: Resolve runtime version
id: runtime
run: |
RUNTIME_VERSION="$(node -p "require('./app.json').expo.version")"
echo "runtime_version=${RUNTIME_VERSION}" >> "$GITHUB_OUTPUT"
echo "Resolved runtimeVersion: ${RUNTIME_VERSION}"
- name: Upload APK to Updates Server
env:
OTA_AUTH_TOKEN: ${{ secrets.OTA_AUTH_TOKEN }}
UPDATES_SERVER_URL: https://updates.littlelan.cn
run: |
test -n "${OTA_AUTH_TOKEN}" || (echo "Missing secret OTA_AUTH_TOKEN" && exit 1)
APK_PATH="android/app/build/outputs/apk/release/app-release.apk"
RUNTIME_VERSION="${{ steps.runtime.outputs.runtime_version }}"
echo "Uploading APK for runtime version: ${RUNTIME_VERSION}"
HTTP_CODE=$(curl -s -o response.json -w "%{http_code}" \
-X POST \
"${UPDATES_SERVER_URL}/admin/apk/upload?runtimeVersion=${RUNTIME_VERSION}" \
-H "Authorization: Bearer ${OTA_AUTH_TOKEN}" \
-H "Content-Type: application/vnd.android.package-archive" \
--data-binary @"${APK_PATH}")
echo "HTTP Response Code: ${HTTP_CODE}"
cat response.json
if [ "${HTTP_CODE}" -ne 200 ]; then
echo "Failed to upload APK"
exit 1
fi
echo "APK uploaded successfully!"
build-and-push-web:
runs-on: ubuntu-latest
permissions:

View File

@@ -10,7 +10,6 @@ RUN npx expo export --platform web --output-dir dist-web
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/dist-web /usr/share/nginx/html
EXPOSE 80

View File

@@ -23,29 +23,6 @@ const devUpdatesUrl =
const expo = appJson.expo;
// 检测是否为 Web 平台
const isWeb = process.env.EXPO_TARGET === 'web' || process.env.PLATFORM === 'web';
// 原生平台特有的插件(在 Web 端会报错)
const nativeOnlyPlugins = [
'expo-camera',
'expo-notifications',
'expo-background-fetch',
'expo-media-library',
'expo-image-picker',
'expo-video',
'expo-sqlite',
'./plugins/withMainActivityConfigChange',
];
// 过滤插件Web 端排除原生插件
const filteredPlugins = isWeb
? expo.plugins.filter((plugin) => {
const pluginName = Array.isArray(plugin) ? plugin[0] : plugin;
return !nativeOnlyPlugins.includes(pluginName);
})
: expo.plugins;
module.exports = {
...expo,
name: isDevVariant ? `${expo.name} Dev` : expo.name,
@@ -63,27 +40,10 @@ module.exports = {
...expo.android,
package: 'skin.carrot.bbs',
},
// Web 端使用过滤后的插件
plugins: filteredPlugins,
extra: {
...(expo.extra || {}),
appVariant: isDevVariant ? 'dev' : 'release',
apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl,
updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
},
// Web 端配置
...(isWeb && {
web: {
...expo.web,
build: {
...expo.web?.build,
// 配置 Web 端别名,避免加载原生模块
babel: {
dangerouslyAddModulePathsToTranspile: [
'react-native-webrtc',
],
},
},
},
}),
};

View File

@@ -2,7 +2,7 @@
"expo": {
"name": "萝卜社区",
"slug": "qojo",
"version": "1.0.13",
"version": "1.0.11",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -15,13 +15,11 @@
"ios": {
"supportsTablet": true,
"infoPlist": {
"NSMicrophoneUsageDescription": "允许萝卜社区访问您的麦克风以进行语音通话",
"UIBackgroundModes": [
"fetch",
"remote-notification",
"fetch",
"remote-notification",
"audio"
"remote-notification"
],
"ITSAppUsesNonExemptEncryption": false
},
@@ -36,10 +34,9 @@
},
"predictiveBackGestureEnabled": false,
"package": "skin.carrot.bbs",
"versionCode": 7,
"versionCode": 6,
"permissions": [
"VIBRATE",
"RECORD_AUDIO",
"RECEIVE_BOOT_COMPLETED",
"WAKE_LOCK",
"READ_EXTERNAL_STORAGE",
@@ -62,17 +59,6 @@
"favicon": "./assets/favicon.png"
},
"plugins": [
"./plugins/withMainActivityConfigChange",
[
"./plugins/withForegroundService",
{
"notificationTitle": "萝卜社区",
"notificationBody": "正在后台同步消息",
"notificationIcon": "ic_notification",
"channelId": "carrot_sync_foreground",
"channelName": "消息同步"
}
],
[
"expo-router",
{
@@ -96,9 +82,9 @@
}
],
[
"expo-background-task",
"expo-background-fetch",
{
"minimumInterval": 15
"minimumInterval": 900
}
],
[

View File

@@ -1,4 +1,4 @@
import { useMemo, useCallback } from 'react';
import { useMemo } from 'react';
import { Platform, useWindowDimensions } from 'react-native';
import { Tabs, usePathname } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
@@ -6,7 +6,7 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useAppColors, shadows } from '../../../src/theme';
import { BREAKPOINTS } from '../../../src/hooks/useResponsive';
import { useHomeTabBarVisibilityStore, useTotalUnreadCount, useHomeTabPressStore } from '../../../src/stores';
import { useHomeTabBarVisibilityStore, useTotalUnreadCount } from '../../../src/stores';
const TAB_BAR_HEIGHT = 56;
const TAB_BAR_FLOATING_MARGIN = 12;
@@ -19,17 +19,10 @@ export default function TabsLayout() {
const pathname = usePathname();
const unreadCount = useTotalUnreadCount();
const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
const triggerHomeTabPress = useHomeTabPressStore((s) => s.triggerPress);
const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop;
const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/');
const handleHomeTabPress = useCallback(() => {
if (pathname === '/home') {
triggerHomeTabPress();
}
}, [pathname, triggerHomeTabPress]);
const tabBarStyle = useMemo(() => {
if (hideTabBar) {
return { display: 'none' as const, height: 0, overflow: 'hidden' as const };
@@ -88,9 +81,6 @@ export default function TabsLayout() {
/>
),
}}
listeners={{
tabPress: handleHomeTabPress,
}}
/>
<Tabs.Screen
name="apps"

View File

@@ -1,11 +0,0 @@
import { Stack } from 'expo-router';
export default function MaterialsStackLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="subject" />
<Stack.Screen name="detail" />
</Stack>
);
}

View File

@@ -1,5 +0,0 @@
import { MaterialDetailScreen } from '../../../../../src/screens/material';
export default function MaterialDetailRoute() {
return <MaterialDetailScreen />;
}

View File

@@ -1,5 +0,0 @@
import { MaterialsScreen } from '../../../../../src/screens/material';
export default function MaterialsRoute() {
return <MaterialsScreen />;
}

View File

@@ -1,5 +0,0 @@
import { SubjectMaterialsScreen } from '../../../../../src/screens/material';
export default function SubjectMaterialsRoute() {
return <SubjectMaterialsScreen />;
}

View File

@@ -36,14 +36,6 @@ export default function ProfileStackLayout() {
<Stack.Screen name="bookmarks" options={{ title: '收藏' }} />
<Stack.Screen name="notification-settings" options={{ title: '通知设置' }} />
<Stack.Screen name="blocked-users" options={{ title: '黑名单' }} />
<Stack.Screen name="chat-settings" options={{ title: '聊天设置' }} />
<Stack.Screen name="about" options={{ title: '关于我们' }} />
<Stack.Screen name="terms" options={{ title: '用户协议' }} />
<Stack.Screen name="privacy" options={{ title: '隐私政策' }} />
<Stack.Screen name="verification" options={{ title: '身份认证' }} />
<Stack.Screen name="data-storage" options={{ title: '数据与存储' }} />
<Stack.Screen name="privacy-settings" options={{ title: '隐私设置' }} />
<Stack.Screen name="account-deletion" options={{ title: '注销账号' }} />
</Stack>
);
}

View File

@@ -1,5 +0,0 @@
import { AboutScreen } from '../../../../src/screens/profile/AboutScreen';
export default function AboutRoute() {
return <AboutScreen />;
}

View File

@@ -1,5 +0,0 @@
import { AccountDeletionScreen } from '../../../../src/screens/profile';
export default function AccountDeletionRoute() {
return <AccountDeletionScreen />;
}

View File

@@ -1,5 +0,0 @@
import { ChatSettingsScreen } from '../../../../src/screens/profile/ChatSettingsScreen';
export default function ChatSettingsRoute() {
return <ChatSettingsScreen />;
}

View File

@@ -1,5 +0,0 @@
import { DataStorageScreen } from '../../../../src/screens/profile';
export default function DataStorageRoute() {
return <DataStorageScreen />;
}

View File

@@ -1,5 +0,0 @@
import { PrivacySettingsScreen } from '../../../../src/screens/profile';
export default function PrivacySettingsRoute() {
return <PrivacySettingsScreen />;
}

View File

@@ -1,5 +0,0 @@
import { PrivacyPolicyScreen } from '../../../../src/screens/profile/PrivacyPolicyScreen';
export default function PrivacyPolicyRoute() {
return <PrivacyPolicyScreen />;
}

View File

@@ -1,5 +0,0 @@
import { TermsOfServiceScreen } from '../../../../src/screens/profile/TermsOfServiceScreen';
export default function TermsOfServiceRoute() {
return <TermsOfServiceScreen />;
}

View File

@@ -1,5 +0,0 @@
import { VerificationSettingsScreen } from '@/screens/profile/VerificationSettingsScreen';
export default function VerificationSettingsPage() {
return <VerificationSettingsScreen />;
}

View File

@@ -1,5 +0,0 @@
import { VerificationFormScreen } from '@/screens/auth/VerificationFormScreen';
export default function VerificationFormPage() {
return <VerificationFormScreen />;
}

View File

@@ -1,5 +0,0 @@
import { VerificationGuideScreen } from '@/screens/auth/VerificationGuideScreen';
export default function VerificationGuidePage() {
return <VerificationGuideScreen />;
}

View File

@@ -10,8 +10,6 @@ import * as Notifications from 'expo-notifications';
import * as SystemUI from 'expo-system-ui';
import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences';
import { api } from '../src/services/api';
import { wsService } from '../src/services/wsService';
import {
ThemeBootstrap,
useAppColors,
@@ -23,8 +21,6 @@ import AppPromptBar from '../src/components/common/AppPromptBar';
import AppDialogHost from '../src/components/common/AppDialogHost';
import { installAlertOverride } from '../src/services/alertOverride';
import { useAuthStore } from '../src/stores';
import { checkForAPKUpdate } from '../src/services/apkUpdateService';
import { CallScreen, IncomingCallModal, FloatingCallWindow } from '../src/components/call';
registerNotificationPresentationHandler();
@@ -52,25 +48,6 @@ if (Platform.OS === 'web' && typeof document !== 'undefined') {
}
*:focus { outline: none !important; }
*:focus-visible { outline: none !important; }
/* 修复移动端 FlatList/ScrollView 滑动问题 */
html, body {
overscroll-behavior: none;
touch-action: pan-y;
}
/* React Native Web 生成的滚动容器 */
[class*="css-"] {
touch-action: pan-y !important;
-webkit-overflow-scrolling: touch !important;
}
/* 确保所有可滚动元素都可以垂直滑动 */
div[style*="overflow"] {
touch-action: pan-y !important;
-webkit-overflow-scrolling: touch !important;
}
/* 禁用水平滑动,只允许垂直滑动 */
* {
touch-action: pan-y pinch-zoom;
}
`;
document.head.appendChild(style);
}
@@ -160,71 +137,21 @@ function NotificationBootstrap() {
return null;
}
function APKUpdateBootstrap() {
const hasChecked = useRef(false);
useEffect(() => {
if (hasChecked.current) return;
hasChecked.current = true;
// 延迟执行,避免与启动流程冲突
const timer = setTimeout(() => {
checkForAPKUpdate().catch((error) => {
console.error('APK update check failed:', error);
});
}, 3000);
return () => clearTimeout(timer);
}, []);
return null;
}
function ThemedStack() {
const router = useRouter();
const colors = useAppColors();
const resolved = useResolvedColorScheme();
useEffect(() => {
api.setExpoRouter(router);
wsService.setRouter(router);
}, [router]);
return (
<>
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
<SystemChrome />
<SessionGate>
<NotificationBootstrap />
<APKUpdateBootstrap />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="(app)" options={{ headerShown: false }} />
<Stack.Screen
name="terms"
options={{
headerShown: true,
title: '用户协议',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
headerShadowVisible: false,
headerBackVisible: false,
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
}}
/>
<Stack.Screen
name="privacy"
options={{
headerShown: true,
title: '隐私政策',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
headerShadowVisible: false,
headerBackVisible: false,
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
}}
/>
<Stack.Screen
name="post/[postId]"
options={{
@@ -270,9 +197,6 @@ export default function RootLayout() {
<ThemedStack />
<AppPromptBar />
<AppDialogHost />
<IncomingCallModal />
<CallScreen />
<FloatingCallWindow />
</QueryClientProvider>
</ThemedProviders>
</SafeAreaProvider>

View File

@@ -7,5 +7,5 @@ export default function Index() {
if (isAuthenticated) {
return <Redirect href="/home" />;
}
return <Redirect href="/welcome" />;
return <Redirect href="/login" />;
}

View File

@@ -1,5 +0,0 @@
import { PrivacyPolicyScreen } from '../src/screens/profile/PrivacyPolicyScreen';
export default function PrivacyRoute() {
return <PrivacyPolicyScreen />;
}

View File

@@ -1,5 +0,0 @@
import { TermsOfServiceScreen } from '../src/screens/profile/TermsOfServiceScreen';
export default function TermsRoute() {
return <TermsOfServiceScreen />;
}

View File

@@ -1,5 +0,0 @@
import { WelcomeScreen } from '../src/screens/auth';
export default function WelcomeRoute() {
return <WelcomeScreen />;
}

View File

@@ -6,10 +6,6 @@ const config = getDefaultConfig(__dirname);
// Add wasm asset support
config.resolver.assetExts.push('wasm');
// Fix for react-native-webrtc event-target-shim import warning
// The package doesn't properly export "./index" in its exports field
config.resolver.unstable_enablePackageExports = false;
// NOTE: enhanceMiddleware is marked as deprecated in Metro's types,
// but there's currently no official alternative for custom dev server headers.
// For production, configure COEP/COOP headers on your hosting platform.

View File

@@ -1,19 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
gzip_min_length 256;
}

64
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "carrot_bbs",
"version": "1.0.2",
"version": "1.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "carrot_bbs",
"version": "1.0.2",
"version": "1.0.1",
"dependencies": {
"@expo/vector-icons": "^15.1.1",
"@react-native-async-storage/async-storage": "^2.2.0",
@@ -29,7 +29,6 @@
"expo-haptics": "~55.0.8",
"expo-image": "^55.0.5",
"expo-image-picker": "^55.0.10",
"expo-intent-launcher": "~55.0.9",
"expo-linear-gradient": "^55.0.8",
"expo-media-library": "~55.0.9",
"expo-notifications": "^55.0.10",
@@ -54,7 +53,6 @@
"react-native-screens": "~4.23.0",
"react-native-sse": "^1.2.1",
"react-native-web": "^0.21.0",
"react-native-webrtc": "^124.0.7",
"react-native-webview": "13.16.0",
"react-native-worklets": "0.7.2",
"zod": "^4.3.6",
@@ -5749,15 +5747,6 @@
"expo": "*"
}
},
"node_modules/expo-intent-launcher": {
"version": "55.0.9",
"resolved": "https://registry.npmmirror.com/expo-intent-launcher/-/expo-intent-launcher-55.0.9.tgz",
"integrity": "sha512-s8k8dF8PfgzN0c+xzNTd9ceHh+/6mt2ncnMuqFaIIry2BeDGITbsgVijHr39eB5vS+KopCcOYYBYr+O1epx4TA==",
"license": "MIT",
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-json-utils": {
"version": "55.0.0",
"resolved": "https://registry.npmmirror.com/expo-json-utils/-/expo-json-utils-55.0.0.tgz",
@@ -9800,55 +9789,6 @@
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/react-native-webrtc": {
"version": "124.0.7",
"resolved": "https://registry.npmmirror.com/react-native-webrtc/-/react-native-webrtc-124.0.7.tgz",
"integrity": "sha512-gnXPdbUS8IkKHq9WNaWptW/yy5s6nMyI6cNn90LXdobPVCgYSk6NA2uUGdT4c4J14BRgaFA95F+cR28tUPkMVA==",
"license": "MIT",
"dependencies": {
"base64-js": "1.5.1",
"debug": "4.3.4",
"event-target-shim": "6.0.2"
},
"peerDependencies": {
"react-native": ">=0.60.0"
}
},
"node_modules/react-native-webrtc/node_modules/debug": {
"version": "4.3.4",
"resolved": "https://registry.npmmirror.com/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
"license": "MIT",
"dependencies": {
"ms": "2.1.2"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/react-native-webrtc/node_modules/event-target-shim": {
"version": "6.0.2",
"resolved": "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-6.0.2.tgz",
"integrity": "sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
},
"funding": {
"url": "https://github.com/sponsors/mysticatea"
}
},
"node_modules/react-native-webrtc/node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
"license": "MIT"
},
"node_modules/react-native-webview": {
"version": "13.16.0",
"resolved": "https://registry.npmmirror.com/react-native-webview/-/react-native-webview-13.16.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "carrot_bbs",
"version": "1.0.3",
"version": "1.0.1",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
@@ -25,6 +25,7 @@
"axios": "^1.13.6",
"date-fns": "^4.1.0",
"expo": "~55.0.4",
"expo-background-fetch": "~55.0.10",
"expo-background-task": "~55.0.10",
"expo-camera": "^55.0.10",
"expo-constants": "~55.0.7",
@@ -34,7 +35,6 @@
"expo-haptics": "~55.0.8",
"expo-image": "^55.0.5",
"expo-image-picker": "^55.0.10",
"expo-intent-launcher": "~55.0.9",
"expo-linear-gradient": "^55.0.8",
"expo-media-library": "~55.0.9",
"expo-notifications": "^55.0.10",
@@ -59,7 +59,6 @@
"react-native-screens": "~4.23.0",
"react-native-sse": "^1.2.1",
"react-native-web": "^0.21.0",
"react-native-webrtc": "^124.0.7",
"react-native-webview": "13.16.0",
"react-native-worklets": "0.7.2",
"zod": "^4.3.6",

View File

@@ -1,467 +0,0 @@
const { withAndroidManifest, withDangerousMod } = require('@expo/config-plugins');
const fs = require('fs');
const path = require('path');
const FOREGROUND_SERVICE_PERMISSION = 'android.permission.FOREGROUND_SERVICE';
const FOREGROUND_SERVICE_DATA_SYNC = 'android.permission.FOREGROUND_SERVICE_DATA_SYNC';
/**
* Expo Config Plugin为 Android 添加前台服务保活
*
* 功能:
* 1. 添加 FOREGROUND_SERVICE 相关权限
* 2. 在 AndroidManifest 中声明 Service
* 3. 生成 Kotlin 原生服务代码
* 4. 生成 React Native Bridge 模块
*
* 参考Element Android 的 GuardAndroidService
*/
const withForegroundService = (config, options = {}) => {
const {
notificationTitle = '萝卜社区',
notificationBody = '正在后台同步消息',
notificationIcon = 'ic_notification',
channelId = 'carrot_sync_foreground',
channelName = '消息同步',
} = options;
// 1. 添加权限和 Service 声明到 AndroidManifest
config = withAndroidManifest(config, (config) => {
const manifest = config.modResults;
// 添加权限声明
const permissions = [
FOREGROUND_SERVICE_PERMISSION,
FOREGROUND_SERVICE_DATA_SYNC,
];
permissions.forEach((permission) => {
const existingPermission = manifest.manifest['uses-permission']?.find(
(p) => p.$['android:name'] === permission
);
if (!existingPermission) {
if (!manifest.manifest['uses-permission']) {
manifest.manifest['uses-permission'] = [];
}
manifest.manifest['uses-permission'].push({
$: { 'android:name': permission },
});
}
});
// 添加 Service 声明
if (!manifest.manifest.application[0].service) {
manifest.manifest.application[0].service = [];
}
const existingService = manifest.manifest.application[0].service.find(
(s) => s.$['android:name'] === '.SyncForegroundService'
);
if (!existingService) {
manifest.manifest.application[0].service.push({
$: {
'android:name': '.SyncForegroundService',
'android:enabled': 'true',
'android:exported': 'false',
'android:foregroundServiceType': 'dataSync',
},
});
}
return config;
});
// 2. 生成 Kotlin 原生代码
config = withDangerousMod(config, [
'android',
async (config) => {
const projectRoot = config.modRequest.projectRoot;
const packageName = config.android.package;
const kotlinPath = path.join(
projectRoot,
'android/app/src/main/java',
packageName.replace(/\./g, '/')
);
// 确保目录存在
if (!fs.existsSync(kotlinPath)) {
fs.mkdirSync(kotlinPath, { recursive: true });
}
// 生成 SyncForegroundService.kt
const serviceCode = generateServiceCode(packageName, {
notificationTitle,
notificationBody,
notificationIcon,
channelId,
channelName,
});
fs.writeFileSync(
path.join(kotlinPath, 'SyncForegroundService.kt'),
serviceCode
);
// 生成 ForegroundServiceModule.kt (React Native Bridge)
const moduleCode = generateModuleCode(packageName);
fs.writeFileSync(
path.join(kotlinPath, 'ForegroundServiceModule.kt'),
moduleCode
);
// 生成 ForegroundServicePackage.kt
const packageCode = generatePackageCode(packageName);
fs.writeFileSync(
path.join(kotlinPath, 'ForegroundServicePackage.kt'),
packageCode
);
// 修改 MainApplication.kt 注册模块
const mainAppPath = path.join(kotlinPath, 'MainApplication.kt');
if (fs.existsSync(mainAppPath)) {
let content = fs.readFileSync(mainAppPath, 'utf-8');
// 添加 import
if (!content.includes('ForegroundServicePackage')) {
content = content.replace(
/package .*\n/,
`$&\nimport ${packageName}.ForegroundServicePackage\n`
);
}
// 在 packages 列表中添加
if (!content.includes('ForegroundServicePackage()')) {
// 查找 PackageList(this).packages.apply 块
content = content.replace(
/(PackageList\(this\)\.packages\.apply\s*\{)/,
`$1\n // 前台服务模块\n add(ForegroundServicePackage())`
);
}
fs.writeFileSync(mainAppPath, content);
}
// 创建通知图标目录和文件
const drawablePath = path.join(projectRoot, 'android/app/src/main/res/drawable');
if (!fs.existsSync(drawablePath)) {
fs.mkdirSync(drawablePath, { recursive: true });
}
const iconPath = path.join(drawablePath, 'ic_notification.xml');
if (!fs.existsSync(iconPath)) {
fs.writeFileSync(iconPath, generateNotificationIcon());
}
return config;
},
]);
return config;
};
/**
* 生成前台服务 Kotlin 代码
*/
function generateServiceCode(packageName, options) {
const { notificationTitle, notificationBody, notificationIcon, channelId, channelName } = options;
return `package ${packageName}
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
/**
* 前台同步服务
*
* 在通知栏显示常驻通知,防止应用进程被系统杀死
* 类似 Element Android 的 GuardAndroidService 和 V2Ray 的保活机制
*/
class SyncForegroundService : Service() {
companion object {
const val NOTIFICATION_ID = 1001
const val CHANNEL_ID = "${channelId}"
const val CHANNEL_NAME = "${channelName}"
const val ACTION_START = "${packageName}.foreground.START"
const val ACTION_STOP = "${packageName}.foreground.STOP"
const val ACTION_UPDATE = "${packageName}.foreground.UPDATE"
const val EXTRA_TITLE = "title"
const val EXTRA_BODY = "body"
/**
* 启动前台服务
*/
fun start(context: Context, title: String = "${notificationTitle}", body: String = "${notificationBody}") {
val intent = Intent(context, SyncForegroundService::class.java).apply {
action = ACTION_START
putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_BODY, body)
}
try {
ContextCompat.startForegroundService(context, intent)
} catch (e: Exception) {
android.util.Log.e("SyncForegroundService", "启动前台服务失败", e)
}
}
/**
* 停止前台服务
*/
fun stop(context: Context) {
val intent = Intent(context, SyncForegroundService::class.java).apply {
action = ACTION_STOP
}
context.startService(intent)
}
/**
* 更新通知内容
*/
fun update(context: Context, title: String, body: String) {
val intent = Intent(context, SyncForegroundService::class.java).apply {
action = ACTION_UPDATE
putExtra(EXTRA_TITLE, title)
putExtra(EXTRA_BODY, body)
}
context.startService(intent)
}
}
private var notificationTitle: String = "${notificationTitle}"
private var notificationBody: String = "${notificationBody}"
override fun onCreate() {
super.onCreate()
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_START -> {
notificationTitle = intent.getStringExtra(EXTRA_TITLE) ?: "${notificationTitle}"
notificationBody = intent.getStringExtra(EXTRA_BODY) ?: "${notificationBody}"
startForegroundCompat()
}
ACTION_STOP -> {
stopForegroundCompat()
stopSelf()
}
ACTION_UPDATE -> {
notificationTitle = intent.getStringExtra(EXTRA_TITLE) ?: notificationTitle
notificationBody = intent.getStringExtra(EXTRA_BODY) ?: notificationBody
updateNotification()
}
}
// START_STICKY: 服务被杀后自动重启
return START_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
/**
* 启动前台服务(兼容 Android Q+
*/
private fun startForegroundCompat() {
val notification = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
)
} else {
startForeground(NOTIFICATION_ID, notification)
}
}
/**
* 停止前台服务(兼容 Android N+
*/
private fun stopForegroundCompat() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
}
/**
* 创建通知渠道
*/
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_MIN // 最低重要性,不打扰用户
).apply {
description = "后台消息同步服务"
setSound(null, null)
setShowBadge(false)
enableLights(false)
enableVibration(false)
}
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel)
}
}
/**
* 构建通知
*/
private fun buildNotification(): Notification {
// 点击通知打开应用
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
val pendingIntent = PendingIntent.getActivity(
this,
0,
launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(notificationTitle)
.setContentText(notificationBody)
.setSmallIcon(R.drawable.${notificationIcon})
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setOngoing(true) // 不可滑动清除
.setShowWhen(false)
.setContentIntent(pendingIntent)
.build()
}
/**
* 更新通知内容
*/
private fun updateNotification() {
val manager = getSystemService(NotificationManager::class.java)
manager.notify(NOTIFICATION_ID, buildNotification())
}
}
`;
}
/**
* 生成 React Native Bridge 模块代码
*/
function generateModuleCode(packageName) {
return `package ${packageName}
import android.content.Context
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
import com.facebook.react.bridge.Promise
/**
* React Native Bridge: 前台服务控制模块
*/
class ForegroundServiceModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = "ForegroundService"
/**
* 启动前台服务
*/
@ReactMethod
fun start(title: String, body: String, promise: Promise) {
try {
SyncForegroundService.start(reactApplicationContext, title, body)
promise.resolve(true)
} catch (e: Exception) {
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
}
}
/**
* 停止前台服务
*/
@ReactMethod
fun stop(promise: Promise) {
try {
SyncForegroundService.stop(reactApplicationContext)
promise.resolve(true)
} catch (e: Exception) {
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
}
}
/**
* 更新通知内容
*/
@ReactMethod
fun update(title: String, body: String, promise: Promise) {
try {
SyncForegroundService.update(reactApplicationContext, title, body)
promise.resolve(true)
} catch (e: Exception) {
promise.reject("FOREGROUND_SERVICE_ERROR", e.message)
}
}
}
`;
}
/**
* 生成 React Native Package 代码
*/
function generatePackageCode(packageName) {
return `package ${packageName}
import com.facebook.react.ReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ViewManager
class ForegroundServicePackage : ReactPackage {
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> {
return listOf(ForegroundServiceModule(reactContext))
}
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> {
return emptyList()
}
}
`;
}
/**
* 生成通知图标 XML
*/
function generateNotificationIcon() {
return `<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<!-- 背景圆 -->
<path
android:fillColor="#4CAF50"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
<!-- 对勾符号 -->
<path
android:fillColor="#FFFFFF"
android:pathData="M10,17l-5,-5 1.41,-1.41L10,14.17l7.59,-7.59L19,8l-9,9z"/>
</vector>
`;
}
module.exports = withForegroundService;

View File

@@ -1,73 +0,0 @@
const { withMainActivity } = require('@expo/config-plugins');
/**
* 在 MainActivity 中添加 onConfigurationChanged 方法
* 用于监听系统深色模式变化并通知 React Native
*/
const withMainActivityConfigChange = (config) => {
return withMainActivity(config, async (config) => {
const { contents } = config.modResults;
// 检查是否已经添加了 onConfigurationChanged
if (contents.includes('onConfigurationChanged')) {
return config;
}
// 需要添加的 imports
const importsToAdd = `import android.content.Intent
import android.content.res.Configuration`;
// 需要添加的方法
const methodToAdd = `
/**
* 监听系统配置变化(包括深色模式切换),并广播给 React Native
* 这对于 Appearance API 正确检测系统主题至关重要
*/
override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
val intent = Intent("onConfigurationChanged")
intent.putExtra("newConfig", newConfig)
sendBroadcast(intent)
}
`;
let newContents = contents;
// 添加 imports在已有 imports 后面)
if (!newContents.includes('import android.content.Intent')) {
// 找到最后一个 import 语句
const importRegex = /import [^\n]+\n/g;
const imports = newContents.match(importRegex);
if (imports && imports.length > 0) {
const lastImport = imports[imports.length - 1];
const lastImportIndex = newContents.lastIndexOf(lastImport);
const insertPosition = lastImportIndex + lastImport.length;
newContents =
newContents.slice(0, insertPosition) +
importsToAdd +
'\n' +
newContents.slice(insertPosition);
}
}
// 在类体的开头添加 onConfigurationChanged 方法
// 找到类定义后的第一个方法或属性
const classBodyRegex = /class MainActivity\s*:\s*ReactActivity\s*\(\)\s*\{([\s\S]*)/;
const match = newContents.match(classBodyRegex);
if (match) {
// 找到 onCreate 方法之前插入
const onCreateIndex = newContents.indexOf('override fun onCreate');
if (onCreateIndex !== -1) {
newContents =
newContents.slice(0, onCreateIndex) +
methodToAdd +
newContents.slice(onCreateIndex);
}
}
config.modResults.contents = newContents;
return config;
});
};
module.exports = withMainActivityConfigChange;

View File

@@ -18,7 +18,7 @@ interface CommentItemProps {
comment: Comment;
onUserPress: () => void;
onReply: () => void;
onLike: (comment: Comment) => void; // 点赞回调,传入评论对象
onLike: () => void;
floorNumber?: number; // 楼层号
isAuthor?: boolean; // 是否是楼主
replyToUser?: string; // 回复给哪位用户
@@ -29,7 +29,6 @@ interface CommentItemProps {
onDelete?: (comment: Comment) => void; // 删除评论的回调
onImagePress?: (images: ImageGridItem[], index: number) => void; // 点击图片查看大图
currentUserId?: string; // 当前用户ID用于判断子评论作者
onReport?: (comment: Comment) => void; // 举报评论的回调
}
function createCommentItemStyles(colors: AppColors) {
@@ -215,7 +214,6 @@ const CommentItem: React.FC<CommentItemProps> = ({
onDelete,
onImagePress,
currentUserId,
onReport,
}) => {
const colors = useAppColors();
const styles = useMemo(() => createCommentItemStyles(colors), [colors]);
@@ -506,25 +504,6 @@ const CommentItem: React.FC<CommentItemProps> = ({
</Text>
</TouchableOpacity>
{/* 点赞按钮 */}
<TouchableOpacity
style={styles.subActionButton}
onPress={() => onLike?.(reply)}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name={reply.is_liked ? 'heart' : 'heart-outline'}
size={12}
color={reply.is_liked ? colors.error.main : colors.text.hint}
/>
<Text
variant="caption"
color={reply.is_liked ? colors.error.main : colors.text.hint}
style={styles.subActionText}
>
{reply.likes_count > 0 ? formatNumber(reply.likes_count) : '赞'}
</Text>
</TouchableOpacity>
{/* 删除按钮 - 子评论作者可见 */}
{isSubReplyAuthor && (
<TouchableOpacity
@@ -542,18 +521,6 @@ const CommentItem: React.FC<CommentItemProps> = ({
</Text>
</TouchableOpacity>
)}
{/* 举报按钮 - 对非子评论作者显示 */}
{!isSubReplyAuthor && onReport && (
<TouchableOpacity
style={styles.subActionButton}
onPress={() => onReport(reply)}
>
<MaterialCommunityIcons name="flag-outline" size={12} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
</Text>
</TouchableOpacity>
)}
</View>
</View>
</TouchableOpacity>
@@ -625,7 +592,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
{/* 操作按钮 - 更紧凑 */}
<View style={styles.actions}>
{/* 点赞 */}
<TouchableOpacity style={styles.actionButton} onPress={() => onLike(comment)}>
<TouchableOpacity style={styles.actionButton} onPress={onLike}>
<MaterialCommunityIcons
name={comment.is_liked ? 'heart' : 'heart-outline'}
size={14}
@@ -648,19 +615,6 @@ const CommentItem: React.FC<CommentItemProps> = ({
</Text>
</TouchableOpacity>
{/* 举报按钮 - 只对非评论作者显示 */}
{!isCommentAuthor && onReport && (
<TouchableOpacity
style={styles.actionButton}
onPress={() => onReport(comment)}
>
<MaterialCommunityIcons name="flag-outline" size={14} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
</Text>
</TouchableOpacity>
)}
{/* 删除按钮 - 只对评论作者显示 */}
{isCommentAuthor && (
<TouchableOpacity

View File

@@ -22,8 +22,7 @@ export type PostCardActionType =
| 'unbookmark' // 取消收藏
| 'share' // 分享
| 'imagePress' // 点击图片
| 'delete' // 删除
| 'report'; // 举报
| 'delete'; // 删除
/**
* Action payload 类型

View File

@@ -7,13 +7,12 @@ import {
Alert,
Modal,
Dimensions,
Platform,
} from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as hrefs from '../../navigation/hrefs';
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import { blurActiveElement } from '../../infrastructure/platform';
const { width, height } = Dimensions.get('window');
@@ -134,80 +133,22 @@ function createQrScannerStyles(colors: AppColors) {
fontSize: fontSizes.md,
fontWeight: '600',
},
webNotSupportedContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl,
backgroundColor: '#000',
},
webNotSupportedText: {
marginTop: spacing.lg,
fontSize: fontSizes.md,
color: 'rgba(255,255,255,0.72)',
textAlign: 'center',
lineHeight: 24,
},
});
}
// Web 端不支持扫码组件
const QRCodeScannerWeb: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
const themeColors = useAppColors();
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
useEffect(() => {
if (visible) {
blurActiveElement();
}
}, [visible]);
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<MaterialCommunityIcons name="close" size={24} color="#fff" />
</TouchableOpacity>
<Text style={styles.headerTitle}></Text>
<View style={styles.placeholder} />
</View>
<View style={styles.webNotSupportedContainer}>
<MaterialCommunityIcons name="web-off" size={64} color="rgba(255,255,255,0.5)" />
<Text style={styles.webNotSupportedText}>
使{'\n'}
App
</Text>
<TouchableOpacity style={styles.permissionButton} onPress={onClose}>
<Text style={styles.permissionButtonText}></Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
};
// 原生端扫码组件
const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
const router = useRouter();
const themeColors = useAppColors();
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
// 动态导入 expo-camera避免 Web 端加载
const [cameraModule, setCameraModule] = useState<typeof import('expo-camera') | null>(null);
const [permission, setPermission] = useState<any>(null);
const [permission, requestPermission] = useCameraPermissions();
const [scanned, setScanned] = useState(false);
useEffect(() => {
if (visible && Platform.OS !== 'web') {
import('expo-camera').then((module) => {
setCameraModule(module);
const [perm, requestPerm] = module.useCameraPermissions();
setPermission(perm);
if (!perm?.granted) {
requestPerm();
if (visible) {
setScanned(false);
if (!permission?.granted) {
requestPermission();
}
});
}
}, [visible]);
@@ -237,7 +178,7 @@ const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose })
}
};
if (!cameraModule || !permission?.granted) {
if (!permission?.granted) {
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={styles.container}>
@@ -251,10 +192,7 @@ const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose })
<View style={styles.permissionContainer}>
<MaterialCommunityIcons name="camera-off" size={64} color="rgba(255,255,255,0.5)" />
<Text style={styles.permissionText}></Text>
<TouchableOpacity
style={styles.permissionButton}
onPress={() => cameraModule?.useCameraPermissions()[1]()}
>
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
<Text style={styles.permissionButtonText}></Text>
</TouchableOpacity>
</View>
@@ -263,8 +201,6 @@ const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose })
);
}
const { CameraView } = cameraModule;
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={styles.container}>
@@ -307,9 +243,4 @@ const QRCodeScannerNative: React.FC<QRCodeScannerProps> = ({ visible, onClose })
);
};
// 根据平台导出不同组件
export const QRCodeScanner: React.FC<QRCodeScannerProps> = Platform.OS === 'web'
? QRCodeScannerWeb
: QRCodeScannerNative;
export default QRCodeScanner;

View File

@@ -1,493 +0,0 @@
/**
* 举报对话框组件
* 支持举报帖子、评论、消息
* 提供友好的用户界面和流畅的交互体验
*/
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import {
Modal,
View,
TouchableOpacity,
StyleSheet,
ScrollView,
TextInput,
ActivityIndicator,
Alert,
Platform,
KeyboardAvoidingView,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors } from '../../../theme';
import { spacing, borderRadius, fontSizes, shadows } from '../../../theme';
import reportService, { ReportReason, ReportTargetType, REPORT_REASONS } from '../../../services/reportService';
import { blurActiveElement } from '../../../infrastructure/platform';
import Text from '../../common/Text';
export interface ReportDialogProps {
visible: boolean;
targetType: ReportTargetType;
targetId: string;
onClose: () => void;
onSuccess?: () => void;
}
// 目标类型配置
const TARGET_CONFIG: Record<ReportTargetType, { label: string; icon: string; color: string }> = {
post: { label: '帖子', icon: 'file-document-outline', color: '#FF6B35' },
comment: { label: '评论', icon: 'comment-text-outline', color: '#4CAF50' },
message: { label: '消息', icon: 'message-text-outline', color: '#2196F3' },
};
// 举报原因详细配置(带图标和说明)
const REPORT_REASONS_DETAILED = [
{ value: 'spam' as ReportReason, label: '垃圾广告', icon: 'email-newsletter', description: '包含广告、推广或重复内容' },
{ value: 'inappropriate' as ReportReason, label: '违规内容', icon: 'alert-circle-outline', description: '包含违法、色情或暴力内容' },
{ value: 'harassment' as ReportReason, label: '辱骂攻击', icon: 'account-off-outline', description: '包含人身攻击、骚扰或歧视' },
{ value: 'misinformation' as ReportReason, label: '虚假信息', icon: 'alert-outline', description: '包含谣言、诈骗或误导信息' },
{ value: 'other' as ReportReason, label: '其他原因', icon: 'dots-horizontal-circle-outline', description: '其他需要举报的情况' },
];
const ReportDialog: React.FC<ReportDialogProps> = ({
visible,
targetType,
targetId,
onClose,
onSuccess,
}) => {
const colors = useAppColors();
const styles = useStyles(colors);
const [selectedReason, setSelectedReason] = useState<ReportReason | null>(null);
const [description, setDescription] = useState('');
const [submitting, setSubmitting] = useState(false);
const targetConfig = TARGET_CONFIG[targetType];
useEffect(() => {
if (visible) {
blurActiveElement();
}
}, [visible]);
// 判断补充说明是否必填(选择"其他原因"时必填)
const isDescriptionRequired = selectedReason === 'other';
const descriptionPlaceholder = isDescriptionRequired
? '请详细描述您举报的原因(必填)...'
: '请提供更多详细信息(选填)...';
// 提交举报
const handleSubmit = useCallback(async () => {
if (!selectedReason) {
Alert.alert('提示', '请选择举报原因', [{ text: '确定' }]);
return;
}
if (isDescriptionRequired && !description.trim()) {
Alert.alert('提示', '请选择"其他原因"时,请详细描述举报原因', [{ text: '确定' }]);
return;
}
setSubmitting(true);
try {
const result = await reportService.createReport(
targetType,
targetId,
selectedReason,
description.trim() || undefined
);
if (result) {
Alert.alert('举报成功', '感谢您的反馈,我们会尽快处理', [
{
text: '确定',
onPress: () => {
onClose();
onSuccess?.();
},
},
]);
} else {
Alert.alert('举报失败', '请稍后重试', [{ text: '确定' }]);
}
} catch (error) {
console.error('Submit report error:', error);
Alert.alert('举报失败', '网络错误,请稍后重试', [{ text: '确定' }]);
} finally {
setSubmitting(false);
}
}, [selectedReason, description, targetType, targetId, onClose, onSuccess]);
// 重置状态
const handleClose = useCallback(() => {
setSelectedReason(null);
setDescription('');
onClose();
}, [onClose]);
const selectedReasonData = useMemo(
() => REPORT_REASONS_DETAILED.find(r => r.value === selectedReason),
[selectedReason]
);
return (
<Modal
visible={visible}
transparent
animationType="fade"
onRequestClose={handleClose}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<View style={styles.overlay}>
<View style={styles.container}>
{/* 头部 */}
<View style={styles.header}>
<View style={styles.headerContent}>
<View style={[styles.headerIcon, { backgroundColor: targetConfig.color + '15' }]}>
<MaterialCommunityIcons
name={targetConfig.icon as any}
size={20}
color={targetConfig.color}
/>
</View>
<View style={styles.headerText}>
<Text style={styles.title}>{targetConfig.label}</Text>
<Text style={styles.subtitle}></Text>
</View>
</View>
<TouchableOpacity
style={styles.closeButton}
onPress={handleClose}
disabled={submitting}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<MaterialCommunityIcons
name="close"
size={24}
color={colors.chat.textSecondary}
/>
</TouchableOpacity>
</View>
{/* 内容 */}
<ScrollView style={styles.content} showsVerticalScrollIndicator={false}>
{/* 举报原因选择 */}
<View style={styles.section}>
<Text style={styles.sectionTitle}></Text>
<View style={styles.reasonList}>
{REPORT_REASONS_DETAILED.map((item) => (
<TouchableOpacity
key={item.value}
style={[
styles.reasonItem,
selectedReason === item.value && [
styles.reasonItemSelected,
{ borderColor: colors.primary.main + '40' }
],
]}
onPress={() => setSelectedReason(item.value)}
disabled={submitting}
activeOpacity={0.7}
>
<View style={styles.reasonLeft}>
<View
style={[
styles.reasonIcon,
selectedReason === item.value && styles.reasonIconSelected,
]}
>
<MaterialCommunityIcons
name={item.icon as any}
size={20}
color={selectedReason === item.value ? colors.primary.main : colors.chat.textSecondary}
/>
</View>
<View style={styles.reasonTextContainer}>
<Text style={[
styles.reasonLabel,
...(selectedReason === item.value ? [styles.reasonLabelSelected] : []),
]}>
{item.label}
</Text>
<Text style={styles.reasonDescription}>{item.description}</Text>
</View>
</View>
<View
style={[
styles.radio,
selectedReason === item.value && styles.radioSelected,
]}
>
{selectedReason === item.value && (
<View style={styles.radioDot} />
)}
</View>
</TouchableOpacity>
))}
</View>
</View>
{/* 补充说明 */}
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>
{isDescriptionRequired ? ' *' : ''}
</Text>
<Text style={styles.charCount}>{description.length}/500</Text>
</View>
<TextInput
style={[
styles.textInput,
isDescriptionRequired && !description && styles.textInputError,
]}
placeholder={descriptionPlaceholder}
placeholderTextColor={colors.chat.textPlaceholder}
multiline
maxLength={500}
value={description}
onChangeText={setDescription}
editable={!submitting}
textAlignVertical="top"
/>
</View>
</ScrollView>
{/* 底部按钮 */}
<View style={styles.footer}>
<TouchableOpacity
style={styles.cancelButton}
onPress={handleClose}
disabled={submitting}
activeOpacity={0.7}
>
<Text style={styles.cancelButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[
styles.submitButton,
(!selectedReason || submitting) && styles.submitButtonDisabled,
]}
onPress={handleSubmit}
disabled={!selectedReason || submitting}
activeOpacity={0.9}
>
{submitting ? (
<ActivityIndicator size="small" color="#FFFFFF" />
) : (
<Text style={styles.submitButtonText}></Text>
)}
</TouchableOpacity>
</View>
</View>
</View>
</KeyboardAvoidingView>
</Modal>
);
};
// 创建样式
const useStyles = (colors: ReturnType<typeof useAppColors>) =>
StyleSheet.create({
keyboardView: {
flex: 1,
},
overlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.5)',
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
},
container: {
backgroundColor: colors.chat.card,
borderRadius: borderRadius.xl,
width: '100%',
maxWidth: 420,
maxHeight: '85%',
...shadows.lg,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: spacing.lg,
paddingBottom: spacing.md,
},
headerContent: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.md,
},
headerIcon: {
width: 40,
height: 40,
borderRadius: borderRadius.md,
justifyContent: 'center',
alignItems: 'center',
},
headerText: {
flex: 1,
},
title: {
fontSize: fontSizes.xl,
fontWeight: '600',
color: colors.chat.textPrimary,
},
subtitle: {
fontSize: fontSizes.sm,
color: colors.chat.textSecondary,
marginTop: 2,
},
closeButton: {
padding: spacing.xs,
borderRadius: borderRadius.full,
},
content: {
padding: spacing.lg,
paddingTop: 0,
maxHeight: 400,
},
section: {
marginBottom: spacing.lg,
},
sectionHeader: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.sm,
},
sectionTitle: {
fontSize: fontSizes.md,
fontWeight: '600',
color: colors.chat.textPrimary,
},
reasonList: {
gap: spacing.sm,
},
reasonItem: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: spacing.md,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: 'transparent',
backgroundColor: colors.chat.surfaceMuted,
},
reasonItemSelected: {
backgroundColor: colors.primary.main + '08',
borderWidth: 1,
},
reasonLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
gap: spacing.md,
},
reasonIcon: {
width: 36,
height: 36,
borderRadius: borderRadius.md,
backgroundColor: colors.chat.surfaceRaised,
justifyContent: 'center',
alignItems: 'center',
},
reasonIconSelected: {
backgroundColor: colors.primary.main + '15',
},
reasonTextContainer: {
flex: 1,
},
reasonLabel: {
fontSize: fontSizes.md,
fontWeight: '500',
color: colors.chat.textPrimary,
},
reasonLabelSelected: {
color: colors.primary.main,
fontWeight: '600',
},
reasonDescription: {
fontSize: fontSizes.sm,
color: colors.chat.textSecondary,
marginTop: 2,
},
radio: {
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 2,
borderColor: colors.chat.border,
justifyContent: 'center',
alignItems: 'center',
marginLeft: spacing.sm,
},
radioSelected: {
borderColor: colors.primary.main,
backgroundColor: colors.primary.main,
},
radioDot: {
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: '#FFFFFF',
},
textInput: {
borderWidth: 1,
borderColor: colors.chat.border,
borderRadius: borderRadius.md,
padding: spacing.md,
fontSize: fontSizes.md,
color: colors.chat.textPrimary,
minHeight: 100,
backgroundColor: colors.chat.surfaceMuted,
},
textInputError: {
borderColor: colors.error.main,
backgroundColor: colors.error.main + '08',
},
charCount: {
fontSize: fontSizes.sm,
color: colors.chat.textSecondary,
},
footer: {
flexDirection: 'row',
padding: spacing.lg,
gap: spacing.md,
borderTopWidth: 1,
borderTopColor: colors.chat.borderLight,
},
cancelButton: {
flex: 1,
paddingVertical: spacing.md,
borderRadius: borderRadius.md,
borderWidth: 1,
borderColor: colors.chat.border,
alignItems: 'center',
backgroundColor: colors.chat.surfaceMuted,
},
cancelButtonText: {
fontSize: fontSizes.md,
fontWeight: '500',
color: colors.chat.textSecondary,
},
submitButton: {
flex: 1,
paddingVertical: spacing.md,
borderRadius: borderRadius.md,
backgroundColor: colors.primary.main,
alignItems: 'center',
},
submitButtonDisabled: {
backgroundColor: colors.background.disabled,
},
submitButtonText: {
fontSize: fontSizes.md,
color: '#FFFFFF',
fontWeight: '600',
},
});
export default ReportDialog;

View File

@@ -1,2 +0,0 @@
export { default } from './ReportDialog';
export type { ReportDialogProps } from './ReportDialog';

View File

@@ -1,7 +1,7 @@
/**
* SystemMessageItem 系统消息项组件 - 扁平化风格
* SystemMessageItem 系统消息项组件 - 美化版
* 根据系统消息类型显示不同图标和内容
* 与登录、注册、设置页面风格保持一致
* 采用卡片式设计符合胡萝卜BBS整体风格
*/
import React, { useMemo } from 'react';
@@ -9,7 +9,7 @@ import { View, TouchableOpacity, StyleSheet } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { formatDistanceToNow } from 'date-fns';
import { zhCN } from 'date-fns/locale';
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
import Text from '../common/Text';
import Avatar from '../common/Avatar';
@@ -124,13 +124,17 @@ function createSystemMessageStyles(colors: AppColors) {
container: {
flexDirection: 'row',
alignItems: 'flex-start',
padding: 16,
padding: spacing.md,
backgroundColor: colors.background.paper,
borderBottomWidth: 1,
borderBottomColor: colors.divider || '#E5E5EA',
borderRadius: borderRadius.lg,
marginHorizontal: spacing.md,
marginBottom: spacing.sm,
...shadows.sm,
},
unreadContainer: {
backgroundColor: colors.primary.main + '0A',
backgroundColor: colors.primary.light + '08',
borderLeftWidth: 3,
borderLeftColor: colors.primary.main,
},
unreadIndicator: {
position: 'absolute',
@@ -139,19 +143,19 @@ function createSystemMessageStyles(colors: AppColors) {
marginTop: -4,
width: 8,
height: 8,
borderRadius: 4,
borderRadius: borderRadius.full,
backgroundColor: colors.primary.main,
},
iconContainer: {
marginRight: 14,
marginRight: spacing.md,
},
avatarWrapper: {
position: 'relative',
},
iconWrapper: {
width: 44,
height: 44,
borderRadius: 12,
width: 48,
height: 48,
borderRadius: borderRadius.lg,
justifyContent: 'center',
alignItems: 'center',
},
@@ -159,9 +163,9 @@ function createSystemMessageStyles(colors: AppColors) {
position: 'absolute',
bottom: -2,
right: -2,
width: 18,
height: 18,
borderRadius: 9,
width: 20,
height: 20,
borderRadius: borderRadius.full,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
@@ -176,17 +180,17 @@ function createSystemMessageStyles(colors: AppColors) {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 4,
marginBottom: spacing.xs,
},
titleLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
marginRight: 8,
marginRight: spacing.sm,
},
title: {
fontWeight: '500',
fontSize: 15,
fontSize: fontSizes.md,
color: colors.text.primary,
},
unreadTitle: {
@@ -196,10 +200,10 @@ function createSystemMessageStyles(colors: AppColors) {
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 6,
paddingHorizontal: spacing.xs,
paddingVertical: 2,
borderRadius: 4,
marginLeft: 6,
borderRadius: borderRadius.sm,
marginLeft: spacing.xs,
},
statusText: {
fontSize: 10,
@@ -207,59 +211,55 @@ function createSystemMessageStyles(colors: AppColors) {
marginLeft: 2,
},
time: {
fontSize: 13,
fontSize: fontSizes.sm,
flexShrink: 0,
color: colors.text.hint,
},
messageContent: {
lineHeight: 20,
fontSize: 14,
color: colors.text.secondary,
fontSize: fontSizes.sm,
},
extraInfo: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 8,
backgroundColor: colors.background.default,
paddingHorizontal: 10,
paddingVertical: 6,
borderRadius: 8,
marginTop: spacing.xs,
backgroundColor: colors.primary.light + '12',
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: borderRadius.md,
alignSelf: 'flex-start',
},
extraText: {
marginLeft: 6,
marginLeft: spacing.xs,
flex: 1,
fontWeight: '500',
fontSize: 13,
},
actionsRow: {
flexDirection: 'row',
marginTop: 12,
gap: 10,
marginTop: spacing.sm,
gap: spacing.sm,
},
actionBtn: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: 8,
paddingHorizontal: 16,
borderRadius: 10,
paddingVertical: spacing.xs,
paddingHorizontal: spacing.md,
borderRadius: borderRadius.md,
borderWidth: 1,
gap: 4,
gap: spacing.xs,
},
rejectBtn: {
borderColor: colors.error.main + '40',
backgroundColor: colors.error.light + '20',
borderColor: `${colors.error.main}55`,
backgroundColor: `${colors.error.main}18`,
},
approveBtn: {
borderColor: colors.success.main + '40',
backgroundColor: colors.success.light + '20',
borderColor: `${colors.success.main}55`,
backgroundColor: `${colors.success.main}18`,
},
actionBtnText: {
fontWeight: '500',
fontSize: 13,
},
arrowContainer: {
marginLeft: 8,
marginLeft: spacing.sm,
justifyContent: 'center',
alignItems: 'center',
height: 20,

View File

@@ -135,33 +135,26 @@ function createTabBarStyles(colors: AppColors) {
modernContainer: {
flexDirection: 'row',
backgroundColor: colors.background.paper,
borderRadius: borderRadius['2xl'],
borderRadius: borderRadius.xl,
marginHorizontal: spacing.lg,
marginVertical: spacing.sm,
marginVertical: spacing.md,
padding: spacing.xs,
shadowColor: colors.chat.shadow,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.1,
shadowRadius: 16,
elevation: 6,
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.08,
shadowRadius: 8,
elevation: 3,
},
modernTab: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.md,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.sm,
borderRadius: borderRadius.lg,
position: 'relative',
backgroundColor: 'transparent',
},
modernTabActive: {
backgroundColor: colors.primary.main + '12',
shadowColor: colors.primary.main + '20',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.1,
shadowRadius: 8,
elevation: 3,
backgroundColor: colors.primary.main + '15',
},
modernTabContent: {
flexDirection: 'row',
@@ -172,7 +165,7 @@ function createTabBarStyles(colors: AppColors) {
marginRight: spacing.xs,
},
modernTabText: {
fontWeight: '600',
fontWeight: '500',
fontSize: fontSizes.md,
},
modernTabTextActive: {
@@ -213,7 +206,7 @@ const TabBar: React.FC<TabBarProps> = ({
key={index}
style={[styles.modernTab, isActive && styles.modernTabActive]}
onPress={() => onTabChange(index)}
activeOpacity={0.7}
activeOpacity={0.85}
>
<View style={styles.modernTabContent}>
{icon && (

View File

@@ -22,4 +22,3 @@ export { default as TabBar } from './TabBar';
export { default as VoteCard } from './VoteCard';
export { default as VoteEditor } from './VoteEditor';
export { default as VotePreview } from './VotePreview';
export { default as ReportDialog } from './ReportDialog';

View File

@@ -1,412 +0,0 @@
import React, { useEffect, useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
StatusBar,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { callStore } from '../../stores/callStore';
import { RTCView } from 'react-native-webrtc';
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const CallScreen: React.FC = () => {
const currentCall = callStore((s) => s.currentCall);
const callDuration = callStore((s) => s.callDuration);
const peerStream = callStore((s) => s.peerStream);
const localStream = callStore((s) => s.localStream);
const endCall = callStore((s) => s.endCall);
const toggleMute = callStore((s) => s.toggleMute);
const toggleSpeaker = callStore((s) => s.toggleSpeaker);
const toggleVideo = callStore((s) => s.toggleVideo);
const isMinimized = callStore((s) => s.isMinimized);
const toggleMinimize = callStore((s) => s.toggleMinimize);
// Track whether peer has video
const [hasPeerVideo, setHasPeerVideo] = useState(false);
// Track whether local has video
const [hasLocalVideo, setHasLocalVideo] = useState(false);
// Check peer stream for video tracks
useEffect(() => {
if (peerStream) {
const videoTracks = peerStream.getVideoTracks();
const hasVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled);
setHasPeerVideo(hasVideo);
} else {
setHasPeerVideo(false);
}
}, [peerStream]);
// Check local stream for video tracks
useEffect(() => {
if (localStream) {
const videoTracks = localStream.getVideoTracks();
const hasVideo = videoTracks.length > 0 && videoTracks.some((t) => t.enabled);
setHasLocalVideo(hasVideo);
} else {
setHasLocalVideo(false);
}
}, [localStream]);
// Don't render full screen when minimized or no call
if (!currentCall || isMinimized) return null;
const getStatusText = (): string => {
switch (currentCall.status) {
case 'calling':
return '正在等待对方接听...';
case 'ringing':
return '来电响铃中...';
case 'connecting':
return '连接中...';
case 'connected':
return formatDuration(callDuration);
case 'reconnecting':
return '网络重连中...';
case 'ended':
return '通话已结束';
case 'failed':
return '连接失败';
default:
return '';
}
};
const handleEndCall = () => {
endCall('hangup');
};
const handleToggleMute = () => {
toggleMute();
};
const handleToggleSpeaker = () => {
toggleSpeaker();
};
const handleToggleVideo = () => {
toggleVideo();
};
const handleMinimize = () => {
toggleMinimize();
};
// Determine if we should show video UI
const showRemoteVideo = hasPeerVideo;
// Use currentCall.isVideoEnabled directly for local video
// This is more reliable than checking localStream.getVideoTracks()
// because the stream object reference may not trigger useEffect properly
const showLocalVideo = currentCall?.isVideoEnabled && localStream;
const isVideoCallActive = showRemoteVideo || showLocalVideo;
return (
<View style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
{/* Background */}
<View style={styles.background} />
{/* Remote video - full screen when peer has video */}
{showRemoteVideo && peerStream && (
<RTCView
streamURL={peerStream.toURL()}
style={styles.fullScreenVideo}
objectFit="cover"
mirror={false}
/>
)}
{/* Local video - picture in picture */}
{showLocalVideo && localStream && (
<View style={styles.localVideoContainer}>
<RTCView
streamURL={localStream.toURL()}
style={styles.localVideo}
objectFit="cover"
mirror={true}
/>
</View>
)}
{/* Top area: minimize button */}
<View style={styles.topBar}>
<TouchableOpacity
style={styles.topButton}
onPress={handleMinimize}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="chevron-down" size={28} color="#fff" />
</TouchableOpacity>
</View>
{/* Center: Peer info - only show when no video */}
{!isVideoCallActive && (
<View style={styles.centerArea}>
<View style={styles.avatarOuter}>
{currentCall.peerAvatar ? (
<Image source={{ uri: currentCall.peerAvatar }} style={styles.avatar} />
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
</Text>
</View>
)}
</View>
<Text style={styles.peerName} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
</Text>
<Text style={styles.status}>{getStatusText()}</Text>
</View>
)}
{/* Peer name overlay when video is active */}
{isVideoCallActive && (
<View style={styles.videoOverlayInfo}>
<Text style={styles.videoPeerName} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
</Text>
<Text style={styles.videoStatus}>{getStatusText()}</Text>
</View>
)}
{/* Bottom controls */}
<View style={styles.controls}>
<View style={styles.controlRow}>
{/* Mute */}
<TouchableOpacity
style={[styles.controlButton, currentCall.isMuted && styles.controlButtonActive]}
onPress={handleToggleMute}
activeOpacity={0.7}
>
<View style={[styles.controlCircle, currentCall.isMuted && styles.controlCircleActive]}>
<MaterialCommunityIcons
name={currentCall.isMuted ? 'microphone-off' : 'microphone'}
size={26}
color={currentCall.isMuted ? '#333' : '#fff'}
/>
</View>
<Text style={[styles.controlLabel, currentCall.isMuted && styles.controlLabelActive]}>
{currentCall.isMuted ? '取消静音' : '静音'}
</Text>
</TouchableOpacity>
{/* Video toggle */}
<TouchableOpacity
style={[styles.controlButton, hasLocalVideo && styles.controlButtonActive]}
onPress={handleToggleVideo}
activeOpacity={0.7}
>
<View style={[styles.controlCircle, hasLocalVideo && styles.controlCircleActive]}>
<MaterialCommunityIcons
name={hasLocalVideo ? 'video' : 'video-off'}
size={26}
color={hasLocalVideo ? '#333' : '#fff'}
/>
</View>
<Text style={[styles.controlLabel, hasLocalVideo && styles.controlLabelActive]}>
{hasLocalVideo ? '关闭摄像头' : '打开摄像头'}
</Text>
</TouchableOpacity>
{/* Speaker */}
<TouchableOpacity
style={[styles.controlButton, currentCall.isSpeakerOn && styles.controlButtonActive]}
onPress={handleToggleSpeaker}
activeOpacity={0.7}
>
<View style={[styles.controlCircle, currentCall.isSpeakerOn && styles.controlCircleActive]}>
<MaterialCommunityIcons
name={currentCall.isSpeakerOn ? 'volume-high' : 'cellphone'}
size={26}
color={currentCall.isSpeakerOn ? '#333' : '#fff'}
/>
</View>
<Text style={[styles.controlLabel, currentCall.isSpeakerOn && styles.controlLabelActive]}>
{currentCall.isSpeakerOn ? '免提' : '听筒'}
</Text>
</TouchableOpacity>
</View>
{/* End call - prominent red button */}
<TouchableOpacity
style={styles.endCallButton}
onPress={handleEndCall}
activeOpacity={0.8}
>
<View style={styles.endCallCircle}>
<MaterialCommunityIcons name="phone-hangup" size={32} color="#fff" />
</View>
<Text style={styles.endCallLabel}></Text>
</TouchableOpacity>
</View>
</View>
);
};
const CONTROL_CIRCLE_SIZE = 56;
const END_CALL_SIZE = 64;
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#1A1A2E',
zIndex: 9999,
},
background: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#1A1A2E',
},
topBar: {
position: 'absolute',
top: 50,
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
height: 44,
zIndex: 100,
},
topButton: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: 'rgba(255, 255, 255, 0.1)',
justifyContent: 'center',
alignItems: 'center',
},
centerArea: {
position: 'absolute',
top: '28%',
left: 0,
right: 0,
alignItems: 'center',
paddingHorizontal: 30,
},
avatarOuter: {
marginBottom: 16,
},
avatar: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: '#3A3A5C',
},
avatarPlaceholder: {
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 40,
color: '#fff',
fontWeight: '600',
},
peerName: {
fontSize: 22,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 6,
textAlign: 'center',
maxWidth: 280,
},
status: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.5)',
letterSpacing: 0.3,
},
fullScreenVideo: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#1A1A2E',
},
localVideoContainer: {
position: 'absolute',
top: 100,
right: 20,
width: 120,
height: 160,
borderRadius: 16,
overflow: 'hidden',
backgroundColor: '#2A2A4E',
borderWidth: 2,
borderColor: 'rgba(255, 255, 255, 0.2)',
zIndex: 50,
},
localVideo: {
width: '100%',
height: '100%',
},
videoOverlayInfo: {
position: 'absolute',
top: 100,
left: 0,
right: 0,
alignItems: 'center',
zIndex: 50,
},
videoPeerName: {
fontSize: 18,
fontWeight: '600',
color: '#FFFFFF',
textShadowColor: 'rgba(0, 0, 0, 0.5)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
videoStatus: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.8)',
marginTop: 4,
textShadowColor: 'rgba(0, 0, 0, 0.5)',
textShadowOffset: { width: 0, height: 1 },
textShadowRadius: 2,
},
controls: {
position: 'absolute',
bottom: 60,
left: 0,
right: 0,
alignItems: 'center',
zIndex: 100,
},
controlRow: {
flexDirection: 'row',
justifyContent: 'center',
gap: 24,
marginBottom: 24,
},
controlButton: {
alignItems: 'center',
width: 70,
},
controlCircle: {
width: 56,
height: 56,
borderRadius: 28,
backgroundColor: 'rgba(255, 255, 255, 0.12)',
justifyContent: 'center',
alignItems: 'center',
},
controlCircleActive: {
backgroundColor: '#FFFFFF',
},
controlButtonActive: {},
controlLabel: {
fontSize: 11,
color: 'rgba(255, 255, 255, 0.55)',
marginTop: 8,
textAlign: 'center',
},
controlLabelActive: {
color: '#FFFFFF',
fontWeight: '500',
},
endCallButton: {
alignItems: 'center',
},
endCallCircle: {
width: 64,
height: 64,
borderRadius: 32,
backgroundColor: '#E54D42',
justifyContent: 'center',
alignItems: 'center',
},
endCallLabel: {
fontSize: 11,
color: 'rgba(255, 255, 255, 0.55)',
marginTop: 8,
},
});
export default CallScreen;

View File

@@ -1,160 +0,0 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
StatusBar,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { callStore } from '../../stores/callStore';
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const CallScreen: React.FC = () => {
const currentCall = callStore((s) => s.currentCall);
const callDuration = callStore((s) => s.callDuration);
const endCall = callStore((s) => s.endCall);
const isMinimized = callStore((s) => s.isMinimized);
// Don't render full screen when minimized or no call
if (!currentCall || isMinimized) return null;
const getStatusText = (): string => {
switch (currentCall.status) {
case 'ringing':
return '通话功能暂不支持网页端';
case 'connecting':
return '通话功能暂不支持网页端';
case 'connected':
return '通话功能暂不支持网页端';
case 'ended':
return '通话已结束';
default:
return '';
}
};
const handleEndCall = () => {
endCall('hangup');
};
return (
<View style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
{/* Background - single consistent color */}
<View style={styles.background} />
{/* Center: Peer info */}
<View style={styles.centerArea}>
<View style={styles.avatarOuter}>
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<MaterialCommunityIcons name="web-off" size={50} color="#fff" />
</View>
</View>
<Text style={styles.peerName} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
</Text>
<Text style={styles.status}>{getStatusText()}</Text>
</View>
{/* Bottom controls */}
<View style={styles.controls}>
{/* End call - prominent red button */}
<TouchableOpacity
style={styles.endCallButton}
onPress={handleEndCall}
activeOpacity={0.8}
>
<View style={styles.endCallCircle}>
<MaterialCommunityIcons name="phone-hangup" size={32} color="#fff" />
</View>
<Text style={styles.endCallLabel}></Text>
</TouchableOpacity>
</View>
</View>
);
};
const END_CALL_SIZE = 64;
const styles = StyleSheet.create({
container: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#1A1A2E',
zIndex: 9999,
},
background: {
...StyleSheet.absoluteFillObject,
backgroundColor: '#1A1A2E',
},
centerArea: {
position: 'absolute',
top: '28%',
left: 0,
right: 0,
alignItems: 'center',
paddingHorizontal: 30,
},
avatarOuter: {
marginBottom: 16,
},
avatar: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: '#3A3A5C',
},
avatarPlaceholder: {
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 40,
color: '#fff',
fontWeight: '600',
},
peerName: {
fontSize: 22,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 6,
textAlign: 'center',
maxWidth: 280,
},
status: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.5)',
letterSpacing: 0.3,
},
controls: {
position: 'absolute',
bottom: 60,
left: 0,
right: 0,
alignItems: 'center',
},
endCallButton: {
alignItems: 'center',
},
endCallCircle: {
width: END_CALL_SIZE,
height: END_CALL_SIZE,
borderRadius: END_CALL_SIZE / 2,
backgroundColor: '#E54D42',
justifyContent: 'center',
alignItems: 'center',
},
endCallLabel: {
fontSize: 11,
color: 'rgba(255, 255, 255, 0.55)',
marginTop: 8,
},
});
export default CallScreen;

View File

@@ -1,159 +0,0 @@
import React from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { callStore } from '../../stores/callStore';
const formatDuration = (seconds: number): string => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const FloatingCallWindow: React.FC = () => {
const currentCall = callStore((s) => s.currentCall);
const callDuration = callStore((s) => s.callDuration);
const isMinimized = callStore((s) => s.isMinimized);
const toggleMinimize = callStore((s) => s.toggleMinimize);
const endCall = callStore((s) => s.endCall);
// Only show when there's an active call and it's minimized
if (!currentCall || !isMinimized) return null;
const getStatusText = (): string => {
switch (currentCall.status) {
case 'calling':
return '等待接听...';
case 'ringing':
return '来电响铃...';
case 'connecting':
return '连接中...';
case 'connected':
return formatDuration(callDuration);
case 'reconnecting':
return '重连中...';
case 'ended':
return '已结束';
case 'failed':
return '连接失败';
default:
return '';
}
};
return (
<View style={styles.container}>
<TouchableOpacity
style={styles.window}
onPress={toggleMinimize}
activeOpacity={0.9}
>
{/* Avatar */}
<View style={styles.avatarContainer}>
{currentCall.peerAvatar ? (
<Image
source={{ uri: currentCall.peerAvatar }}
style={styles.avatar}
/>
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>
{currentCall.peerName?.charAt(0).toUpperCase() || '?'}
</Text>
</View>
)}
</View>
{/* Info */}
<View style={styles.info}>
<Text style={styles.name} numberOfLines={1}>
{currentCall.peerName || '未知用户'}
</Text>
<Text style={styles.status}>{getStatusText()}</Text>
</View>
{/* End call button */}
<TouchableOpacity
style={styles.endButton}
onPress={(e) => {
e.stopPropagation();
endCall('hangup');
}}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="phone-hangup" size={20} color="#fff" />
</TouchableOpacity>
</TouchableOpacity>
</View>
);
};
const styles = StyleSheet.create({
container: {
position: 'absolute',
top: 100,
left: 16,
right: 16,
zIndex: 10000,
},
window: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#2A2A4E',
borderRadius: 16,
padding: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 8,
},
avatarContainer: {
marginRight: 12,
},
avatar: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: '#3A3A5C',
},
avatarPlaceholder: {
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 18,
color: '#fff',
fontWeight: '600',
},
info: {
flex: 1,
justifyContent: 'center',
},
name: {
fontSize: 15,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 2,
},
status: {
fontSize: 12,
color: 'rgba(255, 255, 255, 0.6)',
},
endButton: {
width: 36,
height: 36,
borderRadius: 18,
backgroundColor: '#E54D42',
justifyContent: 'center',
alignItems: 'center',
marginLeft: 8,
},
});
export default FloatingCallWindow;

View File

@@ -1,279 +0,0 @@
import React, { useEffect, useRef } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Image,
Animated,
Modal,
StatusBar,
Dimensions,
Platform,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { callStore } from '../../stores/callStore';
import { blurActiveElement } from '../../infrastructure/platform';
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
const IncomingCallModal: React.FC = () => {
const incomingCall = callStore((s) => s.incomingCall);
const acceptCall = callStore((s) => s.acceptCall);
const rejectCall = callStore((s) => s.rejectCall);
const pulseAnim = useRef(new Animated.Value(1)).current;
const rippleAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (incomingCall) {
blurActiveElement();
const pulse = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.06,
duration: 1000,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
}),
])
);
const ripple = Animated.loop(
Animated.sequence([
Animated.timing(rippleAnim, {
toValue: 1,
duration: 2000,
useNativeDriver: true,
}),
Animated.timing(rippleAnim, {
toValue: 0,
duration: 0,
useNativeDriver: true,
}),
])
);
Animated.parallel([pulse, ripple]).start();
return () => {
pulse.stop();
ripple.stop();
};
}
}, [incomingCall, pulseAnim, rippleAnim]);
const handleAccept = () => {
acceptCall();
};
const handleReject = () => {
rejectCall();
};
if (!incomingCall) return null;
return (
<Modal
visible={!!incomingCall}
transparent
animationType="fade"
statusBarTranslucent
>
<View style={styles.overlay}>
<StatusBar barStyle="light-content" backgroundColor="transparent" translucent />
{/* Avatar with ripple effect - positioned in upper area */}
<View style={styles.avatarSection}>
{/* Ripple rings */}
{[0, 1, 2].map((i) => (
<Animated.View
key={i}
style={[
styles.rippleRing,
{
transform: [
{
scale: rippleAnim.interpolate({
inputRange: [0, 1],
outputRange: [1, 1.5 + i * 0.2],
}),
},
],
opacity: rippleAnim.interpolate({
inputRange: [0, 0.4, 1],
outputRange: [0.25, 0.12, 0],
}),
},
]}
/>
))}
<Animated.View
style={[styles.avatarContainer, { transform: [{ scale: pulseAnim }] }]}
>
{incomingCall.callerAvatar ? (
<Image
source={{ uri: incomingCall.callerAvatar }}
style={styles.avatar}
/>
) : (
<View style={[styles.avatar, styles.avatarPlaceholder]}>
<Text style={styles.avatarText}>
{incomingCall.callerName?.charAt(0).toUpperCase() || '?'}
</Text>
</View>
)}
</Animated.View>
</View>
{/* Caller info */}
<View style={styles.infoSection}>
<Text style={styles.callerName} numberOfLines={1}>
{incomingCall.callerName || '未知用户'}
</Text>
<Text style={styles.callType}>
{incomingCall.callType === 'video' ? '视频通话' : '语音通话'}
</Text>
</View>
{/* Bottom controls */}
<View style={styles.controls}>
{/* Decline */}
<TouchableOpacity
style={styles.actionButton}
onPress={handleReject}
activeOpacity={0.8}
>
<View style={styles.declineCircle}>
<MaterialCommunityIcons name="phone-hangup" size={28} color="#fff" />
</View>
<Text style={styles.actionLabel}></Text>
</TouchableOpacity>
{/* Accept */}
<TouchableOpacity
style={styles.actionButton}
onPress={handleAccept}
activeOpacity={0.8}
>
<View style={styles.acceptCircle}>
<MaterialCommunityIcons name="phone" size={28} color="#fff" />
</View>
<Text style={styles.actionLabel}></Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
};
const AVATAR_SIZE = 100;
const ACTION_CIRCLE_SIZE = 60;
const styles = StyleSheet.create({
overlay: {
flex: 1,
backgroundColor: '#0D0D0D',
},
avatarSection: {
position: 'absolute',
top: '18%',
left: 0,
right: 0,
height: AVATAR_SIZE + 60,
justifyContent: 'center',
alignItems: 'center',
},
rippleRing: {
position: 'absolute',
width: AVATAR_SIZE + 16,
height: AVATAR_SIZE + 16,
borderRadius: (AVATAR_SIZE + 16) / 2,
borderWidth: 2,
borderColor: '#4CD964',
},
avatarContainer: {
width: AVATAR_SIZE + 8,
height: AVATAR_SIZE + 8,
borderRadius: (AVATAR_SIZE + 8) / 2,
backgroundColor: '#4CD964',
justifyContent: 'center',
alignItems: 'center',
},
avatar: {
width: AVATAR_SIZE,
height: AVATAR_SIZE,
borderRadius: AVATAR_SIZE / 2,
backgroundColor: '#3A3A5C',
},
avatarPlaceholder: {
justifyContent: 'center',
alignItems: 'center',
},
avatarText: {
fontSize: 36,
color: '#fff',
fontWeight: '600',
},
infoSection: {
position: 'absolute',
top: '42%',
left: 0,
right: 0,
alignItems: 'center',
paddingHorizontal: 40,
},
callerName: {
fontSize: 24,
fontWeight: '600',
color: '#FFFFFF',
marginBottom: 6,
textAlign: 'center',
maxWidth: 280,
},
callType: {
fontSize: 14,
color: 'rgba(255, 255, 255, 0.5)',
letterSpacing: 0.3,
},
controls: {
position: 'absolute',
bottom: '12%',
left: 0,
right: 0,
flexDirection: 'row',
justifyContent: 'center',
gap: 60,
},
actionButton: {
alignItems: 'center',
width: 80,
},
declineCircle: {
width: ACTION_CIRCLE_SIZE,
height: ACTION_CIRCLE_SIZE,
borderRadius: ACTION_CIRCLE_SIZE / 2,
backgroundColor: '#E54D42',
justifyContent: 'center',
alignItems: 'center',
},
acceptCircle: {
width: ACTION_CIRCLE_SIZE,
height: ACTION_CIRCLE_SIZE,
borderRadius: ACTION_CIRCLE_SIZE / 2,
backgroundColor: '#4CD964',
justifyContent: 'center',
alignItems: 'center',
},
actionLabel: {
fontSize: 12,
color: 'rgba(255, 255, 255, 0.6)',
marginTop: 10,
},
});
export default IncomingCallModal;

View File

@@ -1,3 +0,0 @@
export { default as CallScreen } from './CallScreen';
export { default as IncomingCallModal } from './IncomingCallModal';
export { default as FloatingCallWindow } from './FloatingCallWindow';

View File

@@ -15,14 +15,12 @@ import {
Modal,
Pressable,
Dimensions,
Platform,
} from 'react-native';
import {
useResponsive,
useBreakpointGTE,
FineBreakpointKey,
} from '../../hooks/useResponsive';
import { blurActiveElement } from '../../infrastructure/platform';
export interface AdaptiveLayoutProps {
/** 主内容区 */
@@ -161,9 +159,6 @@ export function AdaptiveLayout({
// 抽屉动画
useEffect(() => {
if (isDrawerOpen) {
if (isDrawerOpen) {
blurActiveElement();
}
Animated.parallel([
Animated.timing(slideAnim, {
toValue: 1,

View File

@@ -1,12 +1,11 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Modal, Pressable, StyleSheet, Text, TouchableOpacity, View, Platform } from 'react-native';
import { Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import type { AlertButton } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { bindDialogListener, DialogPayload } from '../../services/dialogService';
import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme';
import { blurActiveElement } from '../../infrastructure/platform';
function createDialogHostStyles(colors: AppColors) {
return StyleSheet.create({
@@ -106,12 +105,6 @@ const AppDialogHost: React.FC = () => {
return unbind;
}, []);
useEffect(() => {
if (dialog) {
blurActiveElement();
}
}, [dialog]);
const actions = useMemo<AlertButton[]>(() => {
if (!dialog?.actions?.length) return [{ text: '确定' }];
return dialog.actions;

View File

@@ -14,7 +14,6 @@ import {
Text,
StatusBar,
Alert,
Platform,
} from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import {
@@ -33,7 +32,6 @@ import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as MediaLibrary from 'expo-media-library';
import { File, Paths } from 'expo-file-system';
import { spacing, borderRadius, fontSizes } from '../../theme';
import { blurActiveElement } from '../../infrastructure/platform';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
@@ -128,7 +126,6 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
// 打开/关闭时重置状态
useEffect(() => {
if (visible) {
blurActiveElement();
setCurrentIndex(initialIndex);
setShowControls(true);
setError(false);

View File

@@ -132,10 +132,10 @@ function createImageGridStyles(colors: AppColors) {
aspectRatio: 1,
},
gridItem2: {
width: '48.5%',
width: '49%',
},
gridItem3: {
width: '31.8%',
width: '32.5%',
},
masonryContainer: {
flexDirection: 'row',

View File

@@ -1,31 +0,0 @@
import React from 'react';
import { ViewStyle } from 'react-native';
import PagerViewNative from 'react-native-pager-view';
type PagerViewProps = {
style?: ViewStyle;
initialPage?: number;
onPageSelected?: (e: { nativeEvent: { position: number } }) => void;
overdrag?: boolean;
children: React.ReactNode;
};
export const PagerView = React.forwardRef<PagerViewNative, PagerViewProps>(
({ style, initialPage, onPageSelected, overdrag, children }, ref) => {
return (
<PagerViewNative
ref={ref}
style={style}
initialPage={initialPage}
onPageSelected={onPageSelected}
overdrag={overdrag}
>
{children}
</PagerViewNative>
);
}
);
PagerView.displayName = 'PagerView';
export default PagerView;

View File

@@ -1,27 +0,0 @@
import React, { ForwardedRef } from 'react';
import { View, ViewStyle } from 'react-native';
type PagerViewProps = {
style?: ViewStyle;
initialPage?: number;
onPageSelected?: (e: { nativeEvent: { position: number } }) => void;
overdrag?: boolean;
children: React.ReactNode;
};
function PagerViewInternal(
{ style, children }: PagerViewProps,
_ref: ForwardedRef<{ setPage: (page: number) => void }>
) {
const childrenArray = React.Children.toArray(children);
return (
<View style={style}>
{childrenArray[0]}
</View>
);
}
export const PagerView = React.forwardRef(PagerViewInternal);
PagerView.displayName = 'PagerView';
export default PagerView;

View File

@@ -1,200 +0,0 @@
/**
* 步骤指示器组件 - 极简扁平设计
* 与下方表单风格统一
*/
import React from 'react';
import { View, Text, StyleSheet, ViewStyle } from 'react-native';
import { useAppColors, type AppColors } from '../../theme';
export interface Step {
id: number;
title: string;
}
export interface StepIndicatorProps {
steps: Step[];
currentStep: number;
containerStyle?: ViewStyle;
}
export const StepIndicator: React.FC<StepIndicatorProps> = ({
steps,
currentStep,
containerStyle,
}) => {
const colors = useAppColors();
const styles = createStyles(colors);
return (
<View style={[styles.container, containerStyle]}>
{steps.map((step, index) => {
const isCompleted = index < currentStep;
const isCurrent = index === currentStep;
const isPending = index > currentStep;
const isLast = index === steps.length - 1;
return (
<React.Fragment key={step.id}>
<View style={styles.stepWrapper}>
{/* 步骤编号/状态 */}
<View
style={[
styles.stepBadge,
isCompleted && styles.stepBadgeCompleted,
isCurrent && styles.stepBadgeCurrent,
isPending && styles.stepBadgePending,
]}
>
<Text
style={[
styles.stepBadgeText,
isCompleted && styles.stepBadgeTextCompleted,
isCurrent && styles.stepBadgeTextCurrent,
isPending && styles.stepBadgeTextPending,
]}
>
{isCompleted ? '✓' : index + 1}
</Text>
</View>
{/* 步骤标题 */}
<Text
style={[
styles.stepTitle,
isCompleted && styles.stepTitleCompleted,
isCurrent && styles.stepTitleCurrent,
isPending && styles.stepTitlePending,
]}
>
{step.title}
</Text>
</View>
{/* 连接线 */}
{!isLast && (
<View style={styles.connector}>
<View
style={[
styles.connectorLine,
isCompleted && styles.connectorLineCompleted,
]}
/>
</View>
)}
</React.Fragment>
);
})}
</View>
);
};
// 注册流程专用步骤指示器
export const RegisterStepIndicator: React.FC<{
currentStep: number;
containerStyle?: ViewStyle;
}> = ({ currentStep, containerStyle }) => {
const steps: Step[] = [
{ id: 0, title: '输入邮箱' },
{ id: 1, title: '验证码' },
{ id: 2, title: '设置信息' },
];
return (
<StepIndicator
steps={steps}
currentStep={currentStep}
containerStyle={containerStyle}
/>
);
};
function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
width: '100%',
flexDirection: 'row',
alignItems: 'flex-start',
justifyContent: 'center',
paddingHorizontal: 16,
paddingVertical: 8,
},
stepWrapper: {
alignItems: 'center',
minWidth: 80,
},
// 步骤徽章
stepBadge: {
width: 28,
height: 28,
borderRadius: 14,
backgroundColor: '#F5F5F7',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 0,
},
stepBadgeCompleted: {
backgroundColor: '#FF6B35',
},
stepBadgeCurrent: {
backgroundColor: '#FFFFFF',
borderWidth: 2,
borderColor: '#FF6B35',
},
stepBadgePending: {
backgroundColor: '#F5F5F7',
},
stepBadgeText: {
fontSize: 13,
fontWeight: '600',
color: '#999',
},
stepBadgeTextCompleted: {
color: '#FFFFFF',
fontSize: 14,
},
stepBadgeTextCurrent: {
color: '#FF6B35',
},
stepBadgeTextPending: {
color: '#BBB',
},
// 步骤标题
stepTitle: {
marginTop: 8,
fontSize: 12,
color: '#999',
fontWeight: '500',
textAlign: 'center',
},
stepTitleCompleted: {
color: '#FF6B35',
fontWeight: '600',
},
stepTitleCurrent: {
color: '#333',
fontWeight: '600',
},
stepTitlePending: {
color: '#BBB',
},
// 连接线
connector: {
width: 60,
height: 28,
justifyContent: 'center',
alignItems: 'center',
},
connectorLine: {
width: '100%',
height: 2,
backgroundColor: '#E5E5E5',
borderRadius: 1,
},
connectorLineCompleted: {
backgroundColor: '#FF6B35',
},
});
}
export default StepIndicator;

View File

@@ -4,8 +4,8 @@
*/
import React from 'react';
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle, Platform } from 'react-native';
import { useAppColors, fontSizes, fontWeights } from '../../theme';
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle } from 'react-native';
import { useAppColors, fontSizes } from '../../theme';
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
@@ -16,45 +16,38 @@ interface CustomTextProps extends Omit<TextProps, 'style'> {
numberOfLines?: number;
onPress?: () => void;
style?: TextStyle | TextStyle[];
weight?: 'regular' | 'medium' | 'semibold' | 'bold';
}
const variantStyles: Record<TextVariant, object> = {
h1: {
fontSize: fontSizes['4xl'],
fontWeight: fontWeights.bold,
lineHeight: fontSizes['4xl'] * 1.3,
letterSpacing: -0.5,
fontWeight: '700',
lineHeight: fontSizes['4xl'] * 1.4,
},
h2: {
fontSize: fontSizes['3xl'],
fontWeight: fontWeights.semibold,
lineHeight: fontSizes['3xl'] * 1.3,
letterSpacing: -0.3,
fontWeight: '600',
lineHeight: fontSizes['3xl'] * 1.4,
},
h3: {
fontSize: fontSizes['2xl'],
fontWeight: fontWeights.semibold,
fontWeight: '600',
lineHeight: fontSizes['2xl'] * 1.3,
letterSpacing: -0.2,
},
body: {
fontSize: fontSizes.md,
fontWeight: fontWeights.regular,
lineHeight: fontSizes.md * 1.6,
letterSpacing: 0.1,
fontWeight: '400',
lineHeight: fontSizes.md * 1.5,
},
caption: {
fontSize: fontSizes.sm,
fontWeight: fontWeights.regular,
lineHeight: fontSizes.sm * 1.5,
letterSpacing: 0.2,
fontWeight: '400',
lineHeight: fontSizes.sm * 1.4,
},
label: {
fontSize: fontSizes.xs,
fontWeight: fontWeights.medium,
fontWeight: '500',
lineHeight: fontSizes.xs * 1.4,
letterSpacing: 0.3,
},
};
@@ -62,7 +55,6 @@ const Text: React.FC<CustomTextProps> = ({
children,
variant = 'body',
color,
weight,
numberOfLines,
onPress,
style,
@@ -72,7 +64,6 @@ const Text: React.FC<CustomTextProps> = ({
const textStyle = [
styles.base,
variantStyles[variant],
weight ? { fontWeight: fontWeights[weight] } : null,
color ? { color } : { color: colors.text.primary },
style,
];

View File

@@ -1,256 +0,0 @@
/**
* 验证码输入组件
* 参考设计4-6位数字验证码每个数字独立显示在一个方框中
* 支持自动聚焦、粘贴、删除回退
*/
import React, { useRef, useEffect, useCallback } from 'react';
import {
View,
TextInput,
StyleSheet,
Keyboard,
Platform,
Text,
ViewStyle,
} from 'react-native';
import { useAppColors, type AppColors } from '../../theme';
interface VerificationCodeInputProps {
value: string;
onChangeValue: (value: string) => void;
length?: number; // 验证码位数默认4位
autoFocus?: boolean;
onComplete?: (value: string) => void; // 输入完成回调
containerStyle?: ViewStyle;
disabled?: boolean;
}
const CELL_WIDTH = 56;
const CELL_HEIGHT = 64;
const CELL_MARGIN = 8;
export const VerificationCodeInput: React.FC<VerificationCodeInputProps> = ({
value,
onChangeValue,
length = 4,
autoFocus = true,
onComplete,
containerStyle,
disabled = false,
}) => {
const colors = useAppColors();
const styles = createStyles(colors);
// 为每个数字创建一个 ref
const inputRefs = useRef<(TextInput | null)[]>(Array(length).fill(null));
// 将 value 分割为单个字符数组
const chars = value.split('').slice(0, length);
// 填充空位
while (chars.length < length) {
chars.push('');
}
// 获取当前聚焦的位置
const getFocusedIndex = useCallback(() => {
return Math.min(value.length, length - 1);
}, [value.length, length]);
// 聚焦到指定位置
const focusInput = useCallback((index: number) => {
if (index >= 0 && index < length) {
inputRefs.current[index]?.focus();
}
}, [length]);
// 自动聚焦第一个输入框
useEffect(() => {
if (autoFocus && !disabled) {
const timer = setTimeout(() => {
focusInput(0);
}, 100);
return () => clearTimeout(timer);
}
}, [autoFocus, disabled, focusInput]);
// 当值改变时,自动聚焦到下一个输入框
useEffect(() => {
if (value.length < length) {
focusInput(value.length);
}
// 当输入完成时触发回调
if (value.length === length && onComplete) {
onComplete(value);
}
}, [value, length, onComplete, focusInput]);
// 处理文本变化
const handleChangeText = (text: string, index: number) => {
if (disabled) return;
// 只允许数字
const numericText = text.replace(/[^0-9]/g, '');
if (numericText.length === 0) {
// 删除操作
if (value.length > index) {
// 删除当前位置的字符
const newValue = value.slice(0, index) + value.slice(index + 1);
onChangeValue(newValue);
// 聚焦到前一个输入框
if (index > 0) {
focusInput(index - 1);
}
}
} else if (numericText.length === 1) {
// 单个字符输入
let newValue: string;
if (index < value.length) {
// 替换已有字符
newValue = value.slice(0, index) + numericText + value.slice(index + 1);
} else {
// 追加新字符
newValue = value + numericText;
}
onChangeValue(newValue.slice(0, length));
// 自动聚焦到下一个
if (index < length - 1) {
focusInput(index + 1);
}
} else if (numericText.length > 1) {
// 粘贴操作:尝试将粘贴的内容作为完整验证码
const newValue = numericText.slice(0, length);
onChangeValue(newValue);
// 聚焦到最后一个或下一个
const focusIndex = Math.min(newValue.length, length - 1);
focusInput(focusIndex);
}
};
// 处理按键事件(用于删除键)
const handleKeyPress = (e: any, index: number) => {
if (disabled) return;
if (e.nativeEvent.key === 'Backspace') {
if (value.length > index) {
// 删除当前位置的字符
const newValue = value.slice(0, index) + value.slice(index + 1);
onChangeValue(newValue);
} else if (index > 0 && value.length === index) {
// 当前位置没有字符,删除前一个
const newValue = value.slice(0, -1);
onChangeValue(newValue);
focusInput(index - 1);
}
}
};
// 聚焦时全选文本
const handleFocus = (index: number) => {
if (disabled) return;
inputRefs.current[index]?.setNativeProps({
selection: { start: 0, end: 1 },
});
};
return (
<View style={[styles.container, containerStyle]}>
<View style={styles.cellsContainer}>
{chars.map((char, index) => {
const isFocused = value.length === index;
const isFilled = char !== '';
const isLastFilled = index === value.length - 1 && isFilled;
return (
<View
key={index}
style={[
styles.cell,
isFocused && styles.cellFocused,
isFilled && styles.cellFilled,
disabled && styles.cellDisabled,
]}
>
<TextInput
ref={(ref) => {
inputRefs.current[index] = ref;
}}
style={styles.cellInput}
value={char}
onChangeText={(text) => handleChangeText(text, index)}
onKeyPress={(e) => handleKeyPress(e, index)}
onFocus={() => handleFocus(index)}
keyboardType="number-pad"
maxLength={1}
selectTextOnFocus
editable={!disabled}
caretHidden={true}
textContentType="oneTimeCode" // iOS 自动填充支持
/>
{/* 光标指示器 */}
{isFocused && !isFilled && !disabled && (
<View style={styles.cursor} />
)}
</View>
);
})}
</View>
</View>
);
};
function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
},
cellsContainer: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
},
cell: {
width: CELL_WIDTH,
height: CELL_HEIGHT,
borderRadius: 12,
borderWidth: 1.5,
borderColor: colors.divider,
backgroundColor: colors.background.paper,
marginHorizontal: CELL_MARGIN / 2,
justifyContent: 'center',
alignItems: 'center',
position: 'relative',
},
cellFocused: {
borderColor: colors.primary.main,
backgroundColor: `${colors.primary.main}10`,
},
cellFilled: {
borderColor: colors.primary.main,
},
cellDisabled: {
opacity: 0.5,
backgroundColor: colors.background.disabled,
},
cellInput: {
width: '100%',
height: '100%',
textAlign: 'center',
fontSize: 28,
fontWeight: '600',
color: colors.text.primary,
padding: 0,
},
cursor: {
position: 'absolute',
width: 2,
height: 28,
backgroundColor: colors.primary.main,
borderRadius: 1,
},
});
}
export default VerificationCodeInput;

View File

@@ -3,7 +3,7 @@
* 全屏模态播放视频,基于 expo-video
*/
import React, { useCallback, useEffect } from 'react';
import React, { useCallback } from 'react';
import {
Modal,
View,
@@ -12,13 +12,11 @@ import {
TouchableOpacity,
Text,
StatusBar,
Platform,
} from 'react-native';
import { VideoView, useVideoPlayer } from 'expo-video';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { spacing, fontSizes, borderRadius } from '../../theme';
import { blurActiveElement } from '../../infrastructure/platform';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
@@ -81,12 +79,6 @@ export const VideoPlayerModal: React.FC<VideoPlayerModalProps> = ({
url,
onClose,
}) => {
useEffect(() => {
if (visible) {
blurActiveElement();
}
}, [visible]);
return (
<Modal
visible={visible}

View File

@@ -17,7 +17,6 @@ export { default as ResponsiveGrid } from './ResponsiveGrid';
export { default as ResponsiveStack, HStack, VStack } from './ResponsiveStack';
export { default as AdaptiveLayout, SidebarLayout } from './AdaptiveLayout';
export { default as AppBackButton } from './AppBackButton';
export { default as PagerView } from './PagerView';
// 图片相关组件
export { default as SmartImage } from './SmartImage';
@@ -33,8 +32,3 @@ export type { ImageGalleryProps, GalleryImageItem } from './ImageGallery';
export type { ResponsiveGridProps } from './ResponsiveGrid';
export type { ResponsiveStackProps, StackDirection, StackAlignment, StackJustify } from './ResponsiveStack';
export type { AdaptiveLayoutProps, SidebarLayoutProps } from './AdaptiveLayout';
// 注册流程相关组件
export { default as VerificationCodeInput } from './VerificationCodeInput';
export { default as StepIndicator, RegisterStepIndicator } from './StepIndicator';
export type { Step, StepIndicatorProps } from './StepIndicator';

View File

@@ -4,8 +4,8 @@
* 纯业务逻辑无UI依赖
*/
import { messageRepository, userCacheRepository, CachedMessage } from '@/database';
import { wsClient, WSEventType } from '../../data/datasources/WSClient';
import { messageRepository } from '../../data/repositories/MessageRepository';
import { sseClient, SSEEventType } from '../../data/datasources/SSEClient';
import {
Message,
Conversation,
@@ -18,16 +18,6 @@ import {
import { messageService } from '../../services/messageService';
import { api } from '../../services/api';
const cachedMessageToMessage = (cached: CachedMessage): Message => ({
id: cached.id,
conversationId: cached.conversationId,
senderId: cached.senderId,
seq: cached.seq,
segments: cached.segments || [],
createdAt: cached.createdAt,
status: cached.status as Message['status'],
});
// 事件类型定义
export type MessageUseCaseEventType =
| 'message_received'
@@ -72,7 +62,7 @@ class ProcessMessageUseCase {
*/
initialize(currentUserId: string | null): void {
this.currentUserId = currentUserId;
this.setupWSListeners();
this.setupSSEListeners();
}
/**
@@ -91,49 +81,49 @@ class ProcessMessageUseCase {
/**
* 设置SSE监听器
*/
private setupWSListeners(): void {
private setupSSEListeners(): void {
// 监听私聊消息
const unsubChat = wsClient.on('chat', (message) => {
const unsubChat = sseClient.on('chat', (message) => {
this.handleNewMessage(message);
});
// 监听群聊消息
const unsubGroupMessage = wsClient.on('group_message', (message) => {
const unsubGroupMessage = sseClient.on('group_message', (message) => {
this.handleNewMessage(message);
});
// 监听私聊已读回执
const unsubRead = wsClient.on('read', (message) => {
const unsubRead = sseClient.on('read', (message) => {
this.handleReadReceipt(message);
});
// 监听群聊已读回执
const unsubGroupRead = wsClient.on('group_read', (message) => {
const unsubGroupRead = sseClient.on('group_read', (message) => {
this.handleGroupReadReceipt(message);
});
// 监听私聊消息撤回
const unsubRecall = wsClient.on('recall', (message) => {
const unsubRecall = sseClient.on('recall', (message) => {
this.handleRecallMessage(message);
});
// 监听群聊消息撤回
const unsubGroupRecall = wsClient.on('group_recall', (message) => {
const unsubGroupRecall = sseClient.on('group_recall', (message) => {
this.handleGroupRecallMessage(message);
});
// 监听群聊输入状态
const unsubGroupTyping = wsClient.on('group_typing', (message) => {
const unsubGroupTyping = sseClient.on('group_typing', (message) => {
this.handleGroupTyping(message);
});
// 监听群通知
const unsubGroupNotice = wsClient.on('group_notice', (message) => {
const unsubGroupNotice = sseClient.on('group_notice', (message) => {
this.handleGroupNotice(message);
});
// 监听连接状态
const unsubConnected = wsClient.on('connected', () => {
const unsubConnected = sseClient.on('connected', () => {
this.notifySubscribers({
type: 'connection_changed',
payload: { connected: true },
@@ -141,7 +131,7 @@ class ProcessMessageUseCase {
});
});
const unsubDisconnected = wsClient.on('disconnected', () => {
const unsubDisconnected = sseClient.on('disconnected', () => {
this.notifySubscribers({
type: 'connection_changed',
payload: { connected: false },
@@ -192,16 +182,7 @@ class ProcessMessageUseCase {
// 异步保存到本地数据库
const isRead = _isAck || sender_id === this.currentUserId;
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) => {
messageRepository.saveMessage(newMessage, isRead).catch((error) => {
console.error('[ProcessMessageUseCase] 保存消息失败:', error);
});
@@ -246,7 +227,7 @@ class ProcessMessageUseCase {
*/
private async getSenderInfo(userId: string): Promise<any | null> {
// 先检查本地缓存
const cachedUser = await userCacheRepository.get(userId);
const cachedUser = await messageRepository.getUserCache(userId);
if (cachedUser) {
return cachedUser;
}
@@ -274,9 +255,9 @@ class ProcessMessageUseCase {
*/
private async fetchUserInfo(userId: string): Promise<any | null> {
try {
const response = await api.get<any>(`/users/${userId}`);
const response = await api.get(`/users/${userId}`);
if (response.code === 0 && response.data) {
await userCacheRepository.save(response.data as any);
await messageRepository.saveUserCache(response.data);
return response.data;
}
return null;
@@ -341,8 +322,8 @@ class ProcessMessageUseCase {
// 更新本地数据库状态
messageRepository
.updateStatus(message_id, 'recalled', true)
.catch((error: unknown) => {
.updateMessageStatus(message_id, 'recalled', true)
.catch((error) => {
console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error);
});
@@ -368,8 +349,8 @@ class ProcessMessageUseCase {
// 更新本地数据库状态
messageRepository
.updateStatus(message_id, 'recalled', true)
.catch((error: unknown) => {
.updateMessageStatus(message_id, 'recalled', true)
.catch((error) => {
console.error('[ProcessMessageUseCase] 更新撤回状态失败:', error);
});
@@ -525,16 +506,7 @@ class ProcessMessageUseCase {
});
// 保存到本地数据库
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,
});
await messageRepository.saveMessage(message, true);
return message;
}
@@ -550,8 +522,7 @@ class ProcessMessageUseCase {
* 获取本地消息
*/
async getLocalMessages(conversationId: string, limit: number = 20): Promise<Message[]> {
const cached = await messageRepository.getByConversation(conversationId, limit);
return cached.map(cachedMessageToMessage);
return messageRepository.getMessagesByConversation(conversationId, limit);
}
/**
@@ -563,14 +534,14 @@ class ProcessMessageUseCase {
limit: number = 20
): Promise<Message[]> {
// 先从本地获取
const localMessages = await messageRepository.getBeforeSeq(
const localMessages = await messageRepository.getMessagesBeforeSeq(
conversationId,
beforeSeq,
limit
);
if (localMessages.length >= limit) {
return localMessages.map(cachedMessageToMessage);
return localMessages;
}
// 本地数据不足,从服务端获取
@@ -596,16 +567,7 @@ class ProcessMessageUseCase {
);
// 保存到本地
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,
})));
await messageRepository.saveMessages(serverMessages, false);
return serverMessages;
}
@@ -613,7 +575,7 @@ class ProcessMessageUseCase {
console.error('[ProcessMessageUseCase] 获取历史消息失败:', error);
}
return localMessages.map(cachedMessageToMessage);
return localMessages;
}
/**
@@ -640,16 +602,7 @@ class ProcessMessageUseCase {
);
// 保存到本地
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,
})));
await messageRepository.saveMessages(messages, false);
return messages;
}

View File

@@ -0,0 +1,323 @@
/**
* 本地数据库数据源实现
* 封装所有 SQLite 操作,提供统一的错误处理和事务支持
*/
import * as SQLite from 'expo-sqlite';
import { ILocalDataSource, DataSourceError } from './interfaces';
// 数据库实例管理
let dbInstance: SQLite.SQLiteDatabase | null = null;
let currentDbName: string | null = null;
let writeQueue: Promise<void> = Promise.resolve();
export interface LocalDataSourceConfig {
dbName?: string;
userId?: string;
}
export class LocalDataSource implements ILocalDataSource {
private db: SQLite.SQLiteDatabase | null = null;
private dbName: string;
private initialized = false;
private initializingPromise: Promise<void> | null = null;
constructor(config: LocalDataSourceConfig = {}) {
// 如果提供了userId使用用户专属数据库
this.dbName = config.dbName || (config.userId ? `carrot_bbs_${config.userId}.db` : 'carrot_bbs.db');
}
/**
* 初始化数据库连接
*/
async initialize(): Promise<void> {
if (this.initialized && this.db) {
return;
}
// 防止并发初始化导致连接状态竞争
if (this.initializingPromise) {
await this.initializingPromise;
return;
}
this.initializingPromise = this.doInitialize();
try {
await this.initializingPromise;
} finally {
this.initializingPromise = null;
}
}
private async doInitialize(): Promise<void> {
try {
// 使用全局实例管理
if (dbInstance && currentDbName === this.dbName) {
this.db = dbInstance;
this.initialized = true;
return;
}
// 关闭旧连接
if (dbInstance) {
try {
await dbInstance.closeAsync();
} catch (e) {
console.warn('关闭旧数据库连接失败:', e);
}
}
// 创建新连接
this.db = await SQLite.openDatabaseAsync(this.dbName);
dbInstance = this.db;
currentDbName = this.dbName;
this.initialized = true;
// 初始化数据库表结构
await this.createTables();
} catch (error) {
this.handleError(error, 'INITIALIZE');
}
}
/**
* 创建数据库表结构
*/
private async createTables(): Promise<void> {
if (!this.db) return;
const tables = [
// 消息表
`CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY NOT NULL,
conversationId TEXT NOT NULL,
senderId TEXT NOT NULL,
content TEXT,
type TEXT DEFAULT 'text',
isRead INTEGER DEFAULT 0,
createdAt TEXT NOT NULL,
seq INTEGER DEFAULT 0,
status TEXT DEFAULT 'normal',
segments TEXT
)`,
// 会话表
`CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY NOT NULL,
participantId TEXT NOT NULL,
lastMessageId TEXT,
lastSeq INTEGER DEFAULT 0,
unreadCount INTEGER DEFAULT 0,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
// 会话缓存表
`CREATE TABLE IF NOT EXISTS conversation_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
// 会话列表缓存表
`CREATE TABLE IF NOT EXISTS conversation_list_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
// 用户缓存表
`CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
// 当前登录用户缓存
`CREATE TABLE IF NOT EXISTS current_user_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
// 群组缓存表
`CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
// 群成员缓存表
`CREATE TABLE IF NOT EXISTS group_members (
groupId TEXT NOT NULL,
userId TEXT NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL,
PRIMARY KEY (groupId, userId)
)`,
// 帖子缓存表
`CREATE TABLE IF NOT EXISTS posts_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
];
for (const sql of tables) {
await this.db.execAsync(sql);
}
// 创建索引
const indexes = [
`CREATE INDEX IF NOT EXISTS idx_messages_conversationId ON messages(conversationId)`,
`CREATE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq)`,
`CREATE INDEX IF NOT EXISTS idx_messages_conversation_seq ON messages(conversationId, seq)`,
`CREATE INDEX IF NOT EXISTS idx_group_members_groupId ON group_members(groupId)`,
];
for (const sql of indexes) {
await this.db.execAsync(sql);
}
}
/**
* 处理错误
*/
private handleError(error: unknown, operation: string): never {
const message = error instanceof Error ? error.message : 'Unknown error';
throw new DataSourceError(
`Database ${operation} failed: ${message}`,
'DB_ERROR',
'LocalDataSource',
error instanceof Error ? error : undefined
);
}
/**
* 检查数据库连接
*/
private ensureDb(): SQLite.SQLiteDatabase {
if (!this.db) {
throw new DataSourceError(
'Database not initialized',
'DB_NOT_INITIALIZED',
'LocalDataSource'
);
}
return this.db;
}
// ==================== ILocalDataSource 实现 ====================
async query<T>(sql: string, params?: any[]): Promise<T[]> {
try {
const db = this.ensureDb();
if (params && params.length > 0) {
return await db.getAllAsync<T>(sql, params as any);
}
return await db.getAllAsync<T>(sql);
} catch (error) {
this.handleError(error, 'QUERY');
}
}
async getFirst<T>(sql: string, params?: any[]): Promise<T | null> {
try {
const db = this.ensureDb();
if (params && params.length > 0) {
return await db.getFirstAsync<T>(sql, params as any);
}
return await db.getFirstAsync<T>(sql);
} catch (error) {
this.handleError(error, 'GET_FIRST');
}
}
async execute(sql: string, params?: any[]): Promise<void> {
try {
const db = this.ensureDb();
await db.execAsync(sql);
} catch (error) {
this.handleError(error, 'EXECUTE');
}
}
async run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }> {
try {
const db = this.ensureDb();
if (params && params.length > 0) {
return await db.runAsync(sql, params as any);
}
return await db.runAsync(sql);
} catch (error) {
if (this.isRecoverableError(error)) {
this.db = null;
this.initialized = false;
dbInstance = null;
await this.initialize();
const db = this.ensureDb();
if (params && params.length > 0) {
return await db.runAsync(sql, params as any);
}
return await db.runAsync(sql);
}
this.handleError(error, 'RUN');
}
}
async transaction(operations: () => Promise<void>): Promise<void> {
try {
const db = this.ensureDb();
await db.withTransactionAsync(async () => {
await operations();
});
} catch (error) {
this.handleError(error, 'TRANSACTION');
}
}
async batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void> {
try {
await this.transaction(async () => {
await operations(this);
});
} catch (error) {
this.handleError(error, 'BATCH');
}
}
// ==================== 队列写入支持 ====================
/**
* 使用队列执行写入操作(避免并发写入冲突)
*/
async enqueueWrite<T>(operation: () => Promise<T>): Promise<T> {
const wrappedOperation = async () => {
try {
return await operation();
} catch (error) {
// 尝试重连后重试
if (this.isRecoverableError(error)) {
console.error('数据库写入异常,尝试重连后重试:', error);
this.db = null;
dbInstance = null;
await this.initialize();
return operation();
}
throw error;
}
};
const queued = writeQueue.then(wrappedOperation, wrappedOperation);
writeQueue = queued.then(() => undefined, () => undefined);
return queued;
}
/**
* 判断错误是否可恢复
*/
private isRecoverableError(error: unknown): boolean {
const message = String(error);
return (
message.includes('NativeDatabase.prepareAsync') ||
message.includes('NullPointerException') ||
message.includes('database is locked')
);
}
}
// 导出单例实例
export const localDataSource = new LocalDataSource();

View File

@@ -1,10 +1,10 @@
/**
* WSClient - WebSocket连接管理
* WebSocket连接管理
* SSEClient - SSE连接管理
* SSE连接管理
*/
import {
wsService,
sseService,
WSChatMessage,
WSGroupChatMessage,
WSReadMessage,
@@ -14,13 +14,13 @@ import {
WSGroupTypingMessage,
WSGroupNoticeMessage,
WSMessageType,
} from '../../services/wsService';
} from '../../services/sseService';
// 事件处理器类型
type EventHandler<T> = (data: T) => void;
// WS事件类型
export interface WSEvents {
// SSE事件类型
export interface SSEEvents {
'chat': WSChatMessage;
'group_message': WSGroupChatMessage;
'read': WSReadMessage;
@@ -34,65 +34,65 @@ export interface WSEvents {
'error': Error;
}
export type WSEventType = keyof WSEvents;
export type SSEEventType = keyof SSEEvents;
class WSClient {
private handlers: Map<WSEventType, Set<EventHandler<any>>> = new Map();
class SSEClient {
private handlers: Map<SSEEventType, Set<EventHandler<any>>> = new Map();
private unsubscribeFns: Array<() => void> = [];
private isInitialized = false;
/**
* WebSocket监听
* SSE监听
*/
initialize(): void {
if (this.isInitialized) return;
// 监听私聊消息
const unsubChat = wsService.on('chat', (message) => {
const unsubChat = sseService.on('chat', (message) => {
this.emit('chat', message);
});
// 监听群聊消息
const unsubGroupMessage = wsService.on('group_message', (message) => {
const unsubGroupMessage = sseService.on('group_message', (message) => {
this.emit('group_message', message);
});
// 监听私聊已读回执
const unsubRead = wsService.on('read', (message) => {
const unsubRead = sseService.on('read', (message) => {
this.emit('read', message);
});
// 监听群聊已读回执
const unsubGroupRead = wsService.on('group_read', (message) => {
const unsubGroupRead = sseService.on('group_read', (message) => {
this.emit('group_read', message);
});
// 监听私聊消息撤回
const unsubRecall = wsService.on('recall', (message) => {
const unsubRecall = sseService.on('recall', (message) => {
this.emit('recall', message);
});
// 监听群聊消息撤回
const unsubGroupRecall = wsService.on('group_recall', (message) => {
const unsubGroupRecall = sseService.on('group_recall', (message) => {
this.emit('group_recall', message);
});
// 监听群聊输入状态
const unsubGroupTyping = wsService.on('group_typing', (message) => {
const unsubGroupTyping = sseService.on('group_typing', (message) => {
this.emit('group_typing', message);
});
// 监听群通知
const unsubGroupNotice = wsService.on('group_notice', (message) => {
const unsubGroupNotice = sseService.on('group_notice', (message) => {
this.emit('group_notice', message);
});
// 监听连接状态
const unsubConnect = wsService.onConnect(() => {
const unsubConnect = sseService.onConnect(() => {
this.emit('connected', undefined);
});
const unsubDisconnect = wsService.onDisconnect(() => {
const unsubDisconnect = sseService.onDisconnect(() => {
this.emit('disconnected', undefined);
});
@@ -113,7 +113,7 @@ class WSClient {
}
/**
* WebSocket监听
* SSE监听
*/
destroy(): void {
this.unsubscribeFns.forEach((fn) => fn());
@@ -125,9 +125,9 @@ class WSClient {
/**
*
*/
on<T extends WSEventType>(
on<T extends SSEEventType>(
event: T,
handler: EventHandler<WSEvents[T]>
handler: EventHandler<SSEEvents[T]>
): () => void {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
@@ -142,9 +142,9 @@ class WSClient {
/**
*
*/
private emit<T extends WSEventType>(
private emit<T extends SSEEventType>(
event: T,
data: WSEvents[T]
data: SSEEvents[T]
): void {
const handlers = this.handlers.get(event);
if (handlers) {
@@ -152,7 +152,7 @@ class WSClient {
try {
handler(data);
} catch (error) {
console.error(`[WSClient] 事件处理器执行失败: ${event}`, error);
console.error(`[SSEClient] 事件处理器执行失败: ${event}`, error);
}
});
}
@@ -162,23 +162,23 @@ class WSClient {
*
*/
isConnected(): boolean {
return wsService.isConnected();
return sseService.isConnected();
}
/**
* WebSocket连接
* SSE连接
*/
async connect(): Promise<boolean> {
return wsService.start();
return sseService.start();
}
/**
* WebSocket连接
* SSE连接
*/
disconnect(): void {
wsService.stop();
sseService.stop();
}
}
export const wsClient = new WSClient();
export default wsClient;
export const sseClient = new SSEClient();
export default sseClient;

View File

@@ -5,5 +5,5 @@
export * from './interfaces';
export * from './ApiDataSource';
export { LocalDataSource, localDataSource } from '@/database';
export * from './LocalDataSource';
export * from './CacheDataSource';

View File

@@ -20,10 +20,6 @@ export interface ILocalDataSource {
run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }>;
transaction(operations: () => Promise<void>): Promise<void>;
batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void>;
enqueueWrite<T>(operation: () => Promise<T>): Promise<T>;
initialize(userId?: string): Promise<void>;
switchDatabase(userId?: string | null): Promise<void>;
closeDatabase(): Promise<void>;
}
// 缓存数据源接口

View File

@@ -1,289 +0,0 @@
/**
* 学习资料 Repository
* 提供学习资料的数据访问接口
*/
import type {
MaterialSubject,
MaterialFile,
MaterialFileType,
} from '../../types/material';
import { api } from '../../services/api';
// ==================== 接口定义 ====================
export interface GetMaterialsParams {
subjectId?: string;
fileType?: MaterialFileType;
keyword?: string;
sortBy?: 'createdAt' | 'downloadCount' | 'title';
sortOrder?: 'asc' | 'desc';
page?: number;
pageSize?: number;
}
export interface MaterialsResult {
materials: MaterialFile[];
total: number;
page: number;
pageSize: number;
hasMore: boolean;
}
// ==================== API 响应类型 ====================
interface SubjectResponse {
id: string;
name: string;
icon: string;
color: string;
description: string;
sort_order: number;
is_active: boolean;
material_count: number;
created_at: string;
updated_at: string;
}
interface MaterialFileResponse {
id: string;
subject_id: string;
title: string;
description: string;
file_type: string;
file_size: number;
file_url: string;
file_name: string;
download_count: number;
author_id: string;
tags: string[];
status: string;
created_at: string;
updated_at: string;
subject?: SubjectResponse;
}
// ==================== 转换函数 ====================
function toMaterialSubject(res: SubjectResponse): MaterialSubject {
return {
id: res.id,
name: res.name,
icon: res.icon,
color: res.color,
description: res.description,
sort_order: res.sort_order,
is_active: res.is_active,
material_count: res.material_count,
created_at: res.created_at,
updated_at: res.updated_at,
};
}
function toMaterialFile(res: MaterialFileResponse): MaterialFile {
return {
id: res.id,
subject_id: res.subject_id,
title: res.title,
description: res.description,
file_type: res.file_type as MaterialFileType,
file_size: res.file_size,
file_url: res.file_url,
file_name: res.file_name,
download_count: res.download_count,
author_id: res.author_id,
tags: res.tags || [],
status: res.status as 'active' | 'inactive',
created_at: res.created_at,
updated_at: res.updated_at,
subject: res.subject ? toMaterialSubject(res.subject) : undefined,
};
}
// ==================== Repository 实现 ====================
/**
* 学习资料数据仓库
* 使用真实 API
*/
export class MaterialRepository {
private static instance: MaterialRepository;
private constructor() {}
static getInstance(): MaterialRepository {
if (!MaterialRepository.instance) {
MaterialRepository.instance = new MaterialRepository();
}
return MaterialRepository.instance;
}
/**
* 获取学科分类列表
*/
async getSubjects(): Promise<MaterialSubject[]> {
try {
const response = await api.get<{ list: SubjectResponse[] }>('/materials/subjects');
return response.data.list.map(toMaterialSubject);
} catch (error) {
console.error('获取学科列表失败:', error);
return [];
}
}
/**
* 根据ID获取学科详情
*/
async getSubjectById(id: string): Promise<MaterialSubject | null> {
try {
const response = await api.get<SubjectResponse>(`/materials/subjects/${id}`);
return toMaterialSubject(response.data);
} catch (error) {
console.error('获取学科详情失败:', error);
return null;
}
}
/**
* 获取资料列表
*/
async getMaterials(params: GetMaterialsParams): Promise<MaterialsResult> {
const {
subjectId,
fileType,
keyword,
sortBy = 'createdAt',
sortOrder = 'desc',
page = 1,
pageSize = 20,
} = params;
try {
const queryParams: Record<string, any> = {
page,
page_size: pageSize,
};
if (subjectId) {
queryParams.subject_id = subjectId;
}
if (fileType) {
queryParams.file_type = fileType;
}
if (keyword) {
queryParams.keyword = keyword;
}
const response = await api.get<{
list: MaterialFileResponse[];
total: number;
page: number;
page_size: number;
has_more: boolean;
}>('/materials', queryParams);
return {
materials: response.data.list.map(toMaterialFile),
total: response.data.total,
page: response.data.page,
pageSize: response.data.page_size,
hasMore: response.data.has_more,
};
} catch (error) {
console.error('获取资料列表失败:', error);
return {
materials: [],
total: 0,
page: 1,
pageSize,
hasMore: false,
};
}
}
/**
* 根据ID获取资料详情
*/
async getMaterialById(id: string): Promise<MaterialFile | null> {
try {
const response = await api.get<MaterialFileResponse>(`/materials/${id}`);
return toMaterialFile(response.data);
} catch (error) {
console.error('获取资料详情失败:', error);
return null;
}
}
/**
* 搜索资料
*/
async searchMaterials(keyword: string, params?: { fileType?: MaterialFileType }): Promise<MaterialsResult> {
return this.getMaterials({
keyword,
fileType: params?.fileType,
});
}
/**
* 获取热门资料
*/
async getHotMaterials(limit: number = 10): Promise<MaterialFile[]> {
try {
const response = await api.get<{
list: MaterialFileResponse[];
total: number;
page: number;
page_size: number;
has_more: boolean;
}>('/materials', {
page: 1,
page_size: limit,
sort_by: 'download_count',
sort_order: 'desc',
});
return response.data.list.map(toMaterialFile);
} catch (error) {
console.error('获取热门资料失败:', error);
return [];
}
}
/**
* 获取最新资料
*/
async getLatestMaterials(limit: number = 10): Promise<MaterialFile[]> {
try {
const response = await api.get<{
list: MaterialFileResponse[];
total: number;
page: number;
page_size: number;
has_more: boolean;
}>('/materials', {
page: 1,
page_size: limit,
sort_by: 'created_at',
sort_order: 'desc',
});
return response.data.list.map(toMaterialFile);
} catch (error) {
console.error('获取最新资料失败:', error);
return [];
}
}
/**
* 获取资料的下载链接(增加下载次数)
*/
async downloadMaterial(id: string): Promise<{ file_url: string; file_name: string } | null> {
try {
const response = await api.get<{ file_url: string; file_name: string }>(`/materials/${id}/download`);
return response.data;
} catch (error) {
console.error('获取下载链接失败:', error);
return null;
}
}
}
// 导出单例
export const materialRepository = MaterialRepository.getInstance();

View File

@@ -0,0 +1,220 @@
/**
* MessageRepository - 消息仓库实现
* 封装所有SQLite数据库操作不依赖任何UI或状态管理
*/
import {
saveMessage as dbSaveMessage,
saveMessagesBatch as dbSaveMessagesBatch,
getMessagesByConversation as dbGetMessagesByConversation,
getMaxSeq as dbGetMaxSeq,
getMinSeq as dbGetMinSeq,
getMessagesBeforeSeq as dbGetMessagesBeforeSeq,
markConversationAsRead as dbMarkConversationAsRead,
updateConversationCacheUnreadCount as dbUpdateConversationCacheUnreadCount,
getUserCache as dbGetUserCache,
saveUserCache as dbSaveUserCache,
deleteConversation as dbDeleteConversation,
updateMessageStatus as dbUpdateMessageStatus,
CachedMessage,
} from '../../services/database';
import { Message, Conversation, createMessage, createConversation } from '../../core/entities/Message';
// 数据库消息到领域实体的转换
const cachedMessageToMessage = (cached: CachedMessage): Message => ({
id: cached.id,
conversationId: cached.conversationId,
senderId: cached.senderId,
seq: cached.seq,
segments: cached.segments || [],
createdAt: cached.createdAt,
status: cached.status as Message['status'],
});
// 领域实体到数据库消息的转换
const messageToCachedMessage = (message: Message, isRead: boolean): CachedMessage => ({
id: message.id,
conversationId: message.conversationId,
senderId: message.senderId,
content: message.segments
.filter((s) => s.type === 'text')
.map((s) => s.data?.text || '')
.join(''),
type: message.segments.find((s) => s.type === 'image') ? 'image' : 'text',
isRead,
createdAt: message.createdAt,
seq: message.seq,
status: message.status,
segments: message.segments,
});
class MessageRepository {
// ==================== 消息操作 ====================
/**
* 保存单条消息
*/
async saveMessage(message: Message, isRead: boolean): Promise<void> {
try {
const cachedMessage = messageToCachedMessage(message, isRead);
await dbSaveMessage(cachedMessage);
} catch (error) {
console.error('[MessageRepository] 保存消息失败:', error);
throw error;
}
}
/**
* 批量保存消息
*/
async saveMessages(messages: Message[], isRead: boolean): Promise<void> {
if (!messages || messages.length === 0) return;
try {
const cachedMessages = messages.map((m) => messageToCachedMessage(m, isRead));
await dbSaveMessagesBatch(cachedMessages);
} catch (error) {
console.error('[MessageRepository] 批量保存消息失败:', error);
throw error;
}
}
/**
* 获取会话的消息
*/
async getMessagesByConversation(
conversationId: string,
limit: number = 20
): Promise<Message[]> {
try {
const cachedMessages = await dbGetMessagesByConversation(conversationId, limit);
return cachedMessages.map(cachedMessageToMessage);
} catch (error) {
console.error('[MessageRepository] 获取消息失败:', error);
return [];
}
}
/**
* 获取会话的最大消息序号
*/
async getMaxSeq(conversationId: string): Promise<number> {
try {
return await dbGetMaxSeq(conversationId);
} catch (error) {
console.error('[MessageRepository] 获取最大序号失败:', error);
return 0;
}
}
/**
* 获取会话的最小消息序号
*/
async getMinSeq(conversationId: string): Promise<number> {
try {
return await dbGetMinSeq(conversationId);
} catch (error) {
console.error('[MessageRepository] 获取最小序号失败:', error);
return 0;
}
}
/**
* 获取指定seq之前的历史消息
*/
async getMessagesBeforeSeq(
conversationId: string,
beforeSeq: number,
limit: number = 20
): Promise<Message[]> {
try {
const cachedMessages = await dbGetMessagesBeforeSeq(conversationId, beforeSeq, limit);
return cachedMessages.map(cachedMessageToMessage);
} catch (error) {
console.error('[MessageRepository] 获取历史消息失败:', error);
return [];
}
}
/**
* 标记会话的所有消息为已读
*/
async markConversationAsRead(conversationId: string): Promise<void> {
try {
await dbMarkConversationAsRead(conversationId);
} catch (error) {
console.error('[MessageRepository] 标记会话已读失败:', error);
throw error;
}
}
/**
* 更新会话缓存的未读数
*/
async updateConversationUnreadCount(
conversationId: string,
count: number
): Promise<void> {
try {
await dbUpdateConversationCacheUnreadCount(conversationId, count);
} catch (error) {
console.error('[MessageRepository] 更新会话未读数失败:', error);
}
}
/**
* 更新消息状态(如撤回)
*/
async updateMessageStatus(
messageId: string,
status: string,
clearContent: boolean = false
): Promise<void> {
try {
await dbUpdateMessageStatus(messageId, status, clearContent);
} catch (error) {
console.error('[MessageRepository] 更新消息状态失败:', error);
throw error;
}
}
/**
* 删除会话及其所有消息
*/
async deleteConversation(conversationId: string): Promise<void> {
try {
await dbDeleteConversation(conversationId);
} catch (error) {
console.error('[MessageRepository] 删除会话失败:', error);
throw error;
}
}
// ==================== 用户缓存操作 ====================
/**
* 获取用户缓存
*/
async getUserCache(userId: string): Promise<any | null> {
try {
return await dbGetUserCache(userId);
} catch (error) {
console.error('[MessageRepository] 获取用户缓存失败:', error);
return null;
}
}
/**
* 保存用户缓存
*/
async saveUserCache(user: any): Promise<void> {
try {
await dbSaveUserCache(user);
} catch (error) {
console.error('[MessageRepository] 保存用户缓存失败:', error);
}
}
}
export const messageRepository = new MessageRepository();
export default messageRepository;

View File

@@ -4,7 +4,7 @@
*/
import { ApiDataSource, apiDataSource } from '../datasources/ApiDataSource';
import { localDataSource } from '@/database';
import { LocalDataSource, localDataSource } from '../datasources/LocalDataSource';
import { PostMapper } from '../mappers/PostMapper';
import type { Post, PostImage, PostStatus } from '../../core/entities/Post';
import { createPost } from '../../core/entities/Post';
@@ -23,8 +23,8 @@ import type {
* API帖子响应类型
*/
interface PostApiResponse {
id: string;
user_id: string;
id: number;
user_id: number;
title: string;
content: string;
images?: Array<{ url: string; width?: number; height?: number; thumbnail_url?: string }>;
@@ -44,7 +44,7 @@ interface PostApiResponse {
content_edited_at?: string;
channel?: { id: string; name: string } | null;
author?: {
id: string;
id: number;
username: string;
nickname?: string;
avatar?: string;
@@ -79,12 +79,13 @@ interface CachedPost {
export class PostRepository implements IPostRepository {
private api: ApiDataSource;
private localDb = localDataSource;
private localDb: LocalDataSource;
private memoryCache: Map<string, { post: Post; timestamp: number }> = new Map();
private readonly CACHE_TTL = 5 * 60 * 1000;
private readonly CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存过期时间
constructor(api: ApiDataSource) {
constructor(api: ApiDataSource, localDb: LocalDataSource) {
this.api = api;
this.localDb = localDb;
}
// ==================== 私有辅助方法 ====================
@@ -179,6 +180,7 @@ export class PostRepository implements IPostRepository {
*/
private async getFromLocalCache(id: string): Promise<Post | null> {
try {
await this.localDb.initialize();
const result = await this.localDb.getFirst<CachedPost>(
'SELECT * FROM posts_cache WHERE id = ?',
[id]
@@ -199,6 +201,7 @@ export class PostRepository implements IPostRepository {
*/
private async saveToLocalCache(post: Post): Promise<void> {
try {
await this.localDb.initialize();
await this.localDb.enqueueWrite(async () => {
await this.localDb.run(
`INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
@@ -215,6 +218,7 @@ export class PostRepository implements IPostRepository {
*/
private async clearLocalCache(id: string): Promise<void> {
try {
await this.localDb.initialize();
await this.localDb.enqueueWrite(async () => {
await this.localDb.run('DELETE FROM posts_cache WHERE id = ?', [id]);
});
@@ -551,6 +555,7 @@ export class PostRepository implements IPostRepository {
async clearAllCache(): Promise<void> {
this.memoryCache.clear();
try {
await this.localDb.initialize();
await this.localDb.enqueueWrite(async () => {
await this.localDb.run('DELETE FROM posts_cache');
});
@@ -570,5 +575,5 @@ export class PostRepository implements IPostRepository {
// ==================== 单例导出 ====================
export const postRepository = new PostRepository(apiDataSource);
export const postRepository = new PostRepository(apiDataSource, localDataSource);
export default postRepository;

View File

@@ -1,88 +0,0 @@
import * as SQLite from 'expo-sqlite';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
import { databaseManager } from './core/DatabaseManager';
class OperationQueue {
private queue: Promise<unknown> = Promise.resolve();
enqueue<T>(operation: () => Promise<T>): Promise<T> {
const result = this.queue.then(() => operation(), () => operation());
this.queue = result.catch(() => {});
return result;
}
async drain(): Promise<void> {
await this.queue;
}
}
const operationQueue = new OperationQueue();
export class LocalDataSource implements ILocalDataSource {
private async getDb(): Promise<SQLite.SQLiteDatabase> {
return databaseManager.getDb();
}
async initialize(userId?: string): Promise<void> {
if (databaseManager.isReady()) {
return;
}
await operationQueue.enqueue(() => databaseManager.initialize(userId));
}
async query<T>(sql: string, params?: any[]): Promise<T[]> {
const db = await this.getDb();
if (params && params.length > 0) {
return db.getAllAsync<T>(sql, params as any);
}
return db.getAllAsync<T>(sql);
}
async getFirst<T>(sql: string, params?: any[]): Promise<T | null> {
const db = await this.getDb();
if (params && params.length > 0) {
return db.getFirstAsync<T>(sql, params as any);
}
return db.getFirstAsync<T>(sql);
}
async execute(sql: string, params?: any[]): Promise<void> {
const db = await this.getDb();
await db.execAsync(sql);
}
async run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }> {
const db = await this.getDb();
if (params && params.length > 0) {
return db.runAsync(sql, params as any);
}
return db.runAsync(sql);
}
async transaction(operations: () => Promise<void>): Promise<void> {
const db = await this.getDb();
await db.withTransactionAsync(operations);
}
async batch(operations: (db: ILocalDataSource) => Promise<void>): Promise<void> {
await this.transaction(async () => {
await operations(this);
});
}
async enqueueWrite<T>(operation: () => Promise<T>): Promise<T> {
return operationQueue.enqueue(operation);
}
async switchDatabase(userId?: string | null): Promise<void> {
await operationQueue.drain();
await operationQueue.enqueue(() => databaseManager.initialize(userId));
}
async closeDatabase(): Promise<void> {
await operationQueue.drain();
await operationQueue.enqueue(() => databaseManager.close());
}
}
export const localDataSource = new LocalDataSource();

View File

@@ -1,6 +0,0 @@
const DEFAULT_DB_NAME = 'carrot_bbs.db';
const DB_NAME_PREFIX = 'carrot_bbs_';
export function getDbName(userId: string | null): string {
return userId ? `${DB_NAME_PREFIX}${userId}.db` : DEFAULT_DB_NAME;
}

View File

@@ -1,137 +0,0 @@
import * as SQLite from 'expo-sqlite';
import { Platform } from 'react-native';
import { getDbName } from './DatabaseConfig';
import { runMigrations } from '../schema/MigrationManager';
type SQLiteDatabase = SQLite.SQLiteDatabase;
class DatabaseManager {
private static instance: DatabaseManager;
private db: SQLiteDatabase | null = null;
private currentUserId: string | null = null;
private initPromise: Promise<void> | null = null;
static getInstance(): DatabaseManager {
if (!DatabaseManager.instance) {
DatabaseManager.instance = new DatabaseManager();
}
return DatabaseManager.instance;
}
async initialize(userId?: string | null): Promise<void> {
const targetUserId = userId || null;
if (this.db && this.currentUserId === targetUserId) {
return;
}
if (this.initPromise) {
await this.initPromise;
if (this.db && this.currentUserId === targetUserId) {
return;
}
}
this.initPromise = this.doInitialize(targetUserId);
try {
await this.initPromise;
} finally {
this.initPromise = null;
}
}
private async doInitialize(targetUserId: string | null): Promise<void> {
if (this.db) {
this.forceClose(this.db);
this.db = null;
this.currentUserId = null;
}
const dbName = getDbName(targetUserId);
const isWeb = Platform.OS === 'web';
if (isWeb) {
try {
const db = await this.tryOpenDatabase(dbName);
await runMigrations(db);
this.db = db;
this.currentUserId = targetUserId;
} catch (error) {
console.warn('[DatabaseManager] Web数据库打开失败使用内存数据库:', error);
const db = await SQLite.openDatabaseAsync(':memory:');
await runMigrations(db);
this.db = db;
this.currentUserId = targetUserId;
}
return;
}
const db = await this.tryOpenDatabase(dbName);
await runMigrations(db);
this.db = db;
this.currentUserId = targetUserId;
}
private async tryOpenDatabase(dbName: string): Promise<SQLiteDatabase> {
try {
return await SQLite.openDatabaseAsync(dbName, {
useNewConnection: true,
});
} catch (error) {
const msg = String(error);
if (msg.includes('cannot create file') || msg.includes('SQLITE_CANTOPEN') || msg.includes('database is locked')) {
return await SQLite.openDatabaseAsync(':memory:');
}
throw error;
}
}
private forceClose(db: SQLiteDatabase): void {
try {
db.closeSync();
} catch {
try {
db.closeAsync().catch(() => {});
} catch {}
}
}
async close(): Promise<void> {
if (this.initPromise) {
await this.initPromise.catch(() => {});
}
if (this.db) {
this.forceClose(this.db);
this.db = null;
this.currentUserId = null;
}
}
async getDb(): Promise<SQLiteDatabase> {
if (this.db) return this.db;
if (this.initPromise) {
await this.initPromise;
if (this.db) return this.db;
}
throw new Error('数据库未初始化');
}
getDbSync(): SQLiteDatabase | null {
return this.db;
}
getCurrentUserId(): string | null {
return this.currentUserId;
}
isReady(): boolean {
return this.db !== null;
}
}
export const databaseManager = DatabaseManager.getInstance();
export { DatabaseManager };

View File

@@ -1,34 +0,0 @@
export class LockManager {
private static mutex: Promise<void> = Promise.resolve();
static async withMutex<T>(fn: () => Promise<T>): Promise<T> {
const prev = LockManager.mutex;
let release: () => void = () => {};
LockManager.mutex = new Promise<void>((r) => { release = r; });
try {
await prev;
return await fn();
} finally {
release();
}
}
static async withWebLock<T>(lockName: string, fn: () => Promise<T>): Promise<T> {
if (typeof navigator !== 'undefined' && navigator.locks) {
return new Promise((resolve, reject) => {
navigator.locks.request(lockName, { mode: 'exclusive' }, async (lock) => {
if (!lock) {
return reject(new Error('无法获取跨标签页锁'));
}
try {
const result = await fn();
resolve(result);
} catch (e) {
reject(e);
}
});
});
}
return fn();
}
}

View File

@@ -1,58 +0,0 @@
import { localDataSource } from './LocalDataSource';
import { databaseManager } from './core/DatabaseManager';
export { databaseManager } from './core/DatabaseManager';
export { DatabaseManager } from './core/DatabaseManager';
export { localDataSource, LocalDataSource } from './LocalDataSource';
export {
messageRepository, MessageRepository,
conversationRepository, ConversationRepository,
userCacheRepository, UserCacheRepository,
groupCacheRepository, GroupCacheRepository,
postCacheRepository, PostCacheRepository,
} from './repositories';
export { CachedMessage } from './types';
export async function clearAllData(): Promise<void> {
await localDataSource.enqueueWrite(async () => {
await localDataSource.execute(`DELETE FROM messages`);
await localDataSource.execute(`DELETE FROM conversations`);
await localDataSource.execute(`DELETE FROM conversation_cache`);
await localDataSource.execute(`DELETE FROM conversation_list_cache`);
await localDataSource.execute(`DELETE FROM users`);
await localDataSource.execute(`DELETE FROM current_user_cache`);
await localDataSource.execute(`DELETE FROM groups`);
await localDataSource.execute(`DELETE FROM group_members`);
await localDataSource.execute(`DELETE FROM posts_cache`);
});
}
export const initDatabase = async (userId?: string | null): Promise<void> => {
await databaseManager.initialize(userId);
};
export const switchDatabase = async (userId?: string | null): Promise<void> => {
await localDataSource.switchDatabase(userId);
};
export const closeDatabase = async (): Promise<void> => {
await localDataSource.closeDatabase();
};
export const getCurrentDbUserId = (): string | null => {
return databaseManager.getCurrentUserId();
};
export const isDbInitialized = (): boolean => {
return databaseManager.isReady();
};
export const getDbInstance = () => {
return databaseManager.getDbSync();
};
export const getDb = async () => {
return databaseManager.getDb();
};

View File

@@ -1,197 +0,0 @@
import { localDataSource } from '../LocalDataSource';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
import type { ConversationResponse, ConversationDetailResponse } from '@/types/dto';
type ConversationCacheData = ConversationResponse | ConversationDetailResponse;
const safeParseJson = <T>(value: string): T | null => {
try { return JSON.parse(value) as T; } catch { return null; }
};
export interface IConversationRepository {
save(conv: { id: string; participantId: string; lastMessageId?: string; lastSeq?: number; unreadCount?: number; createdAt: string; updatedAt: string }): Promise<void>;
getAll(): Promise<any[]>;
updateUnreadCount(id: string, count: number): Promise<void>;
updateLastMessage(id: string, lastMessageId: string, lastSeq: number, updatedAt: string): Promise<void>;
delete(conversationId: string): Promise<void>;
getCache(id: string): Promise<ConversationCacheData | null>;
getListCache(): Promise<ConversationResponse[]>;
saveCache(conv: ConversationCacheData): Promise<void>;
saveListCache(convs: ConversationResponse[]): Promise<void>;
updateListEntry(id: string, updates: Partial<ConversationResponse>): Promise<void>;
updateCacheUnreadCount(id: string, count: number, myLastReadSeq?: number): Promise<void>;
saveWithRelated(convs: ConversationResponse[], users?: any[], groups?: any[]): Promise<void>;
}
export class ConversationRepository implements IConversationRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async save(conv: { id: string; participantId: string; lastMessageId?: string; lastSeq?: number; unreadCount?: number; createdAt: string; updatedAt: string }): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO conversations (id, participantId, lastMessageId, lastSeq, unreadCount, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
[conv.id, conv.participantId, conv.lastMessageId || null, conv.lastSeq || 0, conv.unreadCount || 0, conv.createdAt, conv.updatedAt]
);
});
}
async getAll(): Promise<any[]> {
return this.dataSource.query<any>(`SELECT * FROM conversations ORDER BY updatedAt DESC`);
}
async updateUnreadCount(id: string, count: number): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`UPDATE conversations SET unreadCount = ? WHERE id = ?`, [count, id]);
});
}
async updateLastMessage(id: string, lastMessageId: string, lastSeq: number, updatedAt: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`UPDATE conversations SET lastMessageId = ?, lastSeq = ?, updatedAt = ? WHERE id = ?`,
[lastMessageId, lastSeq, updatedAt, id]
);
});
}
async delete(conversationId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM messages WHERE conversationId = ?`, [conversationId]);
await this.dataSource.run(`DELETE FROM conversations WHERE id = ?`, [conversationId]);
});
}
async getCache(id: string): Promise<ConversationCacheData | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM conversation_cache WHERE id = ?`, [String(id)]
);
return r?.data ? safeParseJson<ConversationCacheData>(r.data) : null;
}
async getListCache(): Promise<ConversationResponse[]> {
let rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM conversation_list_cache ORDER BY updatedAt DESC`
);
if (!rows.length) {
rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM conversation_cache ORDER BY updatedAt DESC`
);
}
const list = rows.map(r => safeParseJson<ConversationResponse>(r.data))
.filter((c): c is ConversationResponse => Boolean(c))
.filter(c => typeof c.id !== 'undefined' && typeof c.type !== 'undefined');
return list.sort((a, b) => {
const aPinned = a.is_pinned ? 1 : 0;
const bPinned = b.is_pinned ? 1 : 0;
if (aPinned !== bPinned) return bPinned - aPinned;
const aTime = new Date(a.last_message_at || a.updated_at || 0).getTime();
const bTime = new Date(b.last_message_at || b.updated_at || 0).getTime();
return bTime - aTime;
});
}
async saveCache(conv: ConversationCacheData): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
const updatedAt = ('updated_at' in conv && conv.updated_at) ? String(conv.updated_at) : new Date().toISOString();
await this.dataSource.run(
`INSERT OR REPLACE INTO conversation_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(conv.id), JSON.stringify(conv), updatedAt]
);
});
}
async saveListCache(convs: ConversationResponse[]): Promise<void> {
if (!convs?.length) return;
await this.dataSource.enqueueWrite(async () => {
for (const c of convs) {
await this.dataSource.run(
`INSERT OR REPLACE INTO conversation_list_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(c.id), JSON.stringify(c), c.updated_at || new Date().toISOString()]
);
}
});
}
async updateListEntry(id: string, updates: Partial<ConversationResponse>): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM conversation_list_cache WHERE id = ?`, [String(id)]
);
if (!r?.data) return;
const conv = safeParseJson<ConversationResponse>(r.data);
if (!conv) return;
const updated = { ...conv, ...updates };
const updatedAt = updates.last_message_at || updated.last_message_at || new Date().toISOString();
await this.dataSource.run(
`UPDATE conversation_list_cache SET data = ?, updatedAt = ? WHERE id = ?`,
[JSON.stringify(updated), updatedAt, String(id)]
);
});
}
async updateCacheUnreadCount(id: string, count: number, myLastReadSeq?: number): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
const listRow = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM conversation_list_cache WHERE id = ?`, [String(id)]
);
if (listRow?.data) {
const conv = safeParseJson<ConversationResponse>(listRow.data);
if (conv) {
conv.unread_count = count;
if (typeof myLastReadSeq === 'number' && !Number.isNaN(myLastReadSeq)) conv.my_last_read_seq = myLastReadSeq;
await this.dataSource.run(
`UPDATE conversation_list_cache SET data = ? WHERE id = ?`,
[JSON.stringify(conv), String(id)]
);
}
}
const cacheRow = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM conversation_cache WHERE id = ?`, [String(id)]
);
if (cacheRow?.data) {
const conv = safeParseJson<Record<string, unknown>>(cacheRow.data);
if (conv) {
conv.unread_count = count;
if (typeof myLastReadSeq === 'number' && !Number.isNaN(myLastReadSeq)) conv.my_last_read_seq = myLastReadSeq;
await this.dataSource.run(
`UPDATE conversation_cache SET data = ? WHERE id = ?`,
[JSON.stringify(conv), String(id)]
);
}
}
});
}
async saveWithRelated(convs: ConversationResponse[], users?: any[], groups?: any[]): Promise<void> {
if (!convs?.length) return;
const now = new Date().toISOString();
await this.dataSource.enqueueWrite(async () => {
for (const c of convs) {
await this.dataSource.run(
`INSERT OR REPLACE INTO conversation_list_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(c.id), JSON.stringify(c), c.updated_at || now]
);
}
if (users?.length) {
for (const u of users) {
await this.dataSource.run(
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(u.id), JSON.stringify(u), now]
);
}
}
if (groups?.length) {
for (const g of groups) {
await this.dataSource.run(
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(g.id), JSON.stringify(g), now]
);
}
}
});
}
}
export const conversationRepository = new ConversationRepository();

View File

@@ -1,78 +0,0 @@
import { localDataSource } from '../LocalDataSource';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
import type { GroupResponse, GroupMemberResponse } from '@/types/dto';
const safeParseJson = <T>(value: string): T | null => {
try { return JSON.parse(value) as T; } catch { return null; }
};
export interface IGroupCacheRepository {
save(group: GroupResponse): Promise<void>;
saveBatch(groups: GroupResponse[]): Promise<void>;
get(groupId: string): Promise<GroupResponse | null>;
getAll(): Promise<GroupResponse[]>;
saveMembers(groupId: string, members: GroupMemberResponse[]): Promise<void>;
getMembers(groupId: string): Promise<GroupMemberResponse[]>;
}
export class GroupCacheRepository implements IGroupCacheRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async save(group: GroupResponse): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(group.id), JSON.stringify(group), new Date().toISOString()]
);
});
}
async saveBatch(groups: GroupResponse[]): Promise<void> {
if (!groups?.length) return;
await this.dataSource.enqueueWrite(async () => {
const now = new Date().toISOString();
for (const g of groups) {
await this.dataSource.run(
`INSERT OR REPLACE INTO groups (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(g.id), JSON.stringify(g), now]
);
}
});
}
async get(groupId: string): Promise<GroupResponse | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM groups WHERE id = ?`, [String(groupId)]
);
return r?.data ? safeParseJson<GroupResponse>(r.data) : null;
}
async getAll(): Promise<GroupResponse[]> {
const rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM groups ORDER BY updatedAt DESC`
);
return rows.map(r => safeParseJson<GroupResponse>(r.data)).filter((g): g is GroupResponse => Boolean(g));
}
async saveMembers(groupId: string, members: GroupMemberResponse[]): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
const now = new Date().toISOString();
await this.dataSource.run(`DELETE FROM group_members WHERE groupId = ?`, [String(groupId)]);
for (const m of members) {
await this.dataSource.run(
`INSERT OR REPLACE INTO group_members (groupId, userId, data, updatedAt) VALUES (?, ?, ?, ?)`,
[String(groupId), String(m.user_id), JSON.stringify(m), now]
);
}
});
}
async getMembers(groupId: string): Promise<GroupMemberResponse[]> {
const rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM group_members WHERE groupId = ? ORDER BY updatedAt DESC`, [String(groupId)]
);
return rows.map(r => safeParseJson<GroupMemberResponse>(r.data)).filter((m): m is GroupMemberResponse => Boolean(m));
}
}
export const groupCacheRepository = new GroupCacheRepository();

View File

@@ -1,152 +0,0 @@
import { localDataSource } from '../LocalDataSource';
import { CachedMessage } from '../types';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
export interface IMessageRepository {
saveMessage(message: CachedMessage): Promise<void>;
saveMessagesBatch(messages: CachedMessage[]): Promise<void>;
getByConversation(conversationId: string, limit?: number): Promise<CachedMessage[]>;
getMaxSeq(conversationId: string): Promise<number>;
getMinSeq(conversationId: string): Promise<number>;
getBeforeSeq(conversationId: string, beforeSeq: number, limit?: number): Promise<CachedMessage[]>;
getCount(conversationId: string): Promise<number>;
getCountBeforeSeq(conversationId: string, beforeSeq: number): Promise<number>;
markAsRead(messageId: string): Promise<void>;
markConversationAsRead(conversationId: string): Promise<void>;
delete(messageId: string): Promise<void>;
updateStatus(messageId: string, status: string, clearContent?: boolean): Promise<void>;
clearConversation(conversationId: string): Promise<void>;
search(keyword: string): Promise<CachedMessage[]>;
getStats(): Promise<{ totalMessages: number; totalConversations: number }>;
}
export class MessageRepository implements IMessageRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async saveMessage(message: CachedMessage): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO messages (id, conversationId, senderId, content, type, isRead, createdAt, seq, status, segments)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[message.id, message.conversationId, message.senderId, message.content || '',
message.type || 'text', message.isRead ? 1 : 0, message.createdAt,
message.seq || 0, message.status || 'normal',
message.segments ? JSON.stringify(message.segments) : null]
);
});
}
async saveMessagesBatch(messages: CachedMessage[]): Promise<void> {
if (!messages?.length) return;
await this.dataSource.enqueueWrite(async () => {
for (const msg of messages) {
await this.dataSource.run(
`INSERT OR REPLACE INTO messages (id, conversationId, senderId, content, type, isRead, createdAt, seq, status, segments)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[msg.id, msg.conversationId, msg.senderId, msg.content || '',
msg.type || 'text', msg.isRead ? 1 : 0, msg.createdAt,
msg.seq || 0, msg.status || 'normal',
msg.segments ? JSON.stringify(msg.segments) : null]
);
}
});
}
async getByConversation(conversationId: string, limit: number = 20): Promise<CachedMessage[]> {
const rows = await this.dataSource.query<any>(
`SELECT * FROM messages WHERE conversationId = ? ORDER BY seq DESC LIMIT ?`,
[conversationId, limit]
);
return rows.reverse().map(r => ({
...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined
}));
}
async getMaxSeq(conversationId: string): Promise<number> {
const r = await this.dataSource.getFirst<{ maxSeq: number }>(
`SELECT MAX(seq) as maxSeq FROM messages WHERE conversationId = ?`, [conversationId]
);
return r?.maxSeq || 0;
}
async getMinSeq(conversationId: string): Promise<number> {
const r = await this.dataSource.getFirst<{ minSeq: number }>(
`SELECT MIN(seq) as minSeq FROM messages WHERE conversationId = ?`, [conversationId]
);
return r?.minSeq || 0;
}
async getBeforeSeq(conversationId: string, beforeSeq: number, limit: number = 20): Promise<CachedMessage[]> {
const rows = await this.dataSource.query<any>(
`SELECT * FROM messages WHERE conversationId = ? AND seq < ? ORDER BY seq DESC LIMIT ?`,
[conversationId, beforeSeq, limit]
);
return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined })).reverse();
}
async getCount(conversationId: string): Promise<number> {
const r = await this.dataSource.getFirst<{ count: number }>(
`SELECT COUNT(*) as count FROM messages WHERE conversationId = ?`, [conversationId]
);
return r?.count || 0;
}
async getCountBeforeSeq(conversationId: string, beforeSeq: number): Promise<number> {
const r = await this.dataSource.getFirst<{ count: number }>(
`SELECT COUNT(*) as count FROM messages WHERE conversationId = ? AND seq < ?`, [conversationId, beforeSeq]
);
return r?.count || 0;
}
async markAsRead(messageId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`UPDATE messages SET isRead = 1 WHERE id = ?`, [messageId]);
});
}
async markConversationAsRead(conversationId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`UPDATE messages SET isRead = 1 WHERE conversationId = ?`, [conversationId]);
});
}
async delete(messageId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM messages WHERE id = ?`, [messageId]);
});
}
async updateStatus(messageId: string, status: string, clearContent: boolean = false): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
if (clearContent) {
await this.dataSource.run(
`UPDATE messages SET status = ?, content = '', segments = '[]' WHERE id = ?`,
[status, messageId]
);
} else {
await this.dataSource.run(`UPDATE messages SET status = ? WHERE id = ?`, [status, messageId]);
}
});
}
async clearConversation(conversationId: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM messages WHERE conversationId = ?`, [conversationId]);
});
}
async search(keyword: string): Promise<CachedMessage[]> {
const rows = await this.dataSource.query<any>(
`SELECT * FROM messages WHERE content LIKE ? ORDER BY createdAt DESC LIMIT 50`, [`%${keyword}%`]
);
return rows.map(r => ({ ...r, isRead: r.isRead === 1, segments: r.segments ? JSON.parse(r.segments) : undefined }));
}
async getStats(): Promise<{ totalMessages: number; totalConversations: number }> {
const msgCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM messages`);
const convCount = await this.dataSource.getFirst<{ count: number }>(`SELECT COUNT(*) as count FROM conversations`);
return { totalMessages: msgCount?.count || 0, totalConversations: convCount?.count || 0 };
}
}
export const messageRepository = new MessageRepository();

View File

@@ -1,44 +0,0 @@
import { localDataSource } from '../LocalDataSource';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
export interface IPostCacheRepository {
save(id: string, data: any): Promise<void>;
get(id: string): Promise<any | null>;
getAll(): Promise<any[]>;
delete(id: string): Promise<void>;
}
export class PostCacheRepository implements IPostCacheRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async save(id: string, data: any): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(id), JSON.stringify(data), new Date().toISOString()]
);
});
}
async get(id: string): Promise<any | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM posts_cache WHERE id = ?`, [String(id)]
);
return r?.data ? JSON.parse(r.data) : null;
}
async getAll(): Promise<any[]> {
const rows = await this.dataSource.query<{ data: string }>(
`SELECT data FROM posts_cache ORDER BY updatedAt DESC`
);
return rows.map(r => JSON.parse(r.data));
}
async delete(id: string): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM posts_cache WHERE id = ?`, [String(id)]);
});
}
}
export const postCacheRepository = new PostCacheRepository();

View File

@@ -1,81 +0,0 @@
import { localDataSource } from '../LocalDataSource';
import type { ILocalDataSource } from '@/data/datasources/interfaces';
import type { UserDTO } from '@/types/dto';
const safeParseJson = <T>(value: string): T | null => {
try { return JSON.parse(value) as T; } catch { return null; }
};
export interface IUserCacheRepository {
save(user: UserDTO): Promise<void>;
saveBatch(users: UserDTO[]): Promise<void>;
get(userId: string): Promise<UserDTO | null>;
getLatest(): Promise<UserDTO | null>;
saveCurrent(user: UserDTO): Promise<void>;
getCurrent(): Promise<UserDTO | null>;
clearCurrent(): Promise<void>;
}
export class UserCacheRepository implements IUserCacheRepository {
constructor(private dataSource: ILocalDataSource = localDataSource) {}
async save(user: UserDTO): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(user.id), JSON.stringify(user), new Date().toISOString()]
);
});
}
async saveBatch(users: UserDTO[]): Promise<void> {
if (!users?.length) return;
await this.dataSource.enqueueWrite(async () => {
const now = new Date().toISOString();
for (const u of users) {
await this.dataSource.run(
`INSERT OR REPLACE INTO users (id, data, updatedAt) VALUES (?, ?, ?)`,
[String(u.id), JSON.stringify(u), now]
);
}
});
}
async get(userId: string): Promise<UserDTO | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM users WHERE id = ?`, [String(userId)]
);
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
}
async getLatest(): Promise<UserDTO | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM users ORDER BY updatedAt DESC LIMIT 1`
);
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
}
async saveCurrent(user: UserDTO): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(
`INSERT OR REPLACE INTO current_user_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
['me', JSON.stringify(user), new Date().toISOString()]
);
});
}
async getCurrent(): Promise<UserDTO | null> {
const r = await this.dataSource.getFirst<{ data: string }>(
`SELECT data FROM current_user_cache WHERE id = 'me'`
);
return r?.data ? safeParseJson<UserDTO>(r.data) : null;
}
async clearCurrent(): Promise<void> {
await this.dataSource.enqueueWrite(async () => {
await this.dataSource.run(`DELETE FROM current_user_cache WHERE id = 'me'`);
});
}
}
export const userCacheRepository = new UserCacheRepository();

View File

@@ -1,5 +0,0 @@
export { IMessageRepository, MessageRepository, messageRepository } from './MessageRepository';
export { IConversationRepository, ConversationRepository, conversationRepository } from './ConversationRepository';
export { IUserCacheRepository, UserCacheRepository, userCacheRepository } from './UserCacheRepository';
export { IGroupCacheRepository, GroupCacheRepository, groupCacheRepository } from './GroupCacheRepository';
export { IPostCacheRepository, PostCacheRepository, postCacheRepository } from './PostCacheRepository';

View File

@@ -1,40 +0,0 @@
import type { SQLiteDatabase } from 'expo-sqlite';
export const CONVERSATIONS_TABLE = 'conversations';
export const CONVERSATION_CACHE_TABLE = 'conversation_cache';
export const CONVERSATION_LIST_CACHE_TABLE = 'conversation_list_cache';
export const CREATE_CONVERSATIONS_TABLE = `CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY NOT NULL,
participantId TEXT NOT NULL,
lastMessageId TEXT,
lastSeq INTEGER DEFAULT 0,
unreadCount INTEGER DEFAULT 0,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_CONVERSATION_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS conversation_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_CONVERSATION_LIST_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS conversation_list_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const MIGRATE_CONVERSATIONS_TABLE = async (db: SQLiteDatabase): Promise<void> => {
try {
const tableInfo = await db.getAllAsync<{ name: string }>(`PRAGMA table_info(conversations)`);
const columns = tableInfo.map(col => col.name);
if (!columns.includes('lastSeq')) {
await db.execAsync(`ALTER TABLE conversations ADD COLUMN lastSeq INTEGER DEFAULT 0`);
}
} catch (error) {
console.error('[ConversationSchema] 迁移失败:', error);
}
};

View File

@@ -1,20 +0,0 @@
export const GROUPS_TABLE = 'groups';
export const GROUP_MEMBERS_TABLE = 'group_members';
export const CREATE_GROUPS_TABLE = `CREATE TABLE IF NOT EXISTS groups (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_GROUP_MEMBERS_TABLE = `CREATE TABLE IF NOT EXISTS group_members (
groupId TEXT NOT NULL,
userId TEXT NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL,
PRIMARY KEY (groupId, userId)
)`;
export const CREATE_GROUP_INDEXES = [
`CREATE INDEX IF NOT EXISTS idx_group_members_groupId ON group_members(groupId)`,
];

View File

@@ -1,41 +0,0 @@
import type { SQLiteDatabase } from 'expo-sqlite';
export const MESSAGES_TABLE = 'messages';
export const CREATE_MESSAGES_TABLE = `CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY NOT NULL,
conversationId TEXT NOT NULL,
senderId TEXT NOT NULL,
content TEXT,
type TEXT DEFAULT 'text',
isRead INTEGER DEFAULT 0,
createdAt TEXT NOT NULL,
seq INTEGER DEFAULT 0,
status TEXT DEFAULT 'normal',
segments TEXT
)`;
export const CREATE_MESSAGES_INDEXES = [
`CREATE INDEX IF NOT EXISTS idx_messages_conversationId ON messages(conversationId)`,
`CREATE INDEX IF NOT EXISTS idx_messages_seq ON messages(seq)`,
`CREATE INDEX IF NOT EXISTS idx_messages_conversation_seq ON messages(conversationId, seq)`,
];
export const MIGRATE_MESSAGES_TABLE = async (db: SQLiteDatabase): Promise<void> => {
try {
const tableInfo = await db.getAllAsync<{ name: string }>(`PRAGMA table_info(messages)`);
const columns = tableInfo.map(col => col.name);
if (!columns.includes('seq')) {
await db.execAsync(`ALTER TABLE messages ADD COLUMN seq INTEGER DEFAULT 0`);
}
if (!columns.includes('status')) {
await db.execAsync(`ALTER TABLE messages ADD COLUMN status TEXT DEFAULT 'normal'`);
}
if (!columns.includes('segments')) {
await db.execAsync(`ALTER TABLE messages ADD COLUMN segments TEXT`);
}
} catch (error) {
console.error('[MessageSchema] 迁移失败:', error);
}
};

View File

@@ -1,66 +0,0 @@
import type { SQLiteDatabase } from 'expo-sqlite';
import {
CREATE_MESSAGES_TABLE,
CREATE_MESSAGES_INDEXES,
MIGRATE_MESSAGES_TABLE,
} from './MessageSchema';
import {
CREATE_CONVERSATIONS_TABLE,
CREATE_CONVERSATION_CACHE_TABLE,
CREATE_CONVERSATION_LIST_CACHE_TABLE,
MIGRATE_CONVERSATIONS_TABLE,
} from './ConversationSchema';
import {
CREATE_USERS_TABLE,
CREATE_CURRENT_USER_CACHE_TABLE,
} from './UserSchema';
import {
CREATE_GROUPS_TABLE,
CREATE_GROUP_MEMBERS_TABLE,
CREATE_GROUP_INDEXES,
} from './GroupSchema';
import {
CREATE_POSTS_CACHE_TABLE,
} from './PostSchema';
async function execWithRetry(db: SQLiteDatabase, sql: string, maxRetries = 5): Promise<void> {
for (let i = 0; i < maxRetries; i++) {
try {
await db.execAsync(sql);
return;
} catch (error) {
const msg = String(error);
if (msg.includes('database is locked') && i < maxRetries - 1) {
const delay = 200 * (i + 1);
console.warn(`[MigrationManager] 数据库锁定,${delay}ms 后重试 (${i + 1}/${maxRetries})`);
await new Promise(r => setTimeout(r, delay));
continue;
}
throw error;
}
}
}
export async function runMigrations(db: SQLiteDatabase): Promise<void> {
await execWithRetry(db, 'PRAGMA busy_timeout = 10000;');
await execWithRetry(db, CREATE_MESSAGES_TABLE);
await execWithRetry(db, CREATE_CONVERSATIONS_TABLE);
await execWithRetry(db, CREATE_CONVERSATION_CACHE_TABLE);
await execWithRetry(db, CREATE_CONVERSATION_LIST_CACHE_TABLE);
await execWithRetry(db, CREATE_USERS_TABLE);
await execWithRetry(db, CREATE_CURRENT_USER_CACHE_TABLE);
await execWithRetry(db, CREATE_GROUPS_TABLE);
await execWithRetry(db, CREATE_GROUP_MEMBERS_TABLE);
await execWithRetry(db, CREATE_POSTS_CACHE_TABLE);
for (const indexSql of CREATE_MESSAGES_INDEXES) {
await execWithRetry(db, indexSql);
}
for (const indexSql of CREATE_GROUP_INDEXES) {
await execWithRetry(db, indexSql);
}
await MIGRATE_MESSAGES_TABLE(db);
await MIGRATE_CONVERSATIONS_TABLE(db);
}

View File

@@ -1,9 +0,0 @@
export const POSTS_CACHE_TABLE = 'posts_cache';
export const CREATE_POSTS_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS posts_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_POSTS_INDEXES: string[] = [];

View File

@@ -1,16 +0,0 @@
export const USERS_TABLE = 'users';
export const CURRENT_USER_CACHE_TABLE = 'current_user_cache';
export const CREATE_USERS_TABLE = `CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_CURRENT_USER_CACHE_TABLE = `CREATE TABLE IF NOT EXISTS current_user_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`;
export const CREATE_USER_INDEXES: string[] = [];

View File

@@ -1,6 +0,0 @@
export * from './MessageSchema';
export * from './ConversationSchema';
export * from './UserSchema';
export * from './GroupSchema';
export * from './PostSchema';
export { runMigrations } from './MigrationManager';

View File

@@ -1,14 +0,0 @@
export interface CachedMessage {
id: string;
conversationId: string;
senderId: string;
content?: string;
type?: string;
isRead: boolean;
createdAt: string;
seq: number;
status: string;
segments?: any;
}
export { CachedMessage as default };

View File

@@ -1 +0,0 @@
export { CachedMessage } from './CachedMessage';

View File

@@ -111,16 +111,3 @@ export type {
UseDifferentialPostsResult,
DiffUpdatesInfo,
} from './useDifferentialPosts';
// ==================== 平台键盘 Hooks ====================
export {
usePlatformKeyboard,
useKeyboardAvoidingProps,
useKeyboardListener,
} from './usePlatformKeyboard';
export type {
KeyboardEventType,
PlatformKeyboardOptions,
PlatformKeyboardResult,
} from './usePlatformKeyboard';

View File

@@ -1,179 +0,0 @@
/**
* usePlatformKeyboard - 统一的跨平台键盘行为 Hook
*
* 消除散落在多个文件中的 Platform.OS 键盘事件判断
*
* 使用前(重复代码):
* ```tsx
* const showSub = Keyboard.addListener(
* Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow',
* handler
* );
* ```
*
* 使用后:
* ```tsx
* const { addListener } = usePlatformKeyboard();
* const showSub = addListener('show', handler);
* ```
*/
import { useEffect, useRef, useCallback } from 'react';
import { Keyboard, Platform, KeyboardEvent, EmitterSubscription } from 'react-native';
export type KeyboardEventType = 'show' | 'hide' | 'change';
export interface PlatformKeyboardOptions {
onShow?: (event: KeyboardEvent) => void;
onHide?: (event: KeyboardEvent) => void;
onChange?: (height: number) => void;
enabled?: boolean;
}
export interface PlatformKeyboardResult {
/** 键盘高度 */
keyboardHeight: number;
/** 键盘是否可见 */
isKeyboardVisible: boolean;
/** 平台最佳的 KeyboardAvoidingView behavior */
keyboardBehavior: 'padding' | 'height' | undefined;
/** 平台最佳的 keyboardVerticalOffset */
keyboardVerticalOffset: number;
}
/**
* 获取平台特定的键盘事件名称
*/
function getKeyboardEventName(type: KeyboardEventType): string {
switch (type) {
case 'show':
return Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow';
case 'hide':
return Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide';
case 'change':
return Platform.OS === 'ios' ? 'keyboardWillChangeFrame' : 'keyboardDidShow';
}
}
/**
* 平台键盘 Hook
*
* 自动处理跨平台键盘事件差异
*/
export function usePlatformKeyboard(
options: PlatformKeyboardOptions = {}
): PlatformKeyboardResult {
const { onShow, onHide, onChange, enabled = true } = options;
useEffect(() => {
if (!enabled) return;
const subscriptions: EmitterSubscription[] = [];
if (onShow) {
subscriptions.push(
Keyboard.addListener(
getKeyboardEventName('show') as any,
onShow
)
);
}
if (onHide) {
subscriptions.push(
Keyboard.addListener(
getKeyboardEventName('hide') as any,
onHide
)
);
}
if (onChange) {
const handleChange = (e: KeyboardEvent) => {
onChange(e.endCoordinates.height);
};
subscriptions.push(
Keyboard.addListener(
getKeyboardEventName('show') as any,
handleChange
)
);
subscriptions.push(
Keyboard.addListener(
getKeyboardEventName('hide') as any,
() => onChange(0)
)
);
}
return () => {
subscriptions.forEach(sub => sub.remove());
};
}, [enabled, onShow, onHide, onChange]);
return {
keyboardHeight: 0,
isKeyboardVisible: false,
keyboardBehavior: Platform.OS === 'ios' ? 'padding' : undefined,
keyboardVerticalOffset: Platform.OS === 'ios' ? 0 : 0,
};
}
/**
* 获取平台最佳的 KeyboardAvoidingView props
*
* 用于替代重复的 Platform.OS 三元判断:
* ```tsx
* // 之前
* behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
*
* // 之后
* const { keyboardProps } = useKeyboardAvoidingProps();
* <KeyboardAvoidingView {...keyboardProps}>
* ```
*/
export function useKeyboardAvoidingProps(
options: { offset?: number; defaultBehavior?: 'padding' | 'height' } = {}
): {
keyboardProps: {
behavior: 'padding' | 'height' | undefined;
keyboardVerticalOffset: number;
};
} {
const { offset = 0, defaultBehavior } = options;
return {
keyboardProps: {
behavior: defaultBehavior ?? (Platform.OS === 'ios' ? 'padding' : undefined),
keyboardVerticalOffset: Platform.OS === 'ios' ? offset : 0,
},
};
}
/**
* 键盘事件监听 Hook简化版
*
* 返回一个平台无关的 addListener 函数
*/
export function useKeyboardListener() {
const subscriptionsRef = useRef<EmitterSubscription[]>([]);
useEffect(() => {
return () => {
subscriptionsRef.current.forEach(sub => sub.remove());
subscriptionsRef.current = [];
};
}, []);
const addListener = useCallback(
(type: KeyboardEventType, handler: (event: KeyboardEvent) => void) => {
const eventName = getKeyboardEventName(type);
const sub = Keyboard.addListener(eventName as any, handler);
subscriptionsRef.current.push(sub);
return sub;
},
[]
);
return { addListener };
}

View File

@@ -14,7 +14,6 @@ import { postManager } from '../stores/postManager';
import { groupManager } from '../stores/groupManager';
import { userManager } from '../stores/userManager';
import { messageManager } from '../stores/messageManager';
import type { PostListTab } from '../stores/postListSources';
// ==================== 预取配置 ====================
@@ -135,7 +134,7 @@ const prefetchService = new PrefetchService();
* 预取帖子数据
* @param types 帖子类型数组
*/
function prefetchPosts(types: PostListTab[] = ['latest', 'hot']): void {
function prefetchPosts(types: string[] = ['latest', 'hot']): void {
types.forEach((type, index) => {
prefetchService.schedule({
key: `posts:${type}:1`,
@@ -153,7 +152,10 @@ function prefetchConversations(): void {
key: 'conversations:list',
executor: async () => {
await messageManager.initialize();
await messageManager.refreshConversations(true, 'prefetch');
await messageManager.requestConversationListRefresh('prefetch', {
force: true,
allowDefer: true,
});
return messageManager.getConversations();
},
priority: Priority.HIGH,
@@ -277,7 +279,7 @@ export function usePrefetch() {
const hasInitialPrefetched = useRef(false);
/** 预取帖子 */
const prefetchPostsData = useCallback((types?: PostListTab[]) => {
const prefetchPostsData = useCallback((types?: string[]) => {
prefetchPosts(types);
}, []);

View File

@@ -4,9 +4,8 @@
* @description 管理图片、视频、音频缓存,支持多种清理策略
*/
import { File, Directory, Paths } from 'expo-file-system';
import { File, Paths } from 'expo-file-system';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { Platform } from 'react-native';
import {
MediaType,
CleanupTrigger,
@@ -28,7 +27,7 @@ const CACHE_RECORD_PREFIX = 'media_cache_record_';
* 获取缓存目录路径
*/
function getCacheDir(): string {
const cachePath = Paths.cache.uri;
const cachePath = Paths.cache;
return `${cachePath}media_cache/`;
}
@@ -103,15 +102,13 @@ export class MediaCacheManager {
// 加载缓存记录
await this.loadCacheRecords();
// 启动时清理 (非web平台)
if (this.policy.cleanupOnStart && Platform.OS !== 'web') {
// 启动时清理
if (this.policy.cleanupOnStart) {
await this.cleanup(CleanupTrigger.STARTUP);
}
// 启动定期清理 (非web平台)
if (Platform.OS !== 'web') {
// 启动定期清理
this.startPeriodicCleanup();
}
this.isInitialized = true;
} catch (error) {
@@ -124,19 +121,12 @@ export class MediaCacheManager {
* 确保缓存目录存在
*/
private async ensureDirectories(): Promise<void> {
if (Platform.OS === 'web') return;
try {
const cacheDir = new Directory(Paths.cache, 'media_cache');
if (!await cacheDir.exists) {
await cacheDir.create();
}
const subDirs = ['images', 'videos', 'audios'];
for (const subDir of subDirs) {
const dir = new Directory(cacheDir, subDir);
if (!await dir.exists) {
await dir.create();
const dirs = [getImageDir(), getVideoDir(), getAudioDir()];
for (const dir of dirs) {
const dirFile = new File(dir);
if (!await dirFile.exists) {
await dirFile.create();
}
}
} catch (error) {
@@ -178,8 +168,6 @@ export class MediaCacheManager {
* 检查文件是否存在
*/
private async checkFileExists(path: string): Promise<boolean> {
if (Platform.OS === 'web') return false;
try {
const file = new File(path);
return await file.exists;
@@ -211,10 +199,6 @@ export class MediaCacheManager {
size?: number;
} = {}
): Promise<string> {
if (Platform.OS === 'web') {
return uri;
}
const { conversationId, messageId, size = 0 } = options;
// 生成唯一键

View File

@@ -1,83 +0,0 @@
/**
* Web Platform DOM Utilities
*
* 提供Web平台特定的DOM操作工具函数
* 解决跨平台代码中需要访问Web DOM的补丁问题
*/
import { Platform, Keyboard } from 'react-native';
/**
* Blur当前激活的DOM元素Web并收起键盘移动端
*
* - Web: blur DOM active element
* - iOS/Android: Keyboard.dismiss()
*
* 替代散落在多个文件中的重复代码
*/
export function blurActiveElement(): void {
if (Platform.OS === 'web') {
try {
const activeElement = (globalThis as any)?.document?.activeElement as
{ blur?: () => void } | undefined;
if (activeElement?.blur) {
activeElement.blur();
}
} catch {
// 安全忽略某些环境下document可能不可访问
}
} else {
Keyboard.dismiss();
}
}
/**
* 检查当前是否有激活的DOM元素仅Web平台
*/
export function hasActiveElement(): boolean {
if (Platform.OS !== 'web') return false;
try {
const doc = (globalThis as any)?.document;
return !!doc?.activeElement && doc.activeElement !== doc.body;
} catch {
return false;
}
}
/**
* 获取当前激活元素的类型仅Web平台
* 用于判断是否需要特殊处理(如输入框)
*/
export function getActiveElementType(): string | null {
if (Platform.OS !== 'web') return null;
try {
const activeElement = (globalThis as any)?.document?.activeElement as
{ tagName?: string; type?: string } | undefined;
if (!activeElement) return null;
const tagName = activeElement.tagName?.toLowerCase();
const type = activeElement.type?.toLowerCase();
return tagName === 'input' ? `input:${type || 'text'}` : tagName || null;
} catch {
return null;
}
}
/**
* 判断当前激活元素是否为输入类型
*/
export function isInputFocused(): boolean {
const elementType = getActiveElementType();
if (!elementType) return false;
return (
elementType.startsWith('input:') ||
elementType === 'textarea' ||
elementType === 'select'
);
}

View File

@@ -1,12 +0,0 @@
/**
* Platform Infrastructure
*
* 平台相关的基础设施代码
*/
export {
blurActiveElement,
hasActiveElement,
getActiveElementType,
isInputFocused,
} from './domUtils';

View File

@@ -61,10 +61,6 @@ export function hrefProfileBlocked(): string {
return '/profile/blocked-users';
}
export function hrefProfileChatSettings(): string {
return '/profile/chat-settings';
}
export function hrefProfileMyPosts(): string {
return '/profile/my-posts';
}
@@ -159,62 +155,3 @@ export function hrefAuthRegister(): string {
export function hrefAuthForgot(): string {
return '/forgot-password';
}
export function hrefAuthWelcome(): string {
return '/welcome';
}
// ==================== Materials (学习资料) ====================
export function hrefMaterials(): string {
return '/apps/materials';
}
export function hrefMaterialSubject(subjectId: string): string {
return `/apps/materials/subject?subjectId=${encodeURIComponent(subjectId)}`;
}
export function hrefMaterialDetail(materialId: string): string {
return `/apps/materials/detail?materialId=${encodeURIComponent(materialId)}`;
}
// ==================== Profile Settings (设置相关) ====================
export function hrefProfileAbout(): string {
return '/profile/about';
}
export function hrefProfileTerms(): string {
return '/terms';
}
export function hrefProfilePrivacy(): string {
return '/privacy';
}
export function hrefProfileVerification(): string {
return '/profile/verification';
}
export function hrefVerificationGuide(): string {
return '/verification-guide';
}
export function hrefVerificationForm(identity?: string): string {
if (identity) {
return `/verification-form?identity=${encodeURIComponent(identity)}`;
}
return '/verification-form';
}
export function hrefProfileDataStorage(): string {
return '/profile/data-storage';
}
export function hrefProfilePrivacySettings(): string {
return '/profile/privacy-settings';
}
export function hrefProfileDeletion(): string {
return '/profile/account-deletion';
}

View File

@@ -18,7 +18,7 @@ import {
type AppColors,
} from '../../theme';
import * as hrefs from '../../navigation/hrefs';
import { Text } from '../../components/common';
import { Text, ResponsiveContainer } from '../../components/common';
import { useResponsive, useResponsiveSpacing, useBreakpointGTE } from '../../hooks/useResponsive';
type AppItem = {
@@ -37,18 +37,8 @@ const APP_ENTRIES: AppItem[] = [
icon: 'calendar-week',
href: hrefs.hrefSchedule(),
},
{
id: 'materials',
title: '学习资料',
subtitle: '按学科分类的学习资源网盘',
icon: 'folder-multiple',
href: hrefs.hrefMaterials(),
},
];
// 应用卡片最大宽度设置
const APP_CARD_MAX_WIDTH = 720;
export const AppsScreen: React.FC = () => {
const colors = useAppColors();
const resolvedScheme = useResolvedColorScheme();
@@ -66,9 +56,6 @@ export const AppsScreen: React.FC = () => {
shadowOpacity: 0.06,
shadowRadius: 8,
elevation: 2,
maxWidth: APP_CARD_MAX_WIDTH,
alignSelf: 'center' as const,
width: '100%' as const,
}),
[colors]
);
@@ -142,6 +129,17 @@ export const AppsScreen: React.FC = () => {
</View>
</View>
{isWideScreen ? (
<ResponsiveContainer maxWidth={720}>
<ScrollView
style={styles.scroll}
contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}
showsVerticalScrollIndicator={false}
>
{renderTiles()}
</ScrollView>
</ResponsiveContainer>
) : (
<ScrollView
style={styles.scroll}
contentContainerStyle={[
@@ -152,6 +150,7 @@ export const AppsScreen: React.FC = () => {
>
{renderTiles()}
</ScrollView>
)}
</SafeAreaView>
);
};

View File

@@ -1,15 +1,4 @@
/**
* 忘记密码页 ForgotPasswordScreen简洁表单样式
* 胡萝卜BBS - 找回密码
*
* 设计风格:
* - 纯白背景,扁平化设计
* - 增大间距
* - 输入框带灰色背景填充
* - 橙色圆角按钮
*/
import React, { useEffect, useMemo, useState, useRef } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import {
View,
Text,
@@ -20,22 +9,123 @@ import {
Platform,
ScrollView,
ActivityIndicator,
Animated,
StatusBar,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors, useResolvedColorScheme, type AppColors } from '../../theme';
import { LinearGradient } from 'expo-linear-gradient';
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
import { authService, resolveAuthApiError } from '../../services/authService';
import { showPrompt } from '../../services/promptService';
function createForgotPasswordStyles(colors: AppColors) {
return StyleSheet.create({
container: { flex: 1 },
gradient: { flex: 1 },
keyboardView: { flex: 1 },
scrollContent: {
flexGrow: 1,
justifyContent: 'center',
paddingHorizontal: spacing.xl,
paddingVertical: spacing['2xl'],
},
formCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius['2xl'],
padding: spacing.xl,
...shadows.md,
},
title: {
fontSize: fontSizes['2xl'],
fontWeight: '700',
color: colors.text.primary,
textAlign: 'center',
marginBottom: spacing.xs,
},
subtitle: {
fontSize: fontSizes.sm,
color: colors.text.secondary,
textAlign: 'center',
marginBottom: spacing.lg,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
borderRadius: borderRadius.lg,
borderWidth: 1.5,
borderColor: colors.divider,
paddingHorizontal: spacing.md,
height: 52,
marginBottom: spacing.md,
},
inputIcon: {
marginRight: spacing.sm,
},
input: {
flex: 1,
fontSize: fontSizes.md,
color: colors.text.primary,
},
codeRow: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
marginBottom: spacing.md,
},
codeInput: {
flex: 1,
marginBottom: 0,
},
sendCodeButton: {
height: 52,
minWidth: 110,
borderRadius: borderRadius.lg,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: spacing.md,
},
sendCodeButtonDisabled: {
opacity: 0.6,
},
sendCodeButtonText: {
color: '#fff',
fontSize: fontSizes.sm,
fontWeight: '600',
},
submitButton: {
height: 50,
borderRadius: borderRadius.lg,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginTop: spacing.sm,
},
submitButtonDisabled: {
opacity: 0.6,
},
submitButtonText: {
color: '#fff',
fontSize: fontSizes.lg,
fontWeight: '700',
},
backButton: {
alignItems: 'center',
marginTop: spacing.md,
},
backButtonText: {
color: colors.primary.main,
fontSize: fontSizes.md,
fontWeight: '500',
},
});
}
export const ForgotPasswordScreen: React.FC = () => {
const colors = useAppColors();
const resolved = useResolvedColorScheme();
const styles = useMemo(() => createForgotPasswordStyles(colors), [colors]);
const router = useRouter();
const [email, setEmail] = useState('');
const [verificationCode, setVerificationCode] = useState('');
const [newPassword, setNewPassword] = useState('');
@@ -46,26 +136,6 @@ export const ForgotPasswordScreen: React.FC = () => {
const [loading, setLoading] = useState(false);
const [countdown, setCountdown] = useState(0);
// 动画值
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(20)).current;
// 启动入场动画
useEffect(() => {
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 400,
useNativeDriver: true,
}),
Animated.timing(slideAnim, {
toValue: 0,
duration: 400,
useNativeDriver: true,
}),
]).start();
}, []);
useEffect(() => {
if (countdown <= 0) {
return;
@@ -109,20 +179,8 @@ export const ForgotPasswordScreen: React.FC = () => {
showPrompt({ title: '提示', message: '请输入验证码', type: 'warning' });
return;
}
if (newPassword.length < 8) {
showPrompt({ title: '提示', message: '新密码至少 8 位', type: 'warning' });
return;
}
if (!/[A-Z]/.test(newPassword)) {
showPrompt({ title: '提示', message: '新密码必须包含大写字母', type: 'warning' });
return;
}
if (!/[a-z]/.test(newPassword)) {
showPrompt({ title: '提示', message: '新密码必须包含小写字母', type: 'warning' });
return;
}
if (!/[0-9]/.test(newPassword)) {
showPrompt({ title: '提示', message: '新密码必须包含数字', type: 'warning' });
if (newPassword.length < 6) {
showPrompt({ title: '提示', message: '新密码至少 6 位', type: 'warning' });
return;
}
if (newPassword !== confirmPassword) {
@@ -150,61 +208,32 @@ export const ForgotPasswordScreen: React.FC = () => {
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle={resolved === 'dark' ? 'light-content' : 'dark-content'} backgroundColor={colors.background.paper} />
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<ScrollView
contentContainerStyle={styles.scrollContent}
keyboardShouldPersistTaps="handled"
showsVerticalScrollIndicator={false}
>
<Animated.View
style={[
styles.content,
{
opacity: fadeAnim,
transform: [{ translateY: slideAnim }],
},
]}
>
{/* 标题区域 */}
<View style={styles.titleSection}>
<LinearGradient colors={['#FF6B35', '#FF8F66', '#FFB088']} style={styles.gradient}>
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.keyboardView}>
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
<View style={styles.formCard}>
<Text style={styles.title}></Text>
<View style={styles.underline} />
<Text style={styles.subtitle}></Text>
</View>
<Text style={styles.subtitle}></Text>
{/* 表单区域 */}
<View style={styles.formSection}>
{/* 邮箱输入框 */}
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}></Text>
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="email-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="请输入邮箱地址"
placeholder="邮箱"
placeholderTextColor={colors.text.hint}
value={email}
onChangeText={setEmail}
autoCapitalize="none"
keyboardType="email-address"
/>
{email.length > 0 && validateEmail(email) && (
<MaterialCommunityIcons name="check-circle" size={20} color={colors.primary.main} style={styles.checkIcon} />
)}
</View>
</View>
{/* 验证码 */}
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}></Text>
<View style={styles.codeRow}>
<View style={[styles.inputWrapper, styles.codeInputWrapper]}>
<View style={[styles.inputWrapper, styles.codeInput]}>
<MaterialCommunityIcons name="shield-key-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="6位验证码"
placeholder="验证码"
placeholderTextColor={colors.text.hint}
value={verificationCode}
onChangeText={setVerificationCode}
@@ -216,242 +245,64 @@ export const ForgotPasswordScreen: React.FC = () => {
style={[styles.sendCodeButton, (sendingCode || countdown > 0) && styles.sendCodeButtonDisabled]}
onPress={handleSendCode}
disabled={sendingCode || countdown > 0}
activeOpacity={0.8}
>
{sendingCode ? (
<ActivityIndicator size="small" color={colors.primary.main} />
<ActivityIndicator size="small" color="#fff" />
) : (
<Text style={styles.sendCodeButtonText}>{countdown > 0 ? `${countdown}s` : '获取验证码'}</Text>
<Text style={styles.sendCodeButtonText}>{countdown > 0 ? `${countdown}s` : '发送验证码'}</Text>
)}
</TouchableOpacity>
</View>
</View>
{/* 新密码输入框 */}
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}></Text>
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="lock-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="至少6位"
placeholder="新密码(至少6位"
placeholderTextColor={colors.text.hint}
value={newPassword}
onChangeText={setNewPassword}
secureTextEntry={!showPassword}
autoCapitalize="none"
/>
<TouchableOpacity
onPress={() => setShowPassword((v) => !v)}
style={styles.eyeButton}
>
<MaterialCommunityIcons
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={colors.text.hint}
/>
<TouchableOpacity onPress={() => setShowPassword((v) => !v)}>
<MaterialCommunityIcons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={20} color={colors.text.hint} />
</TouchableOpacity>
</View>
</View>
{/* 确认密码输入框 */}
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}></Text>
<View style={styles.inputWrapper}>
<MaterialCommunityIcons name="lock-check-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
<TextInput
style={styles.input}
placeholder="再次输入新密码"
placeholder="确认新密码"
placeholderTextColor={colors.text.hint}
value={confirmPassword}
onChangeText={setConfirmPassword}
secureTextEntry={!showConfirmPassword}
autoCapitalize="none"
returnKeyType="done"
onSubmitEditing={handleResetPassword}
/>
<TouchableOpacity
onPress={() => setShowConfirmPassword((v) => !v)}
style={styles.eyeButton}
>
<MaterialCommunityIcons
name={showConfirmPassword ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={colors.text.hint}
/>
<TouchableOpacity onPress={() => setShowConfirmPassword((v) => !v)}>
<MaterialCommunityIcons name={showConfirmPassword ? 'eye-off-outline' : 'eye-outline'} size={20} color={colors.text.hint} />
</TouchableOpacity>
</View>
</View>
{/* 重置密码按钮 */}
<TouchableOpacity
style={[styles.submitButton, loading && styles.submitButtonDisabled]}
onPress={handleResetPassword}
disabled={loading}
activeOpacity={0.9}
>
{loading ? (
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.submitButtonText}></Text>
)}
{loading ? <ActivityIndicator size="small" color="#fff" /> : <Text style={styles.submitButtonText}></Text>}
</TouchableOpacity>
{/* 返回登录 */}
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
activeOpacity={0.8}
>
<MaterialCommunityIcons name="arrow-left" size={18} color={colors.primary.main} style={{ marginRight: 4 }} />
<TouchableOpacity style={styles.backButton} onPress={() => router.back()}>
<Text style={styles.backButtonText}></Text>
</TouchableOpacity>
</View>
</Animated.View>
</ScrollView>
</KeyboardAvoidingView>
</LinearGradient>
</SafeAreaView>
);
};
function createForgotPasswordStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.paper,
},
keyboardView: {
flex: 1,
},
scrollContent: {
flexGrow: 1,
paddingHorizontal: 28,
paddingTop: 50,
paddingBottom: 40,
},
content: {
flex: 1,
maxWidth: 400,
width: '100%',
alignSelf: 'center',
},
// 标题区域
titleSection: {
marginBottom: 40,
marginTop: 20,
},
title: {
fontSize: 32,
fontWeight: '700',
color: colors.text.primary,
lineHeight: 40,
},
underline: {
width: 40,
height: 4,
backgroundColor: colors.primary.main,
borderRadius: 2,
marginTop: 12,
marginBottom: 16,
},
subtitle: {
fontSize: 16,
color: colors.text.secondary,
},
// 表单区域
formSection: {
marginBottom: 24,
},
// 输入框
inputContainer: {
marginBottom: 20,
},
inputLabel: {
fontSize: 14,
fontWeight: '500',
color: colors.text.secondary,
marginBottom: 10,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
borderRadius: 14,
paddingHorizontal: 18,
height: 56,
},
input: {
flex: 1,
fontSize: 16,
color: colors.text.primary,
height: 56,
},
checkIcon: {
marginLeft: 8,
},
eyeButton: {
padding: 4,
marginLeft: 4,
},
// 验证码行
codeRow: {
flexDirection: 'row',
gap: 12,
},
codeInputWrapper: {
flex: 1,
},
sendCodeButton: {
height: 56,
paddingHorizontal: 16,
backgroundColor: colors.primary.light + '20',
borderRadius: 14,
alignItems: 'center',
justifyContent: 'center',
minWidth: 100,
},
sendCodeButtonDisabled: {
opacity: 0.5,
},
sendCodeButtonText: {
fontSize: 14,
fontWeight: '600',
color: colors.primary.main,
},
// 提交按钮
submitButton: {
height: 56,
borderRadius: 14,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginTop: 8,
},
submitButtonDisabled: {
opacity: 0.7,
},
submitButtonText: {
color: colors.text.inverse,
fontSize: 17,
fontWeight: '600',
},
// 返回按钮
backButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 24,
},
backButtonText: {
color: colors.primary.main,
fontSize: 15,
fontWeight: '500',
},
});
}
export default ForgotPasswordScreen;

View File

@@ -1,12 +1,10 @@
/**
* 登录页 LoginScreen简洁表单样式
* 登录页 LoginScreen响应式适配 - 分栏布局
* 胡萝卜BBS - 用户登录
*
* 设计风格
* - 纯白背景,扁平化设计
* - 增大间距,避免页面太空
* - 输入框带灰色背景填充
* - 橙色圆角按钮
* 布局规则
* - 屏幕宽度 < 768px单栏布局橙色渐变背景
* - 屏幕宽度 >= 768px分栏布局左侧橙色右侧中性灰
*/
import React, { useState, useEffect, useRef, useMemo } from 'react';
@@ -26,45 +24,83 @@ import {
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors, useResolvedColorScheme, type AppColors } from '../../theme';
import { LinearGradient } from 'expo-linear-gradient';
import { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
import { useAuthStore } from '../../stores';
import * as hrefs from '../../navigation/hrefs';
import { useResponsive } from '../../hooks';
import { useResponsive, useResponsiveValue } from '../../hooks';
import { showPrompt } from '../../services/promptService';
// 分栏布局断点
const SPLIT_BREAKPOINT = 768;
export const LoginScreen: React.FC = () => {
const colors = useAppColors();
const resolved = useResolvedColorScheme();
const styles = useMemo(() => createLoginStyles(colors), [colors]);
const router = useRouter();
const login = useAuthStore((state) => state.login);
const isAuthenticated = useAuthStore((state) => state.isAuthenticated);
const storeError = useAuthStore((state) => state.error);
const setStoreError = useAuthStore((state) => state.setError);
const { isLandscape } = useResponsive();
// 响应式布局
const { isLandscape, width } = useResponsive();
// 是否使用分栏布局
const isSplitLayout = width >= SPLIT_BREAKPOINT;
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [agreedToTerms, setAgreedToTerms] = useState(false);
const [errorMsg, setErrorMsg] = useState<string | null>(null);
// 动画值
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideAnim = useRef(new Animated.Value(20)).current;
const slideAnim = useRef(new Animated.Value(50)).current;
const scaleAnim = useRef(new Animated.Value(0.9)).current;
const inputFadeAnim = useRef(new Animated.Value(0)).current;
// 响应式值 - 大屏模式单独放大Logo尺寸更显大气
const logoSize = useResponsiveValue({
xs: 64, sm: 72, md: 80,
lg: isSplitLayout ? 140 : 96, // 大屏模式Logo图标进一步放大
xl: isSplitLayout ? 160 : 110
});
const logoContainerSize = useResponsiveValue({
xs: 110, sm: 120, md: 130,
lg: isSplitLayout ? 200 : 150, // 大屏模式Logo容器进一步放大
xl: isSplitLayout ? 220 : 170
});
const appNameSize = useResponsiveValue({
xs: 28, sm: 30, md: 32,
lg: isSplitLayout ? 60 : 36, // 大屏模式标题文字大幅放大
xl: isSplitLayout ? 72 : 40
});
const formMaxWidth = isSplitLayout ? 420 : undefined;
// 启动入场动画
useEffect(() => {
Animated.sequence([
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 400,
duration: 600,
useNativeDriver: true,
}),
Animated.timing(scaleAnim, {
toValue: 1,
duration: 600,
useNativeDriver: true,
}),
]),
Animated.timing(slideAnim, {
toValue: 0,
duration: 500,
useNativeDriver: true,
}),
Animated.timing(inputFadeAnim, {
toValue: 1,
duration: 400,
useNativeDriver: true,
}),
@@ -81,13 +117,20 @@ export const LoginScreen: React.FC = () => {
showPrompt({ title: '提示', message: '请输入密码', type: 'warning' });
return false;
}
if (!agreedToTerms) {
showPrompt({ title: '提示', message: '请同意用户协议和隐私政策', type: 'warning' });
if (password.length < 6) {
showPrompt({ title: '提示', message: '密码长度不能少于6位', type: 'warning' });
return false;
}
return true;
};
// store error 同步到本地
useEffect(() => {
if (storeError) {
setErrorMsg(storeError);
}
}, [storeError]);
useEffect(() => {
if (isAuthenticated) {
router.replace(hrefs.hrefHome());
@@ -95,6 +138,7 @@ export const LoginScreen: React.FC = () => {
}, [isAuthenticated, router]);
const clearError = () => {
setErrorMsg(null);
setStoreError(null);
};
@@ -108,11 +152,11 @@ export const LoginScreen: React.FC = () => {
const success = await login({ username, password });
if (!success) {
if (!useAuthStore.getState().error) {
showPrompt({ title: '登录失败', message: '请检查用户名和密码', type: 'error' });
setErrorMsg('登录失败,请稍后重试');
}
}
} catch (error: any) {
showPrompt({ title: '登录失败', message: error.message || '请检查用户名和密码', type: 'error' });
setErrorMsg(error.message || '登录失败,请检查用户名和密码');
} finally {
setLoading(false);
}
@@ -123,46 +167,86 @@ export const LoginScreen: React.FC = () => {
router.push(hrefs.hrefAuthRegister());
};
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle={resolved === 'dark' ? 'light-content' : 'dark-content'} backgroundColor={colors.background.paper} />
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<ScrollView
contentContainerStyle={[
styles.scrollContent,
isLandscape && styles.scrollContentLandscape,
]}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
// 渲染左侧面板Logo和品牌信息- 优化大屏布局
const renderLeftPanel = () => (
<Animated.View
style={[
styles.content,
styles.leftPanel,
isSplitLayout && styles.splitLeftPanel, // 大屏模式专用布局
{
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 }],
},
]}
>
{/* 标题区域 - 增大间距 */}
<View style={styles.titleSection}>
<Text style={styles.title}></Text>
<View style={styles.underline} />
<Text style={styles.subtitle}></Text>
</View>
<Text style={styles.formTitle}></Text>
{/* 表单区域 */}
<View style={styles.formSection}>
{/* 用户名输入框 */}
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}></Text>
<View style={styles.inputWrapper}>
<MaterialCommunityIcons
name="account-outline"
size={22}
color={colors.primary.main}
style={styles.inputIcon}
/>
<TextInput
style={styles.input}
placeholder="请输入用户名"
placeholder="用户名 / 邮箱 / 手机号"
placeholderTextColor={colors.text.hint}
value={username}
onChangeText={(v) => { setUsername(v); clearError(); }}
@@ -171,15 +255,29 @@ export const LoginScreen: React.FC = () => {
returnKeyType="next"
/>
{username.length > 0 && (
<MaterialCommunityIcons name="check-circle" size={20} color={colors.primary.main} style={styles.checkIcon} />
<TouchableOpacity
onPress={() => setUsername('')}
style={styles.clearButton}
>
<MaterialCommunityIcons
name="close-circle"
size={20}
color={colors.text.hint}
/>
</TouchableOpacity>
)}
</View>
</View>
{/* 密码输入框 */}
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}></Text>
<View style={styles.inputWrapper}>
<MaterialCommunityIcons
name="lock-outline"
size={22}
color={colors.primary.main}
style={styles.inputIcon}
/>
<TextInput
style={styles.input}
placeholder="请输入密码"
@@ -193,7 +291,7 @@ export const LoginScreen: React.FC = () => {
/>
<TouchableOpacity
onPress={() => setShowPassword(!showPassword)}
style={styles.eyeButton}
style={styles.clearButton}
>
<MaterialCommunityIcons
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
@@ -212,171 +310,343 @@ export const LoginScreen: React.FC = () => {
<Text style={styles.forgotPasswordText}></Text>
</TouchableOpacity>
{/* 服务条款勾选 */}
<View style={styles.termsContainer}>
<TouchableOpacity
onPress={() => setAgreedToTerms(!agreedToTerms)}
activeOpacity={0.8}
style={styles.checkboxWrapper}
>
{/* 内联错误提示 */}
{errorMsg && (
<View style={styles.errorBox}>
<MaterialCommunityIcons
name={agreedToTerms ? 'checkbox-marked' : 'checkbox-blank-outline'}
size={22}
color={agreedToTerms ? colors.primary.main : colors.text.hint}
name="alert-circle-outline"
size={16}
color={colors.error.dark}
style={{ marginRight: 6, marginTop: 1 }}
/>
</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>
<Text style={styles.errorText}>{errorMsg}</Text>
</View>
)}
{/* 登录按钮 */}
<TouchableOpacity
style={[styles.loginButton, loading && styles.loginButtonDisabled]}
onPress={handleLogin}
disabled={loading}
activeOpacity={0.9}
activeOpacity={0.8}
>
<LinearGradient
colors={[colors.primary.main, colors.primary.light]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.loginButtonGradient}
>
{loading ? (
<ActivityIndicator size="small" color={colors.text.inverse} />
<ActivityIndicator size="small" color="#FFF" />
) : (
<Text style={styles.loginButtonText}> </Text>
)}
</LinearGradient>
</TouchableOpacity>
</View>
</Animated.View>
{/* 底部注册提示 */}
<View style={styles.footer}>
<Animated.View
style={[
styles.footerSection,
{ opacity: inputFadeAnim },
]}
>
<Text style={styles.footerText}></Text>
<TouchableOpacity onPress={handleGoToRegister}>
<Text style={styles.registerLink}></Text>
</TouchableOpacity>
</View>
</Animated.View>
</View>
);
// 渲染分栏布局
const renderSplitLayout = () => (
<View style={styles.splitContainer}>
{/* 左侧橙色面板 */}
<LinearGradient
colors={[colors.primary.main, colors.primary.light, '#FFB088']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.leftPanelContainer}
>
{/* 装饰性背景元素 */}
<View style={styles.decorCircle1} />
<View style={styles.decorCircle2} />
<StatusBar barStyle="light-content" />
<SafeAreaView style={styles.leftPanelSafeArea}>
{renderLeftPanel()}
</SafeAreaView>
</LinearGradient>
{/* 右侧中性灰面板 */}
<View style={styles.rightPanelContainer}>
<StatusBar barStyle="dark-content" />
<SafeAreaView style={styles.rightPanelSafeArea}>
{/* 核心修复用View包裹确保垂直+水平居中 */}
<View style={styles.splitRightContentWrapper}>
{renderFormContent()}
</View>
</SafeAreaView>
</View>
</View>
);
// 渲染单栏布局
const renderSingleLayout = () => (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" />
<LinearGradient
colors={[colors.primary.main, colors.primary.light, '#FFB088']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.gradient}
>
<KeyboardAvoidingView
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
style={styles.keyboardView}
>
<ScrollView
contentContainerStyle={[
styles.scrollContent,
isLandscape && styles.scrollContentLandscape,
]}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
{/* 装饰性背景元素 */}
<View style={styles.decorCircle1} />
<View style={styles.decorCircle2} />
{/* 头部 */}
<Animated.View
style={[
styles.headerSection,
isLandscape && styles.headerSectionLandscape,
{
opacity: fadeAnim,
transform: [{ scale: scaleAnim }],
},
]}
>
<View style={[
styles.logoContainer,
{ width: logoContainerSize, height: logoContainerSize, borderRadius: logoContainerSize / 2 }
]}>
<MaterialCommunityIcons
name="carrot"
size={logoSize}
color="#FFF"
/>
</View>
<Text style={[styles.appName, { fontSize: appNameSize }]}></Text>
<Text style={styles.subtitle}></Text>
</Animated.View>
{/* 表单 */}
{renderFormContent()}
</ScrollView>
</KeyboardAvoidingView>
</LinearGradient>
</SafeAreaView>
);
return isSplitLayout ? renderSplitLayout() : renderSingleLayout();
};
function createLoginStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.paper,
},
gradient: {
flex: 1,
},
keyboardView: {
flex: 1,
},
// 单栏布局内容
scrollContent: {
flexGrow: 1,
paddingHorizontal: 28,
paddingTop: 60,
paddingBottom: 40,
paddingHorizontal: spacing.xl,
paddingVertical: spacing['2xl'],
},
scrollContentCentered: {
alignItems: 'center',
justifyContent: 'center',
flex: 1,
},
scrollContentLandscape: {
paddingTop: 30,
paddingVertical: spacing.lg,
},
content: {
flex: 1,
maxWidth: 400,
width: '100%',
alignSelf: 'center',
// 装饰性背景元素
decorCircle1: {
position: 'absolute',
top: -100,
right: -100,
width: 300,
height: 300,
borderRadius: 150,
backgroundColor: 'rgba(255,255,255,0.1)',
},
// 标题区域 - 增大间距
titleSection: {
marginBottom: 48,
marginTop: 20,
decorCircle2: {
position: 'absolute',
bottom: 100,
left: -150,
width: 250,
height: 250,
borderRadius: 125,
backgroundColor: 'rgba(255,255,255,0.08)',
},
title: {
fontSize: 32,
fontWeight: '700',
color: colors.text.primary,
lineHeight: 40,
// 头部区域
headerSection: {
alignItems: 'center',
marginTop: spacing['3xl'],
marginBottom: spacing['3xl'],
},
underline: {
width: 40,
height: 4,
backgroundColor: colors.primary.main,
borderRadius: 2,
marginTop: 12,
marginBottom: 16,
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: 16,
color: colors.text.secondary,
fontSize: fontSizes.md,
color: 'rgba(255,255,255,0.9)',
textAlign: 'center',
paddingHorizontal: spacing.xl,
},
// 表单区域
formSection: {
marginBottom: 32,
},
// 输入框
inputContainer: {
marginBottom: 24,
},
inputLabel: {
fontSize: 14,
// 大屏模式副标题样式 - 大幅放大
splitSubtitle: {
fontSize: 24, // 放大副标题
marginBottom: spacing['6xl'], // 增加底部间距
lineHeight: 36,
paddingHorizontal: spacing['4xl'],
letterSpacing: 1.5,
fontWeight: '500',
color: colors.text.secondary,
marginBottom: 10,
},
// 表单卡片(基础样式,无阴影)
formCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius['2xl'],
padding: spacing['2xl'],
marginBottom: spacing.xl,
},
// 小屏模式:原有默认阴影
normalFormCardShadow: {
...shadows.md,
},
// 大屏模式:专用全向阴影
splitFormCardShadow: {
// iOS 全向柔和阴影
shadowColor: '#000',
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.12,
shadowRadius: 12,
// Android 高程 + 轻微边框(模拟顶部阴影)
elevation: 8,
// 新增:极细微的顶部边框,强化区分度
borderTopWidth: 1,
borderTopColor: 'rgba(0,0,0,0.05)',
},
formTitle: {
fontSize: fontSizes['2xl'],
fontWeight: '700',
color: colors.text.primary,
marginBottom: spacing.xl,
textAlign: 'center',
},
inputContainer: {
marginBottom: spacing.lg,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
borderRadius: 14,
paddingHorizontal: 18,
borderRadius: borderRadius.lg,
borderWidth: 1.5,
borderColor: colors.divider,
paddingHorizontal: spacing.md,
height: 56,
},
inputIcon: {
marginRight: spacing.sm,
},
input: {
flex: 1,
fontSize: 16,
fontSize: fontSizes.md,
color: colors.text.primary,
height: 56,
},
checkIcon: {
marginLeft: 8,
clearButton: {
padding: spacing.xs,
},
eyeButton: {
padding: 4,
marginLeft: 4,
},
// 忘记密码
forgotPassword: {
alignSelf: 'flex-end',
marginBottom: 28,
marginTop: -8,
marginBottom: spacing.md,
},
forgotPasswordText: {
fontSize: 14,
fontSize: fontSizes.sm,
color: colors.primary.main,
fontWeight: '500',
fontWeight: '600',
},
errorBox: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: colors.accent.light,
borderRadius: borderRadius.md,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
marginBottom: spacing.md,
borderLeftWidth: 3,
borderLeftColor: colors.accent.main,
},
errorText: {
flex: 1,
fontSize: fontSizes.sm,
color: colors.accent.dark,
lineHeight: 20,
},
// 登录按钮
loginButton: {
height: 56,
borderRadius: 14,
backgroundColor: colors.primary.main,
borderRadius: borderRadius.lg,
overflow: 'hidden',
...shadows.md,
},
loginButtonGradient: {
height: 52,
alignItems: 'center',
justifyContent: 'center',
},
@@ -384,59 +654,116 @@ function createLoginStyles(colors: AppColors) {
opacity: 0.7,
},
loginButtonText: {
fontSize: 17,
fontWeight: '600',
color: colors.text.inverse,
fontSize: fontSizes.lg,
fontWeight: '700',
color: '#FFFFFF',
},
// 底部
footer: {
// 底部区域
footerSection: {
flexDirection: 'row',
justifyContent: 'center',
alignItems: 'center',
marginTop: 40,
paddingTop: spacing.xl,
},
footerText: {
fontSize: 15,
color: colors.text.secondary,
fontSize: fontSizes.md,
color: colors.text.primary,
},
registerLink: {
fontSize: 15,
fontSize: fontSizes.md,
color: colors.primary.main,
fontWeight: '600',
marginLeft: 6,
fontWeight: '700',
marginLeft: spacing.xs,
textDecorationLine: 'underline',
},
policyFooter: {
alignItems: 'center',
marginTop: 24,
},
policyText: {
fontSize: 13,
color: colors.text.hint,
},
policyLink: {
color: colors.primary.main,
fontWeight: '500',
},
termsContainer: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 20,
marginTop: 4,
},
checkboxWrapper: {
paddingTop: 2,
},
termsText: {
fontSize: 14,
color: colors.text.secondary,
marginLeft: 10,
// ========== 分栏布局样式 ==========
splitContainer: {
flex: 1,
lineHeight: 22,
flexDirection: 'row',
},
termsLink: {
color: colors.primary.main,
fontWeight: '500',
leftPanelContainer: {
flex: 1, // 50%
justifyContent: 'center',
alignItems: 'center',
overflow: 'hidden',
},
leftPanelSafeArea: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
paddingHorizontal: spacing['4xl'], // 增加内边距,不贴边
},
leftPanel: {
alignItems: 'center',
paddingHorizontal: spacing['2xl'],
},
// 大屏模式左侧面板布局
splitLeftPanel: {
width: '100%',
justifyContent: 'center',
paddingVertical: spacing['6xl'],
},
leftPanelDecor: {
position: 'absolute',
bottom: spacing['4xl'],
left: 0,
right: 0,
alignItems: 'center',
},
leftPanelDecorBottom: {
marginTop: spacing['2xl'],
},
// 大屏模式装饰元素布局
splitLeftPanelDecorBottom: {
marginTop: spacing['6xl'],
width: '100%',
alignItems: 'center',
},
// 大屏模式装饰元素水平排列
splitDecorRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-around',
width: '100%',
gap: spacing['2xl'], // 增加图标间距
},
// 大屏模式装饰图标样式
splitDecorIcon: {
transform: [{ scale: 1.1 }],
opacity: 0.9,
},
decorRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-around',
width: '80%',
},
rightPanelContainer: {
flex: 1, // 50%
backgroundColor: colors.background.paper,
},
rightPanelSafeArea: {
flex: 1,
// 移除左右内边距,交给子容器控制
},
// 分栏布局右侧内容包裹器(核心修复)
splitRightContentWrapper: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
// 新增:左右内边距,保证表单和边缘有间距
paddingHorizontal: spacing['2xl'],
},
// 表单内容包裹器(区分大屏/小屏)
splitFormWrapper: {
// 关键修改:设置最大宽度 + 自适应宽度,实现水平居中
maxWidth: 420,
width: '100%',
},
singleFormWrapper: {
flexGrow: 1,
width: '100%',
// 移除这里的阴影,避免重复
},
});
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,186 +0,0 @@
/**
* 注册步骤1输入邮箱
* 包含邮箱输入框和获取验证码按钮
*/
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
TextInput,
TouchableOpacity,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors, type AppColors } from '../../theme';
import { RegisterStepProps } from './types';
export const RegisterStep1Email: React.FC<RegisterStepProps> = ({
formData,
updateFormData,
onNext,
colors: propColors,
sendingCode,
countdown,
onSendCode,
}) => {
const colors = propColors || useAppColors();
const styles = createStyles(colors);
const [emailError, setEmailError] = useState('');
const validateEmail = (email: string): boolean => {
if (!email.trim()) {
setEmailError('请输入邮箱');
return false;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setEmailError('请输入正确的邮箱地址');
return false;
}
setEmailError('');
return true;
};
const handleSendCode = async () => {
if (!validateEmail(formData.email)) {
return;
}
if (onSendCode) {
const success = await onSendCode(formData.email);
if (success) {
onNext();
}
}
};
return (
<View style={styles.container}>
<View style={styles.formSection}>
{/* 邮箱输入框 */}
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}></Text>
<View
style={[
styles.inputWrapper,
emailError ? styles.inputWrapperError : null,
]}
>
<TextInput
style={styles.input}
placeholder="请输入邮箱地址"
placeholderTextColor={colors.text?.hint || '#999'}
value={formData.email}
onChangeText={(text) => {
updateFormData({ email: text });
if (emailError) setEmailError('');
}}
autoCapitalize="none"
autoCorrect={false}
keyboardType="email-address"
editable={!sendingCode}
/>
{formData.email.length > 0 &&
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email) && (
<MaterialCommunityIcons
name="check-circle"
size={20}
color={colors.primary.main}
style={styles.checkIcon}
/>
)}
</View>
{emailError ? (
<Text style={styles.errorText}>{emailError}</Text>
) : null}
</View>
{/* 获取验证码按钮 */}
<TouchableOpacity
style={[
styles.actionButton,
(sendingCode || (countdown ?? 0) > 0) && styles.actionButtonDisabled,
]}
onPress={handleSendCode}
disabled={sendingCode || (countdown ?? 0) > 0}
activeOpacity={0.9}
>
<Text style={styles.actionButtonText}>
{sendingCode
? '发送中...'
: (countdown ?? 0) > 0
? `${countdown}秒后重试`
: '获取验证码'}
</Text>
</TouchableOpacity>
</View>
</View>
);
}
function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
paddingTop: 20,
},
formSection: {
marginBottom: 24,
},
inputContainer: {
marginBottom: 24,
},
inputLabel: {
fontSize: 14,
fontWeight: '500',
color: colors.text?.secondary || '#666',
marginBottom: 10,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
borderRadius: 14,
paddingHorizontal: 18,
height: 56,
borderWidth: 1,
borderColor: 'transparent',
},
inputWrapperError: {
borderColor: colors.error?.main || '#FF4444',
},
input: {
flex: 1,
fontSize: 16,
color: colors.text?.primary || '#333',
height: 56,
},
checkIcon: {
marginLeft: 8,
},
errorText: {
fontSize: 12,
color: colors.error?.main || '#FF4444',
marginTop: 6,
marginLeft: 4,
},
actionButton: {
height: 56,
borderRadius: 14,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
marginTop: 8,
},
actionButtonDisabled: {
opacity: 0.6,
},
actionButtonText: {
fontSize: 17,
fontWeight: '600',
color: colors.text.inverse,
},
});
}
export default RegisterStep1Email;

View File

@@ -1,201 +0,0 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors, type AppColors } from '../../theme';
import { VerificationCodeInput } from '../../components/common';
import { RegisterStepProps } from './types';
export const RegisterStep2Verification: React.FC<RegisterStepProps> = ({
formData,
updateFormData,
onNext,
onPrev,
colors: propColors,
countdown,
onSendCode,
}) => {
const colors = propColors || useAppColors();
const styles = createStyles(colors);
const [codeError, setCodeError] = useState('');
const [isVerifying, setIsVerifying] = useState(false);
const handleComplete = async (code: string) => {
if (code.length !== 6) {
setCodeError('请输入6位验证码');
return;
}
setCodeError('');
setIsVerifying(true);
// 模拟验证实际应该调用API验证验证码
// const success = await authService.verifyCode(formData.email, code);
setTimeout(() => {
setIsVerifying(false);
onNext();
}, 500);
};
const handleResendCode = async () => {
if (onSendCode && countdown === 0) {
await onSendCode(formData.email);
}
};
const formatCountdown = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes}:${secs.toString().padStart(2, '0')}`;
};
return (
<View style={styles.container}>
<View style={styles.emailInfo}>
<Text style={styles.emailText}> {formData.email}</Text>
</View>
<View style={styles.formSection}>
{/* 验证码输入 */}
<View style={styles.codeInputContainer}>
<VerificationCodeInput
value={formData.verificationCode}
onChangeValue={(value) => {
updateFormData({ verificationCode: value });
if (codeError) setCodeError('');
}}
length={6}
autoFocus={true}
onComplete={handleComplete}
disabled={isVerifying}
/>
{codeError ? (
<Text style={styles.errorText}>{codeError}</Text>
) : null}
</View>
{/* 重发倒计时 */}
<View style={styles.resendContainer}>
{(countdown ?? 0) > 0 ? (
<Text style={styles.countdownText}>
{formatCountdown(countdown!)}
</Text>
) : (
<TouchableOpacity onPress={handleResendCode} activeOpacity={0.8}>
<Text style={styles.resendText}></Text>
</TouchableOpacity>
)}
</View>
{/* 下一步按钮 */}
<TouchableOpacity
style={[
styles.actionButton,
formData.verificationCode.length !== 6 && styles.actionButtonDisabled,
isVerifying && styles.actionButtonDisabled,
]}
onPress={() => handleComplete(formData.verificationCode)}
disabled={formData.verificationCode.length !== 6 || isVerifying}
activeOpacity={0.9}
>
<Text style={styles.actionButtonText}>
{isVerifying ? '验证中...' : '下一步'}
</Text>
</TouchableOpacity>
{/* 返回按钮 */}
<TouchableOpacity
style={styles.backButton}
onPress={onPrev}
activeOpacity={0.8}
>
<MaterialCommunityIcons
name="arrow-left"
size={18}
color={colors.text?.secondary || '#666'}
/>
<Text style={styles.backButtonText}></Text>
</TouchableOpacity>
</View>
</View>
);
}
function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
paddingTop: 20,
},
emailInfo: {
marginBottom: 32,
alignItems: 'center',
},
emailText: {
fontSize: 14,
color: colors.text?.secondary || '#666',
},
formSection: {
marginBottom: 24,
alignItems: 'center',
},
codeInputContainer: {
marginBottom: 24,
width: '100%',
alignItems: 'center',
},
errorText: {
fontSize: 12,
color: colors.error?.main || '#FF4444',
marginTop: 12,
textAlign: 'center',
},
resendContainer: {
marginBottom: 32,
},
countdownText: {
fontSize: 14,
color: colors.text?.secondary || '#999',
},
resendText: {
fontSize: 14,
color: colors.primary.main,
fontWeight: '500',
},
actionButton: {
height: 56,
borderRadius: 14,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
width: '100%',
maxWidth: 320,
},
actionButtonDisabled: {
opacity: 0.6,
},
actionButtonText: {
fontSize: 17,
fontWeight: '600',
color: colors.text.inverse,
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
marginTop: 20,
paddingVertical: 8,
},
backButtonText: {
fontSize: 14,
color: colors.text?.secondary || '#666',
marginLeft: 6,
},
});
}
export default RegisterStep2Verification;

View File

@@ -1,356 +0,0 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
TextInput,
TouchableOpacity,
ActivityIndicator,
} from 'react-native';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors, type AppColors } from '../../theme';
import { RegisterStepProps } from './types';
import * as hrefs from '../../navigation/hrefs';
export const RegisterStep3Profile: React.FC<RegisterStepProps> = ({
formData,
updateFormData,
onNext,
onPrev,
colors: propColors,
loading,
}) => {
const colors = propColors || useAppColors();
const styles = createStyles(colors);
const router = useRouter();
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [agreedToTerms, setAgreedToTerms] = useState(false);
const [errors, setErrors] = useState<Record<string, string>>({});
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.username.trim()) {
newErrors.username = '请输入用户名';
} else if (formData.username.length < 3 || formData.username.length > 20) {
newErrors.username = '用户名长度需在3-20个字符之间';
} else if (!/^[a-zA-Z0-9_]+$/.test(formData.username)) {
newErrors.username = '用户名只能包含字母、数字和下划线';
}
if (!formData.nickname.trim()) {
newErrors.nickname = '请输入昵称';
} else if (formData.nickname.length < 2 || formData.nickname.length > 20) {
newErrors.nickname = '昵称长度需在2-20个字符之间';
}
if (!formData.password) {
newErrors.password = '请输入密码';
} else if (formData.password.length < 8) {
newErrors.password = '密码长度不能少于8位';
} else if (!/[A-Z]/.test(formData.password)) {
newErrors.password = '密码必须包含大写字母';
} else if (!/[a-z]/.test(formData.password)) {
newErrors.password = '密码必须包含小写字母';
} else if (!/[0-9]/.test(formData.password)) {
newErrors.password = '密码必须包含数字';
}
if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = '两次输入的密码不一致';
}
if (!agreedToTerms) {
newErrors.terms = '请同意用户协议和隐私政策';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = () => {
if (validateForm()) {
onNext();
}
};
const renderInput = (
label: string,
field: keyof typeof formData,
placeholder: string,
options: {
secureTextEntry?: boolean;
keyboardType?: 'default' | 'email-address' | 'numeric' | 'phone-pad';
maxLength?: number;
autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters';
showToggle?: boolean;
toggleValue?: boolean;
onToggle?: () => void;
} = {}
) => {
const {
secureTextEntry = false,
keyboardType = 'default',
maxLength,
autoCapitalize = 'none',
showToggle,
toggleValue,
onToggle,
} = options;
const error = errors[field as string];
const value = formData[field] || '';
return (
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}>{label}</Text>
<View
style={[
styles.inputWrapper,
error ? styles.inputWrapperError : null,
]}
>
<TextInput
style={styles.input}
placeholder={placeholder}
placeholderTextColor={colors.text?.hint || '#999'}
value={value}
onChangeText={(text) => {
updateFormData({ [field]: text } as Partial<typeof formData>);
if (errors[field as string]) {
setErrors((prev) => ({ ...prev, [field as string]: '' }));
}
}}
autoCapitalize={autoCapitalize}
autoCorrect={false}
keyboardType={keyboardType}
maxLength={maxLength}
secureTextEntry={secureTextEntry && !toggleValue}
editable={!loading}
/>
{showToggle && onToggle && (
<TouchableOpacity onPress={onToggle} style={styles.eyeButton}>
<MaterialCommunityIcons
name={toggleValue ? 'eye-off-outline' : 'eye-outline'}
size={20}
color={colors.text?.hint || '#999'}
/>
</TouchableOpacity>
)}
</View>
{error ? <Text style={styles.errorText}>{error}</Text> : null}
</View>
);
};
return (
<View style={styles.container}>
<View style={styles.formSection}>
{/* 用户名 */}
{renderInput('用户名', 'username', '3-20个字符字母数字下划线', {
maxLength: 20,
})}
{/* 昵称 */}
{renderInput('昵称', 'nickname', '2-20个字符', {
autoCapitalize: 'sentences',
maxLength: 20,
})}
{/* 手机号(选填) */}
{renderInput('手机号(选填)', 'phone', '请输入手机号', {
keyboardType: 'phone-pad',
maxLength: 11,
})}
{/* 密码 */}
{renderInput('密码', 'password', '至少8位含大小写字母和数字', {
secureTextEntry: true,
showToggle: true,
toggleValue: showPassword,
onToggle: () => setShowPassword(!showPassword),
})}
{/* 确认密码 */}
{renderInput('确认密码', 'confirmPassword', '再次输入密码', {
secureTextEntry: true,
showToggle: true,
toggleValue: showConfirmPassword,
onToggle: () => setShowConfirmPassword(!showConfirmPassword),
})}
{/* 服务条款 */}
<View style={styles.termsContainer}>
<TouchableOpacity
onPress={() => {
setAgreedToTerms(!agreedToTerms);
if (errors.terms) {
setErrors((prev) => ({ ...prev, terms: '' }));
}
}}
activeOpacity={0.8}
style={styles.checkboxWrapper}
>
<MaterialCommunityIcons
name={agreedToTerms ? 'checkbox-marked' : 'checkbox-blank-outline'}
size={22}
color={agreedToTerms ? colors.primary.main : colors.text?.hint || '#999'}
/>
</TouchableOpacity>
<Text style={styles.termsText}>
<Text
style={styles.termsLink}
onPress={() => router.push(hrefs.hrefProfileTerms())}
>
</Text>
<Text
style={styles.termsLink}
onPress={() => router.push(hrefs.hrefProfilePrivacy())}
>
</Text>
</Text>
</View>
{errors.terms ? (
<Text style={[styles.errorText, { marginTop: -12, marginBottom: 12 }]}>
{errors.terms}
</Text>
) : null}
{/* 完成注册按钮 */}
<TouchableOpacity
style={[styles.actionButton, loading && styles.actionButtonDisabled]}
onPress={handleSubmit}
disabled={loading}
activeOpacity={0.9}
>
{loading ? (
<ActivityIndicator size="small" color="#FFF" />
) : (
<Text style={styles.actionButtonText}></Text>
)}
</TouchableOpacity>
{/* 返回按钮 */}
<TouchableOpacity
style={styles.backButton}
onPress={onPrev}
activeOpacity={0.8}
disabled={loading}
>
<MaterialCommunityIcons
name="arrow-left"
size={18}
color={colors.text?.secondary || '#666'}
/>
<Text style={styles.backButtonText}></Text>
</TouchableOpacity>
</View>
</View>
);
}
function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
paddingTop: 20,
},
formSection: {
marginBottom: 24,
},
inputContainer: {
marginBottom: 16,
},
inputLabel: {
fontSize: 14,
fontWeight: '500',
color: colors.text?.secondary || '#666',
marginBottom: 8,
},
inputWrapper: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.default,
borderRadius: 14,
paddingHorizontal: 18,
height: 52,
borderWidth: 1,
borderColor: 'transparent',
},
inputWrapperError: {
borderColor: colors.error?.main || '#FF4444',
},
input: {
flex: 1,
fontSize: 16,
color: colors.text?.primary || '#333',
height: 52,
},
eyeButton: {
padding: 4,
marginLeft: 4,
},
errorText: {
fontSize: 12,
color: colors.error?.main || '#FF4444',
marginTop: 6,
marginLeft: 4,
},
termsContainer: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: 20,
marginTop: 4,
},
checkboxWrapper: {
paddingTop: 2,
},
termsText: {
fontSize: 14,
color: colors.text?.secondary || '#666',
marginLeft: 10,
flex: 1,
lineHeight: 22,
},
termsLink: {
color: colors.primary.main,
fontWeight: '500',
},
actionButton: {
height: 56,
borderRadius: 14,
backgroundColor: colors.primary.main,
alignItems: 'center',
justifyContent: 'center',
},
actionButtonDisabled: {
opacity: 0.7,
},
actionButtonText: {
fontSize: 17,
fontWeight: '600',
color: colors.text.inverse,
},
backButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
marginTop: 16,
paddingVertical: 8,
},
backButtonText: {
fontSize: 14,
color: colors.text?.secondary || '#666',
marginLeft: 6,
},
});
}
export default RegisterStep3Profile;

View File

@@ -1,445 +0,0 @@
/**
* 身份认证申请表单页面
* 用户填写认证信息并提交申请
*/
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
TextInput,
TouchableOpacity,
ScrollView,
Image,
Alert,
ActivityIndicator,
Platform,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as ImagePicker from 'expo-image-picker';
import { useAppColors, type AppColors } from '../../theme';
import { useVerificationStore } from '../../stores/verificationStore';
import { showPrompt } from '../../services/promptService';
import { uploadService } from '../../services/uploadService';
import type { UserIdentity } from '../../types/dto';
const IDENTITY_CONFIG: Record<string, { title: string; idField: string; idLabel: string }> = {
student: { title: '在校学生', idField: 'student_id', idLabel: '学号' },
teacher: { title: '教职工', idField: 'employee_id', idLabel: '工号' },
staff: { title: '工作人员', idField: 'employee_id', idLabel: '工号' },
alumni: { title: '校友', idField: 'student_id', idLabel: '学号(原)' },
};
export const VerificationFormScreen: React.FC = () => {
const colors = useAppColors();
const styles = createStyles(colors);
const router = useRouter();
const params = useLocalSearchParams<{ identity?: string }>();
const { submitVerification, isLoading } = useVerificationStore();
const [uploading, setUploading] = useState(false);
const [formData, setFormData] = useState({
real_name: '',
student_id: '',
employee_id: '',
department: '',
proof_materials: '',
});
const [errors, setErrors] = useState<Record<string, string>>({});
const identity = params.identity as UserIdentity || 'student';
const config = IDENTITY_CONFIG[identity] || IDENTITY_CONFIG.student;
const validateForm = (): boolean => {
const newErrors: Record<string, string> = {};
if (!formData.real_name.trim()) {
newErrors.real_name = '请输入真实姓名';
}
if (config.idField === 'student_id' && !formData.student_id.trim()) {
newErrors.student_id = `请输入${config.idLabel}`;
}
if (config.idField === 'employee_id' && !formData.employee_id.trim()) {
newErrors.employee_id = `请输入${config.idLabel}`;
}
if (!formData.proof_materials) {
newErrors.proof_materials = '请上传证明材料';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handlePickImage = async () => {
try {
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!permissionResult.granted) {
Alert.alert('提示', '需要相册权限才能上传图片');
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
aspect: [4, 3],
quality: 0.8,
});
if (!result.canceled && result.assets[0]) {
setFormData({ ...formData, proof_materials: result.assets[0].uri });
if (errors.proof_materials) {
setErrors({ ...errors, proof_materials: '' });
}
}
} catch (error) {
console.error('Failed to pick image:', error);
Alert.alert('错误', '选择图片失败');
}
};
const handleSubmit = async () => {
if (!validateForm()) return;
setUploading(true);
try {
let proofMaterialsUrl = formData.proof_materials;
if (formData.proof_materials.startsWith('file://') || formData.proof_materials.startsWith('content://') || formData.proof_materials.startsWith('blob:')) {
const uploadResult = await uploadService.uploadImage({
uri: formData.proof_materials,
});
if (!uploadResult || !uploadResult.url) {
showPrompt({
title: '上传失败',
message: '证明材料图片上传失败,请重试',
type: 'error',
});
return;
}
proofMaterialsUrl = uploadResult.url;
}
const success = await submitVerification({
identity,
real_name: formData.real_name.trim(),
student_id: formData.student_id.trim() || undefined,
employee_id: formData.employee_id.trim() || undefined,
department: formData.department.trim() || undefined,
proof_materials: proofMaterialsUrl,
});
if (success) {
showPrompt({
title: '提交成功',
message: '您的认证申请已提交,请等待审核',
type: 'success',
});
router.back();
} else {
const error = useVerificationStore.getState().error;
showPrompt({
title: '提交失败',
message: error || '请稍后重试',
type: 'error',
});
}
} finally {
setUploading(false);
}
};
const renderInput = (
field: keyof typeof formData,
label: string,
placeholder: string,
options: {
required?: boolean;
keyboardType?: 'default' | 'numeric' | 'email-address';
maxLength?: number;
} = {}
) => {
const { required, keyboardType = 'default', maxLength } = options;
const error = errors[field];
return (
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}>
{label}
{required && <Text style={styles.required}> *</Text>}
</Text>
<View style={[styles.inputWrapper, error && styles.inputWrapperError]}>
<TextInput
style={styles.input}
placeholder={placeholder}
placeholderTextColor={colors.text?.hint || '#999'}
value={formData[field]}
onChangeText={(text) => {
setFormData({ ...formData, [field]: text });
if (errors[field]) {
setErrors({ ...errors, [field]: '' });
}
}}
keyboardType={keyboardType}
maxLength={maxLength}
editable={!isLoading && !uploading}
/>
</View>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
);
};
return (
<SafeAreaView style={styles.container}>
<View style={styles.header}>
<TouchableOpacity
style={styles.backButton}
onPress={() => router.back()}
activeOpacity={0.8}
>
<MaterialCommunityIcons
name="arrow-left"
size={24}
color={colors.text?.primary || '#333'}
/>
</TouchableOpacity>
<Text style={styles.headerTitle}>{config.title}</Text>
<View style={styles.headerRight} />
</View>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
>
<View style={styles.infoCard}>
<MaterialCommunityIcons
name="information-outline"
size={20}
color={colors.primary.main}
/>
<Text style={styles.infoText}>
</Text>
</View>
<View style={styles.formSection}>
{renderInput('real_name', '真实姓名', '请输入身份证上的姓名', { required: true })}
{config.idField === 'student_id' && (
renderInput('student_id', config.idLabel, `请输入${config.idLabel}`, {
required: true,
keyboardType: 'numeric',
maxLength: 20,
})
)}
{config.idField === 'employee_id' && (
renderInput('employee_id', config.idLabel, `请输入${config.idLabel}`, {
required: true,
keyboardType: 'numeric',
maxLength: 20,
})
)}
{renderInput('department', '院系/部门', '选填,如:计算机学院', {
maxLength: 50,
})}
<View style={styles.inputContainer}>
<Text style={styles.inputLabel}>
<Text style={styles.required}> *</Text>
</Text>
<Text style={styles.hintText}>
</Text>
<TouchableOpacity
style={[styles.uploadButton, errors.proof_materials && styles.uploadButtonError]}
onPress={handlePickImage}
activeOpacity={0.8}
disabled={isLoading || uploading}
>
{formData.proof_materials ? (
<Image
source={{ uri: formData.proof_materials }}
style={styles.previewImage}
resizeMode="cover"
/>
) : (
<View style={styles.uploadPlaceholder}>
<MaterialCommunityIcons
name="camera-plus-outline"
size={40}
color={colors.text?.hint || '#999'}
/>
<Text style={styles.uploadText}></Text>
</View>
)}
</TouchableOpacity>
{errors.proof_materials && (
<Text style={styles.errorText}>{errors.proof_materials}</Text>
)}
</View>
</View>
<TouchableOpacity
style={[styles.submitButton, (isLoading || uploading) && styles.submitButtonDisabled]}
onPress={handleSubmit}
activeOpacity={0.9}
disabled={isLoading || uploading}
>
{(isLoading || uploading) ? (
<ActivityIndicator size="small" color={colors.text.inverse} />
) : (
<Text style={styles.submitButtonText}></Text>
)}
</TouchableOpacity>
</ScrollView>
</SafeAreaView>
);
};
function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.paper,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 12,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider || '#E0E0E0',
},
backButton: {
width: 40,
height: 40,
justifyContent: 'center',
alignItems: 'center',
},
headerTitle: {
fontSize: 18,
fontWeight: '600',
color: colors.text?.primary || '#333',
},
headerRight: {
width: 40,
},
scrollContent: {
flexGrow: 1,
padding: 24,
},
infoCard: {
flexDirection: 'row',
alignItems: 'flex-start',
backgroundColor: colors.primary.main + '15',
borderRadius: 12,
padding: 16,
marginBottom: 24,
},
infoText: {
flex: 1,
fontSize: 14,
color: colors.text?.secondary || '#666',
marginLeft: 8,
lineHeight: 20,
},
formSection: {
marginBottom: 24,
},
inputContainer: {
marginBottom: 20,
},
inputLabel: {
fontSize: 15,
fontWeight: '500',
color: colors.text?.primary || '#333',
marginBottom: 8,
},
required: {
color: colors.primary.main,
},
hintText: {
fontSize: 13,
color: colors.text?.hint || '#999',
marginBottom: 8,
},
inputWrapper: {
backgroundColor: colors.background.default,
borderRadius: 12,
paddingHorizontal: 16,
height: 50,
borderWidth: 1,
borderColor: 'transparent',
},
inputWrapperError: {
borderColor: colors.error?.main || '#FF4444',
},
input: {
flex: 1,
fontSize: 16,
color: colors.text?.primary || '#333',
height: 50,
},
errorText: {
fontSize: 12,
color: colors.error?.main || '#FF4444',
marginTop: 6,
},
uploadButton: {
backgroundColor: colors.background.default,
borderRadius: 12,
overflow: 'hidden',
minHeight: 200,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: 'transparent',
},
uploadButtonError: {
borderColor: colors.error?.main || '#FF4444',
},
uploadPlaceholder: {
alignItems: 'center',
justifyContent: 'center',
padding: 24,
},
uploadText: {
fontSize: 14,
color: colors.text?.hint || '#999',
marginTop: 12,
},
previewImage: {
width: '100%',
height: 200,
},
submitButton: {
height: 56,
borderRadius: 14,
backgroundColor: colors.primary.main,
justifyContent: 'center',
alignItems: 'center',
},
submitButtonDisabled: {
opacity: 0.7,
},
submitButtonText: {
fontSize: 17,
fontWeight: '600',
color: colors.text.inverse,
},
});
}
export default VerificationFormScreen;

View File

@@ -1,323 +0,0 @@
import React, { useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
ScrollView,
Alert,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useAppColors, type AppColors } from '../../theme';
import * as hrefs from '../../navigation/hrefs';
// 身份类型选项
const IDENTITY_OPTIONS = [
{
key: 'student',
title: '在校学生',
description: '需要提交学生证或校园卡照片',
icon: 'school-outline',
},
{
key: 'teacher',
title: '教职工',
description: '需要提交工作证或教师资格证',
icon: 'account-tie',
},
{
key: 'staff',
title: '工作人员',
description: '需要提交工作证或在职证明',
icon: 'badge-account-outline',
},
{
key: 'alumni',
title: '校友',
description: '需要提交毕业证或校友卡',
icon: 'account-school-outline',
},
];
export const VerificationGuideScreen: React.FC = () => {
const colors = useAppColors();
const styles = createStyles(colors);
const router = useRouter();
const [selectedIdentity, setSelectedIdentity] = useState<string | null>(null);
const handleSkip = () => {
Alert.alert(
'跳过认证',
'跳过后您将以普通用户身份使用,部分功能可能受限。您可以在设置中随时完成认证。',
[
{ text: '继续认证', style: 'cancel' },
{
text: '跳过',
style: 'default',
onPress: () => {
router.replace(hrefs.hrefHome());
},
},
]
);
};
const handleContinue = () => {
if (!selectedIdentity) {
Alert.alert('提示', '请选择您的身份类型');
return;
}
// 导航到认证表单页面
router.push(hrefs.hrefVerificationForm(selectedIdentity));
};
const renderIdentityCard = (option: typeof IDENTITY_OPTIONS[0]) => {
const isSelected = selectedIdentity === option.key;
return (
<TouchableOpacity
key={option.key}
style={[styles.identityCard, isSelected && styles.identityCardSelected]}
onPress={() => setSelectedIdentity(option.key)}
activeOpacity={0.8}
>
<View style={styles.identityIconContainer}>
<MaterialCommunityIcons
name={option.icon as any}
size={32}
color={isSelected ? colors.primary.main : colors.text?.secondary || '#666'}
/>
</View>
<View style={styles.identityContent}>
<Text style={[styles.identityTitle, isSelected && styles.identityTitleSelected]}>
{option.title}
</Text>
<Text style={styles.identityDescription}>{option.description}</Text>
</View>
<View style={styles.checkContainer}>
{isSelected && (
<MaterialCommunityIcons
name="check-circle"
size={24}
color={colors.primary.main}
/>
)}
</View>
</TouchableOpacity>
);
};
return (
<SafeAreaView style={styles.container}>
<ScrollView
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
>
{/* 头部图标 */}
<View style={styles.header}>
<View style={styles.iconCircle}>
<MaterialCommunityIcons
name="shield-check-outline"
size={48}
color={colors.primary.main}
/>
</View>
<Text style={styles.title}></Text>
<Text style={styles.subtitle}>
</Text>
</View>
{/* 认证优势 */}
<View style={styles.benefitsContainer}>
<View style={styles.benefitItem}>
<MaterialCommunityIcons
name="check-decagram"
size={20}
color={colors.primary.main}
/>
<Text style={styles.benefitText}></Text>
</View>
<View style={styles.benefitItem}>
<MaterialCommunityIcons
name="lock-open-outline"
size={20}
color={colors.primary.main}
/>
<Text style={styles.benefitText}></Text>
</View>
<View style={styles.benefitItem}>
<MaterialCommunityIcons
name="account-group-outline"
size={20}
color={colors.primary.main}
/>
<Text style={styles.benefitText}></Text>
</View>
</View>
{/* 身份选择 */}
<View style={styles.sectionContainer}>
<Text style={styles.sectionTitle}></Text>
{IDENTITY_OPTIONS.map(renderIdentityCard)}
</View>
{/* 按钮区域 */}
<View style={styles.buttonContainer}>
<TouchableOpacity
style={[styles.continueButton, !selectedIdentity && styles.continueButtonDisabled]}
onPress={handleContinue}
activeOpacity={0.9}
>
<Text style={styles.continueButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity style={styles.skipButton} onPress={handleSkip} activeOpacity={0.8}>
<Text style={styles.skipButtonText}></Text>
</TouchableOpacity>
</View>
</ScrollView>
</SafeAreaView>
);
};
function createStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.paper,
},
scrollContent: {
flexGrow: 1,
paddingHorizontal: 24,
paddingTop: 40,
paddingBottom: 40,
},
header: {
alignItems: 'center',
marginBottom: 32,
},
iconCircle: {
width: 100,
height: 100,
borderRadius: 50,
backgroundColor: colors.primary.main + '15',
justifyContent: 'center',
alignItems: 'center',
marginBottom: 20,
},
title: {
fontSize: 28,
fontWeight: '700',
color: colors.text?.primary || '#333',
marginBottom: 8,
},
subtitle: {
fontSize: 15,
color: colors.text?.secondary || '#666',
textAlign: 'center',
},
benefitsContainer: {
flexDirection: 'row',
justifyContent: 'center',
gap: 16,
marginBottom: 40,
},
benefitItem: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
benefitText: {
fontSize: 13,
color: colors.text?.secondary || '#666',
},
sectionContainer: {
marginBottom: 32,
},
sectionTitle: {
fontSize: 16,
fontWeight: '600',
color: colors.text?.primary || '#333',
marginBottom: 16,
},
identityCard: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
backgroundColor: colors.background.default,
borderRadius: 14,
marginBottom: 12,
borderWidth: 2,
borderColor: 'transparent',
},
identityCardSelected: {
backgroundColor: colors.primary.main + '15',
borderColor: colors.primary.main,
},
identityIconContainer: {
width: 48,
height: 48,
justifyContent: 'center',
alignItems: 'center',
marginRight: 16,
},
identityContent: {
flex: 1,
},
identityTitle: {
fontSize: 16,
fontWeight: '600',
color: colors.text?.primary || '#333',
marginBottom: 4,
},
identityTitleSelected: {
color: colors.primary.main,
},
identityDescription: {
fontSize: 13,
color: colors.text?.secondary || '#666',
},
checkContainer: {
width: 24,
height: 24,
justifyContent: 'center',
alignItems: 'center',
},
buttonContainer: {
gap: 12,
},
continueButton: {
height: 56,
borderRadius: 14,
backgroundColor: colors.primary.main,
justifyContent: 'center',
alignItems: 'center',
},
continueButtonDisabled: {
backgroundColor: colors.background.disabled,
},
continueButtonText: {
fontSize: 17,
fontWeight: '600',
color: colors.text.inverse,
},
skipButton: {
height: 56,
borderRadius: 14,
backgroundColor: 'transparent',
justifyContent: 'center',
alignItems: 'center',
borderWidth: 1,
borderColor: colors.divider || '#E0E0E0',
},
skipButtonText: {
fontSize: 17,
fontWeight: '500',
color: colors.text?.secondary || '#666',
},
});
}
export default VerificationGuideScreen;

View File

@@ -1,265 +0,0 @@
/**
* 欢迎页 WelcomeScreen
* 胡萝卜BBS - 用户欢迎页
*
* 亮色模式:白色主色调 + 胡萝卜橙辅色
* 暗色模式:深色背景 + 胡萝卜橙辅色
*/
import React, { useEffect, useRef, useMemo } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Animated,
StatusBar,
Dimensions,
} from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { useAppColors, useResolvedColorScheme, type AppColors } from '../../theme';
import * as hrefs from '../../navigation/hrefs';
const { width, height } = Dimensions.get('window');
export const WelcomeScreen: React.FC = () => {
const router = useRouter();
const colors = useAppColors();
const resolved = useResolvedColorScheme();
const styles = useMemo(() => createWelcomeStyles(colors), [colors]);
const isDark = resolved === 'dark';
// 动画值
const fadeAnim = useRef(new Animated.Value(0)).current;
const slideUpAnim = useRef(new Animated.Value(50)).current;
const scaleAnim = useRef(new Animated.Value(0.9)).current;
useEffect(() => {
// 启动入场动画
Animated.parallel([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(slideUpAnim, {
toValue: 0,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(scaleAnim, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
]).start();
}, []);
const handleSignIn = () => {
router.push(hrefs.hrefAuthLogin());
};
const handleCreateAccount = () => {
router.push(hrefs.hrefAuthRegister());
};
return (
<View style={styles.container}>
<StatusBar barStyle={isDark ? 'light-content' : 'dark-content'} />
{/* 装饰性背景元素 */}
<View style={styles.decorCircle1} />
<View style={styles.decorCircle2} />
<View style={styles.decorCircle3} />
<SafeAreaView style={styles.safeArea}>
<Animated.View
style={[
styles.content,
{
opacity: fadeAnim,
transform: [
{ translateY: slideUpAnim },
{ scale: scaleAnim },
],
},
]}
>
{/* 品牌区域 */}
<View style={styles.brandSection}>
<Text style={styles.welcomeEn}>Welcome To</Text>
<Text style={styles.brandName}></Text>
<View style={styles.brandUnderline} />
<Text style={styles.subtitle}>
{'\n'}
</Text>
</View>
{/* 按钮区域 */}
<View style={styles.buttonSection}>
<TouchableOpacity
style={styles.signInButton}
onPress={handleSignIn}
activeOpacity={0.8}
>
<Text style={styles.signInButtonText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.createAccountButton}
onPress={handleCreateAccount}
activeOpacity={0.8}
>
<Text style={styles.createAccountButtonText}></Text>
</TouchableOpacity>
</View>
</Animated.View>
</SafeAreaView>
</View>
);
};
function createWelcomeStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.background.default,
},
safeArea: {
flex: 1,
},
content: {
flex: 1,
justifyContent: 'space-between',
paddingHorizontal: 32,
paddingTop: 60,
paddingBottom: 40,
},
// 装饰性背景元素 - 使用胡萝卜橙的淡色
decorCircle1: {
position: 'absolute',
top: -100,
right: -100,
width: 400,
height: 400,
borderRadius: 200,
backgroundColor: colors.primary.main + '08', // 非常淡的胡萝卜橙
},
decorCircle2: {
position: 'absolute',
top: height * 0.3,
left: -80,
width: 200,
height: 200,
borderRadius: 100,
backgroundColor: colors.primary.main + '10',
},
decorCircle3: {
position: 'absolute',
bottom: -50,
right: -50,
width: 250,
height: 250,
borderRadius: 125,
backgroundColor: colors.primary.main + '08',
},
// 品牌区域
brandSection: {
alignItems: 'center',
},
badge: {
backgroundColor: colors.primary.main + '15',
paddingHorizontal: 20,
paddingVertical: 8,
borderRadius: 20,
marginBottom: 24,
},
badgeText: {
color: colors.primary.main,
fontSize: 14,
fontWeight: '600',
letterSpacing: 1,
},
welcomeEn: {
fontSize: 24,
fontWeight: '400',
color: colors.text.secondary,
marginBottom: 4,
letterSpacing: 1,
},
brandName: {
fontSize: 42,
fontWeight: '800',
color: colors.text.primary,
letterSpacing: 4,
marginBottom: 8,
},
brandUnderline: {
width: 80,
height: 4,
backgroundColor: colors.primary.main,
borderRadius: 2,
marginBottom: 24,
},
title: {
fontSize: 48,
fontWeight: '800',
color: colors.text.primary,
marginBottom: 16,
letterSpacing: 2,
},
subtitle: {
fontSize: 16,
color: colors.text.secondary,
textAlign: 'center',
lineHeight: 24,
letterSpacing: 0.5,
},
// 按钮区域
buttonSection: {
width: '100%',
gap: 16,
},
signInButton: {
backgroundColor: colors.primary.main,
borderRadius: 16,
height: 56,
alignItems: 'center',
justifyContent: 'center',
shadowColor: colors.primary.main,
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.3,
shadowRadius: 8,
elevation: 5,
},
signInButtonText: {
color: colors.primary.contrast,
fontSize: 16,
fontWeight: '700',
letterSpacing: 1,
},
createAccountButton: {
backgroundColor: 'transparent',
borderRadius: 16,
height: 56,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: colors.primary.main,
},
createAccountButtonText: {
color: colors.primary.main,
fontSize: 16,
fontWeight: '700',
letterSpacing: 1,
},
});
}
export default WelcomeScreen;

Some files were not shown because too many files have changed in this diff Show More