Initial frontend repository commit.
Include app source and update .gitignore to exclude local release artifacts and signing files. Made-with: Cursor
This commit is contained in:
52
.gitignore
vendored
Normal file
52
.gitignore
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||
|
||||
# dependencies
|
||||
node_modules/
|
||||
|
||||
# Expo
|
||||
.expo/
|
||||
dist/
|
||||
web-build/
|
||||
expo-env.d.ts
|
||||
|
||||
# Native
|
||||
.kotlin/
|
||||
*.orig.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
|
||||
# Metro
|
||||
.metro-health-check*
|
||||
|
||||
# debug
|
||||
npm-debug.*
|
||||
yarn-debug.*
|
||||
yarn-error.*
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# generated native folders
|
||||
/ios
|
||||
/android
|
||||
|
||||
# Release artifacts
|
||||
dist-android/
|
||||
dist-android-update.zip
|
||||
|
||||
# Local signing files
|
||||
*.keystore
|
||||
|
||||
# Backend
|
||||
backend/data/
|
||||
backend/logs/
|
||||
110
App.tsx
Normal file
110
App.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 萝卜BBS - 主应用入口
|
||||
* 配置导航、主题、状态管理等
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { AppState, AppStateStatus } from 'react-native';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { PaperProvider } from 'react-native-paper';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
|
||||
// 配置通知处理器 - 必须在应用启动时设置
|
||||
Notifications.setNotificationHandler({
|
||||
handleNotification: async () => ({
|
||||
shouldShowAlert: true, // 即使在后台也要显示通知
|
||||
shouldPlaySound: true, // 播放声音
|
||||
shouldSetBadge: true, // 设置角标
|
||||
shouldShowBanner: true, // 显示横幅
|
||||
shouldShowList: true, // 显示通知列表
|
||||
}),
|
||||
});
|
||||
|
||||
import MainNavigator from './src/navigation/MainNavigator';
|
||||
import { paperTheme } from './src/theme';
|
||||
import AppPromptBar from './src/components/common/AppPromptBar';
|
||||
import AppDialogHost from './src/components/common/AppDialogHost';
|
||||
import { installAlertOverride } from './src/services/alertOverride';
|
||||
// 数据库初始化移到 authStore 中,根据用户ID创建用户专属数据库
|
||||
|
||||
// 创建 QueryClient 实例
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
retry: 2,
|
||||
staleTime: 5 * 60 * 1000, // 5分钟
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
installAlertOverride();
|
||||
|
||||
export default function App() {
|
||||
const appState = useRef<AppStateStatus>(AppState.currentState);
|
||||
const notificationResponseListener = useRef<Notifications.EventSubscription | null>(null);
|
||||
|
||||
// 系统通知功能初始化
|
||||
useEffect(() => {
|
||||
const initNotifications = async () => {
|
||||
const { systemNotificationService } = await import('./src/services/systemNotificationService');
|
||||
await systemNotificationService.initialize();
|
||||
|
||||
// 初始化后台保活服务
|
||||
const { initBackgroundService } = await import('./src/services/backgroundService');
|
||||
await initBackgroundService();
|
||||
console.log('[App] 后台保活服务已初始化');
|
||||
|
||||
// 监听 App 状态变化
|
||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||
if (
|
||||
appState.current.match(/inactive|background/) &&
|
||||
nextAppState === 'active'
|
||||
) {
|
||||
systemNotificationService.clearBadge();
|
||||
}
|
||||
appState.current = nextAppState;
|
||||
});
|
||||
|
||||
// 监听通知点击响应
|
||||
notificationResponseListener.current = Notifications.addNotificationResponseReceivedListener(
|
||||
(response) => {
|
||||
const data = response.notification.request.content.data;
|
||||
console.log('[App] 通知被点击:', data);
|
||||
}
|
||||
);
|
||||
|
||||
// 监听通知到达(用于前台显示时额外处理)
|
||||
const notificationReceivedSubscription = Notifications.addNotificationReceivedListener(
|
||||
(notification) => {
|
||||
console.log('[App] 通知已接收:', notification.request.content.title);
|
||||
}
|
||||
);
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
notificationResponseListener.current?.remove();
|
||||
notificationReceivedSubscription.remove();
|
||||
};
|
||||
};
|
||||
|
||||
initNotifications();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<SafeAreaProvider>
|
||||
<PaperProvider theme={paperTheme}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<StatusBar style="light" />
|
||||
<MainNavigator />
|
||||
<AppPromptBar />
|
||||
<AppDialogHost />
|
||||
</QueryClientProvider>
|
||||
</PaperProvider>
|
||||
</SafeAreaProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
52
app.config.js
Normal file
52
app.config.js
Normal file
@@ -0,0 +1,52 @@
|
||||
const appJson = require('./app.json');
|
||||
|
||||
const isDevVariant = process.env.APP_VARIANT === 'dev';
|
||||
const releaseApiBaseUrl = 'https://bbs.littlelan.cn/api/v1';
|
||||
const releaseWsUrl = 'wss://bbs.littlelan.cn/ws';
|
||||
const releaseUpdatesBaseUrl = 'https://updates.littlelan.cn';
|
||||
const devApiBaseUrl = process.env.EXPO_PUBLIC_API_BASE_URL || 'http://192.168.31.238:8080/api/v1';
|
||||
const devWsUrl = process.env.EXPO_PUBLIC_WS_URL || 'ws://192.168.31.238:8080/ws';
|
||||
|
||||
function toManifestUrl(baseUrl, portOverride) {
|
||||
const parsed = new URL(baseUrl);
|
||||
if (portOverride != null) {
|
||||
parsed.port = String(portOverride);
|
||||
}
|
||||
parsed.pathname = '/api/manifest';
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
const releaseUpdatesUrl =
|
||||
process.env.EXPO_PUBLIC_UPDATES_URL || toManifestUrl(releaseUpdatesBaseUrl);
|
||||
const devUpdatesUrl =
|
||||
process.env.EXPO_PUBLIC_DEV_UPDATES_URL || toManifestUrl(devApiBaseUrl, 3001);
|
||||
|
||||
const expo = appJson.expo;
|
||||
|
||||
module.exports = {
|
||||
...expo,
|
||||
name: isDevVariant ? `${expo.name} Dev` : expo.name,
|
||||
runtimeVersion: {
|
||||
policy: 'appVersion',
|
||||
},
|
||||
updates: {
|
||||
...(expo.updates || {}),
|
||||
url: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
|
||||
checkAutomatically: 'ON_LOAD',
|
||||
fallbackToCacheTimeout: 0,
|
||||
},
|
||||
ios: expo.ios,
|
||||
android: {
|
||||
...expo.android,
|
||||
package: 'skin.carrot.bbs',
|
||||
},
|
||||
extra: {
|
||||
...(expo.extra || {}),
|
||||
appVariant: isDevVariant ? 'dev' : 'release',
|
||||
apiBaseUrl: isDevVariant ? devApiBaseUrl : releaseApiBaseUrl,
|
||||
wsUrl: isDevVariant ? devWsUrl : releaseWsUrl,
|
||||
updatesUrl: isDevVariant ? devUpdatesUrl : releaseUpdatesUrl,
|
||||
},
|
||||
};
|
||||
130
app.json
Normal file
130
app.json
Normal file
@@ -0,0 +1,130 @@
|
||||
{
|
||||
"expo": {
|
||||
"name": "萝卜社区",
|
||||
"slug": "qojo",
|
||||
"version": "1.0.10",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "light",
|
||||
"splash": {
|
||||
"image": "./assets/splash-icon.png",
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#ffffff"
|
||||
},
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"infoPlist": {
|
||||
"UIBackgroundModes": [
|
||||
"fetch",
|
||||
"remote-notification",
|
||||
"fetch",
|
||||
"remote-notification"
|
||||
],
|
||||
"ITSAppUsesNonExemptEncryption": false
|
||||
},
|
||||
"bundleIdentifier": "skin.carrot.bbs"
|
||||
},
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"backgroundColor": "#E6F4FE",
|
||||
"foregroundImage": "./assets/android-icon-foreground.png",
|
||||
"backgroundImage": "./assets/android-icon-background.png",
|
||||
"monochromeImage": "./assets/android-icon-monochrome.png"
|
||||
},
|
||||
"predictiveBackGestureEnabled": false,
|
||||
"package": "skin.carrot.bbs",
|
||||
"versionCode": 5,
|
||||
"permissions": [
|
||||
"VIBRATE",
|
||||
"RECEIVE_BOOT_COMPLETED",
|
||||
"WAKE_LOCK",
|
||||
"READ_EXTERNAL_STORAGE",
|
||||
"WRITE_EXTERNAL_STORAGE",
|
||||
"READ_MEDIA_IMAGES",
|
||||
"READ_MEDIA_VIDEO",
|
||||
"READ_MEDIA_AUDIO",
|
||||
"android.permission.RECEIVE_BOOT_COMPLETED",
|
||||
"android.permission.WAKE_LOCK",
|
||||
"android.permission.READ_EXTERNAL_STORAGE",
|
||||
"android.permission.WRITE_EXTERNAL_STORAGE",
|
||||
"android.permission.READ_MEDIA_VISUAL_USER_SELECTED",
|
||||
"android.permission.ACCESS_MEDIA_LOCATION",
|
||||
"android.permission.READ_MEDIA_IMAGES",
|
||||
"android.permission.READ_MEDIA_VIDEO",
|
||||
"android.permission.READ_MEDIA_AUDIO"
|
||||
]
|
||||
},
|
||||
"web": {
|
||||
"favicon": "./assets/favicon.png"
|
||||
},
|
||||
"plugins": [
|
||||
[
|
||||
"expo-router",
|
||||
{
|
||||
"headers": {
|
||||
"Cross-Origin-Embedder-Policy": "credentialless",
|
||||
"Cross-Origin-Opener-Policy": "same-origin"
|
||||
}
|
||||
}
|
||||
],
|
||||
"expo-sqlite",
|
||||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"sounds": []
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-background-fetch",
|
||||
{
|
||||
"minimumInterval": 900
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-media-library",
|
||||
{
|
||||
"photosPermission": "允许萝卜社区访问您的照片以发布内容",
|
||||
"savePhotosPermission": "允许萝卜社区保存照片到您的相册",
|
||||
"isAccessMediaLocationEnabled": true
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-image-picker",
|
||||
{
|
||||
"photosPermission": "允许萝卜社区访问您的照片以选择图片",
|
||||
"cameraPermission": "允许萝卜社区访问您的相机以拍摄图片",
|
||||
"colors": {
|
||||
"cropToolbarColor": "#111827",
|
||||
"cropToolbarIconColor": "#ffffff",
|
||||
"cropToolbarActionTextColor": "#ffffff",
|
||||
"cropBackButtonIconColor": "#ffffff",
|
||||
"cropBackgroundColor": "#f3f4f6"
|
||||
},
|
||||
"dark": {
|
||||
"colors": {
|
||||
"cropToolbarColor": "#000000",
|
||||
"cropToolbarIconColor": "#ffffff",
|
||||
"cropToolbarActionTextColor": "#ffffff",
|
||||
"cropBackButtonIconColor": "#ffffff",
|
||||
"cropBackgroundColor": "#0a0a0a"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
[
|
||||
"expo-video",
|
||||
{
|
||||
"proguardRules": "-keep class com.google.android.exoplayer2.** { *; }"
|
||||
}
|
||||
],
|
||||
"expo-font"
|
||||
],
|
||||
"jsEngine": "hermes",
|
||||
"extra": {
|
||||
"eas": {
|
||||
"projectId": "65540196-d37d-437b-8496-227df0317069"
|
||||
}
|
||||
},
|
||||
"owner": "qojo"
|
||||
}
|
||||
}
|
||||
BIN
assets/android-icon-background.png
Normal file
BIN
assets/android-icon-background.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
BIN
assets/android-icon-foreground.png
Normal file
BIN
assets/android-icon-foreground.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
BIN
assets/android-icon-monochrome.png
Normal file
BIN
assets/android-icon-monochrome.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 66 KiB |
BIN
assets/favicon.png
Normal file
BIN
assets/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 895 KiB |
BIN
assets/splash-icon.png
Normal file
BIN
assets/splash-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 895 KiB |
40
eas.json
Normal file
40
eas.json
Normal file
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"cli": {
|
||||
"version": ">= 18.1.0",
|
||||
"appVersionSource": "remote"
|
||||
},
|
||||
"build": {
|
||||
"development": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"resourceClass": "m-medium"
|
||||
}
|
||||
},
|
||||
"ios-simulator": {
|
||||
"developmentClient": true,
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"simulator": true,
|
||||
"resourceClass": "m-medium"
|
||||
}
|
||||
},
|
||||
"preview": {
|
||||
"distribution": "internal",
|
||||
"ios": {
|
||||
"resourceClass": "m-medium"
|
||||
}
|
||||
},
|
||||
"production": {
|
||||
"autoIncrement": true,
|
||||
"ios": {
|
||||
"resourceClass": "m-medium"
|
||||
}
|
||||
}
|
||||
},
|
||||
"submit": {
|
||||
"production": {
|
||||
"ios": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
index.ts
Normal file
8
index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { registerRootComponent } from 'expo';
|
||||
|
||||
import App from './App';
|
||||
|
||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||
// the environment is set up appropriately
|
||||
registerRootComponent(App);
|
||||
21
metro.config.js
Normal file
21
metro.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
const { getDefaultConfig } = require('expo/metro-config');
|
||||
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
// Add wasm asset support
|
||||
config.resolver.assetExts.push('wasm');
|
||||
|
||||
// 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.
|
||||
// These headers are required for SharedArrayBuffer (used by expo-sqlite on web).
|
||||
config.server.enhanceMiddleware = (middleware) => {
|
||||
return (req, res, next) => {
|
||||
res.setHeader('Cross-Origin-Embedder-Policy', 'credentialless');
|
||||
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
|
||||
middleware(req, res, next);
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
11347
package-lock.json
generated
Normal file
11347
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
67
package.json
Normal file
67
package.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "carrot_bbs",
|
||||
"version": "1.0.0",
|
||||
"main": "index.ts",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"web": "expo start --web",
|
||||
"ios:dev": "eas build --platform ios --profile development",
|
||||
"ios:simulator": "eas build --platform ios --profile ios-simulator",
|
||||
"ios:preview": "eas build --platform ios --profile preview",
|
||||
"ios:prod": "eas build --platform ios --profile production",
|
||||
"ios:submit": "eas submit --platform ios --profile production"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo/vector-icons": "^15.1.1",
|
||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||
"@react-navigation/bottom-tabs": "^7.15.2",
|
||||
"@react-navigation/material-top-tabs": "^7.4.16",
|
||||
"@react-navigation/native": "^7.1.31",
|
||||
"@react-navigation/native-stack": "^7.14.2",
|
||||
"@shopify/flash-list": "2.0.2",
|
||||
"@tanstack/react-query": "^5.90.21",
|
||||
"axios": "^1.13.6",
|
||||
"date-fns": "^4.1.0",
|
||||
"expo": "~55.0.4",
|
||||
"expo-background-fetch": "~55.0.9",
|
||||
"expo-constants": "~55.0.7",
|
||||
"expo-dev-client": "~55.0.10",
|
||||
"expo-file-system": "~55.0.10",
|
||||
"expo-font": "~55.0.4",
|
||||
"expo-haptics": "~55.0.8",
|
||||
"expo-image": "^55.0.5",
|
||||
"expo-image-picker": "^55.0.10",
|
||||
"expo-linear-gradient": "^55.0.8",
|
||||
"expo-media-library": "~55.0.9",
|
||||
"expo-notifications": "^55.0.10",
|
||||
"expo-router": "^55.0.4",
|
||||
"expo-sqlite": "~55.0.10",
|
||||
"expo-status-bar": "~55.0.4",
|
||||
"expo-system-ui": "^55.0.9",
|
||||
"expo-task-manager": "~55.0.9",
|
||||
"expo-updates": "^55.0.12",
|
||||
"expo-video": "^55.0.10",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-native": "0.83.2",
|
||||
"react-native-gesture-handler": "^2.30.0",
|
||||
"react-native-pager-view": "^8.0.0",
|
||||
"react-native-paper": "^5.15.0",
|
||||
"react-native-reanimated": "^4.2.1",
|
||||
"react-native-safe-area-context": "~5.6.2",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-worklets": "0.7.2",
|
||||
"zod": "^4.3.6",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-native-community/cli": "^20.1.2",
|
||||
"@types/react": "~19.2.2",
|
||||
"@types/react-native-vector-icons": "^6.4.18",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
612
src/components/business/CommentItem.tsx
Normal file
612
src/components/business/CommentItem.tsx
Normal file
@@ -0,0 +1,612 @@
|
||||
/**
|
||||
* CommentItem 评论项组件 - QQ频道风格
|
||||
* 支持嵌套回复显示、楼层号、身份标识、删除评论、图片显示
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, Alert, Dimensions } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { colors, spacing, borderRadius, fontSizes } from '../../theme';
|
||||
import { Comment, CommentImage } from '../../types';
|
||||
import Text from '../common/Text';
|
||||
import Avatar from '../common/Avatar';
|
||||
import { CompactImageGrid, ImageGridItem } from '../common';
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
|
||||
interface CommentItemProps {
|
||||
comment: Comment;
|
||||
onUserPress: () => void;
|
||||
onReply: () => void;
|
||||
onLike: () => void;
|
||||
floorNumber?: number; // 楼层号
|
||||
isAuthor?: boolean; // 是否是楼主
|
||||
replyToUser?: string; // 回复给哪位用户
|
||||
onReplyPress?: (comment: Comment) => void; // 点击评论的评论
|
||||
allReplies?: Comment[]; // 所有回复列表,用于根据 target_id 查找被回复用户
|
||||
onLoadMoreReplies?: (commentId: string) => void; // 加载更多回复的回调
|
||||
isCommentAuthor?: boolean; // 当前用户是否为评论作者
|
||||
onDelete?: (comment: Comment) => void; // 删除评论的回调
|
||||
onImagePress?: (images: ImageGridItem[], index: number) => void; // 点击图片查看大图
|
||||
currentUserId?: string; // 当前用户ID,用于判断子评论作者
|
||||
}
|
||||
|
||||
const CommentItem: React.FC<CommentItemProps> = ({
|
||||
comment,
|
||||
onUserPress,
|
||||
onReply,
|
||||
onLike,
|
||||
floorNumber,
|
||||
isAuthor = false,
|
||||
replyToUser,
|
||||
onReplyPress,
|
||||
allReplies,
|
||||
onLoadMoreReplies,
|
||||
isCommentAuthor = false,
|
||||
onDelete,
|
||||
onImagePress,
|
||||
currentUserId,
|
||||
}) => {
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
// 格式化时间
|
||||
const formatTime = (dateString: string): string => {
|
||||
try {
|
||||
return formatDistanceToNow(new Date(dateString), {
|
||||
addSuffix: true,
|
||||
locale: zhCN,
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化数字
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 10000) {
|
||||
return (num / 10000).toFixed(1) + 'w';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'k';
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
// 处理图片点击
|
||||
const handleImagePress = (index: number) => {
|
||||
if (onImagePress && comment.images && comment.images.length > 0) {
|
||||
const images: ImageGridItem[] = comment.images.map(img => ({
|
||||
id: img.url,
|
||||
url: img.url,
|
||||
}));
|
||||
onImagePress(images, index);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染评论图片 - 使用 CompactImageGrid
|
||||
const renderCommentImages = () => {
|
||||
if (!comment.images || comment.images.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gridImages: ImageGridItem[] = comment.images.map(img => ({
|
||||
id: img.url,
|
||||
url: img.url,
|
||||
}));
|
||||
|
||||
return (
|
||||
<CompactImageGrid
|
||||
images={gridImages}
|
||||
maxDisplayCount={6}
|
||||
gap={4}
|
||||
borderRadius={borderRadius.sm}
|
||||
showMoreOverlay
|
||||
onImagePress={onImagePress}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 处理删除评论
|
||||
const handleDelete = () => {
|
||||
if (!onDelete || isDeleting) return;
|
||||
|
||||
Alert.alert(
|
||||
'删除评论',
|
||||
'确定要删除这条评论吗?删除后将无法恢复。',
|
||||
[
|
||||
{
|
||||
text: '取消',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: '删除',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDelete(comment);
|
||||
} catch (error) {
|
||||
console.error('删除评论失败:', error);
|
||||
Alert.alert('删除失败', '删除评论时发生错误,请稍后重试');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染身份标识
|
||||
const renderBadges = () => {
|
||||
const badges = [];
|
||||
const authorId = comment.author?.id || '';
|
||||
|
||||
if (isAuthor) {
|
||||
badges.push(
|
||||
<View key="author" style={[styles.badge, styles.authorBadge]}>
|
||||
<Text variant="caption" style={styles.badgeText}>楼主</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (authorId === '1') { // 管理员
|
||||
badges.push(
|
||||
<View key="admin" style={[styles.badge, styles.adminBadge]}>
|
||||
<Text variant="caption" style={styles.badgeText}>管理员</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return badges;
|
||||
};
|
||||
|
||||
// 渲染楼层号
|
||||
const renderFloorNumber = () => {
|
||||
if (!floorNumber) return null;
|
||||
|
||||
// 获取楼层显示文本
|
||||
const getFloorText = (floor: number): string => {
|
||||
switch (floor) {
|
||||
case 1:
|
||||
return '沙发';
|
||||
case 2:
|
||||
return '板凳';
|
||||
case 3:
|
||||
return '地板';
|
||||
default:
|
||||
return `${floor}楼`;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.floorTag}>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.floorText}>
|
||||
{getFloorText(floorNumber)}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 根据 target_id 查找被回复用户的昵称
|
||||
const getTargetUserNickname = (targetId: string): string => {
|
||||
// 首先在顶级评论中查找
|
||||
if (comment.id === targetId) {
|
||||
return comment.author?.nickname || '用户';
|
||||
}
|
||||
// 然后在回复列表中查找
|
||||
if (allReplies) {
|
||||
const targetComment = allReplies.find(r => r.id === targetId);
|
||||
if (targetComment) {
|
||||
return targetComment.author?.nickname || '用户';
|
||||
}
|
||||
}
|
||||
// 最后在当前评论的 replies 中查找
|
||||
if (comment.replies) {
|
||||
const targetComment = comment.replies.find(r => r.id === targetId);
|
||||
if (targetComment) {
|
||||
return targetComment.author?.nickname || '用户';
|
||||
}
|
||||
}
|
||||
return '用户';
|
||||
};
|
||||
|
||||
// 渲染子评论图片
|
||||
const renderSubReplyImages = (reply: Comment) => {
|
||||
if (!reply.images || reply.images.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gridImages: ImageGridItem[] = reply.images.map(img => ({
|
||||
id: img.url,
|
||||
url: img.url,
|
||||
}));
|
||||
|
||||
return (
|
||||
<CompactImageGrid
|
||||
images={gridImages}
|
||||
maxDisplayCount={3}
|
||||
gap={4}
|
||||
borderRadius={borderRadius.sm}
|
||||
showMoreOverlay
|
||||
onImagePress={onImagePress}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 处理子评论删除
|
||||
const handleSubReplyDelete = (reply: Comment) => {
|
||||
if (!onDelete || isDeleting) return;
|
||||
|
||||
Alert.alert(
|
||||
'删除回复',
|
||||
'确定要删除这条回复吗?删除后将无法恢复。',
|
||||
[
|
||||
{
|
||||
text: '取消',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: '删除',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDelete(reply);
|
||||
} catch (error) {
|
||||
console.error('删除回复失败:', error);
|
||||
Alert.alert('删除失败', '删除回复时发生错误,请稍后重试');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染评论的评论(子评论)- 抖音/b站风格:平铺展示
|
||||
const renderSubReplies = () => {
|
||||
if (!comment.replies || comment.replies.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const commentAuthorId = comment.author?.id || '';
|
||||
|
||||
return (
|
||||
<View style={styles.subRepliesContainer}>
|
||||
{comment.replies.map((reply) => {
|
||||
const replyAuthorId = reply.author?.id || '';
|
||||
// 根据 target_id 获取被回复的用户昵称
|
||||
const targetId = reply.target_id;
|
||||
const targetNickname = targetId ? getTargetUserNickname(targetId) : null;
|
||||
// 判断当前用户是否为子评论作者
|
||||
const isSubReplyAuthor = currentUserId === replyAuthorId;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={reply.id}
|
||||
style={styles.subReplyItem}
|
||||
onPress={() => onReplyPress?.(reply)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Avatar
|
||||
source={reply.author?.avatar}
|
||||
size={24}
|
||||
name={reply.author?.nickname || '用户'}
|
||||
/>
|
||||
<View style={styles.subReplyContent}>
|
||||
<View style={styles.subReplyHeader}>
|
||||
<Text variant="caption" style={styles.subReplyAuthor}>
|
||||
{reply.author?.nickname || '用户'}
|
||||
</Text>
|
||||
{replyAuthorId === commentAuthorId && (
|
||||
<View style={[styles.badge, styles.authorBadge, styles.smallBadge]}>
|
||||
<Text variant="caption" style={styles.smallBadgeText}>楼主</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* 显示回复引用:aaa 回复 bbb */}
|
||||
{targetNickname && (
|
||||
<>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.replyToText}>
|
||||
{' '}回复
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.primary.main} style={styles.replyToName}>
|
||||
{targetNickname}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
{/* 显示回复内容(如果有文字) */}
|
||||
{reply.content ? (
|
||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={2}>
|
||||
{reply.content}
|
||||
</Text>
|
||||
) : null}
|
||||
{/* 显示回复图片(如果有图片) */}
|
||||
{renderSubReplyImages(reply)}
|
||||
{/* 子评论操作按钮 */}
|
||||
<View style={styles.subReplyActions}>
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() => onReplyPress?.(reply)}
|
||||
>
|
||||
<MaterialCommunityIcons name="reply" size={12} color={colors.text.hint} />
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
|
||||
回复
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{/* 删除按钮 - 子评论作者可见 */}
|
||||
{isSubReplyAuthor && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={() => handleSubReplyDelete(reply)}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={isDeleting ? 'loading' : 'delete-outline'}
|
||||
size={12}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
|
||||
{isDeleting ? '删除中' : '删除'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
{comment.replies_count > (comment.replies?.length || 0) && (
|
||||
<TouchableOpacity
|
||||
style={styles.moreRepliesButton}
|
||||
onPress={() => onLoadMoreReplies?.(comment.id)}
|
||||
>
|
||||
<Text variant="caption" color={colors.primary.main}>
|
||||
展开剩余 {comment.replies_count - (comment.replies?.length || 0)} 条回复
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 用户头像 */}
|
||||
<TouchableOpacity onPress={onUserPress}>
|
||||
<Avatar
|
||||
source={comment.author?.avatar}
|
||||
size={36}
|
||||
name={comment.author?.nickname}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 评论内容 */}
|
||||
<View style={styles.content}>
|
||||
{/* 用户信息行 - QQ频道风格 */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.userInfo}>
|
||||
<TouchableOpacity onPress={onUserPress}>
|
||||
<Text variant="body" style={styles.username}>
|
||||
{comment.author?.nickname}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{renderBadges()}
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
|
||||
{formatTime(comment.created_at || '')}
|
||||
</Text>
|
||||
</View>
|
||||
{renderFloorNumber()}
|
||||
</View>
|
||||
|
||||
{/* 回复引用 */}
|
||||
{replyToUser && (
|
||||
<View style={styles.replyReference}>
|
||||
<Text variant="caption" color={colors.primary.main}>
|
||||
回复 @{replyToUser}:
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 评论文本 - 非气泡样式 */}
|
||||
<View style={styles.commentContent}>
|
||||
<Text variant="body" color={colors.text.primary} style={styles.text}>
|
||||
{comment.content}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 评论图片 */}
|
||||
{renderCommentImages()}
|
||||
|
||||
{/* 操作按钮 - 更紧凑 */}
|
||||
<View style={styles.actions}>
|
||||
{/* 点赞 */}
|
||||
<TouchableOpacity style={styles.actionButton} onPress={onLike}>
|
||||
<MaterialCommunityIcons
|
||||
name={comment.is_liked ? 'heart' : 'heart-outline'}
|
||||
size={14}
|
||||
color={comment.is_liked ? colors.error.main : colors.text.hint}
|
||||
/>
|
||||
<Text
|
||||
variant="caption"
|
||||
color={comment.is_liked ? colors.error.main : colors.text.hint}
|
||||
style={styles.actionText}
|
||||
>
|
||||
{comment.likes_count > 0 ? formatNumber(comment.likes_count) : '赞'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 回复 */}
|
||||
<TouchableOpacity style={styles.actionButton} onPress={onReply}>
|
||||
<MaterialCommunityIcons name="reply" size={14} color={colors.text.hint} />
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
|
||||
回复
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 删除按钮 - 只对评论作者显示 */}
|
||||
{isCommentAuthor && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionButton}
|
||||
onPress={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={isDeleting ? 'loading' : 'delete-outline'}
|
||||
size={14}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
|
||||
{isDeleting ? '删除中' : '删除'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 评论的评论(子评论)- 抖音/b站风格:平铺展示不开新层级 */}
|
||||
{renderSubReplies()}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
userInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
flex: 1,
|
||||
},
|
||||
username: {
|
||||
fontWeight: '600',
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.primary,
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
badge: {
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 2,
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
smallBadge: {
|
||||
paddingHorizontal: 2,
|
||||
paddingVertical: 0,
|
||||
},
|
||||
authorBadge: {
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
adminBadge: {
|
||||
backgroundColor: colors.error.main,
|
||||
},
|
||||
badgeText: {
|
||||
color: colors.text.inverse,
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '600',
|
||||
},
|
||||
smallBadgeText: {
|
||||
color: colors.text.inverse,
|
||||
fontSize: 9,
|
||||
fontWeight: '600',
|
||||
},
|
||||
timeText: {
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
floorTag: {
|
||||
backgroundColor: colors.background.default,
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
},
|
||||
floorText: {
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
replyReference: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
commentContent: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
text: {
|
||||
lineHeight: 20,
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
actions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
actionText: {
|
||||
marginLeft: 2,
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
subRepliesContainer: {
|
||||
marginTop: spacing.sm,
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.md,
|
||||
padding: spacing.sm,
|
||||
},
|
||||
subReplyItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
subReplyContent: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
subReplyHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 2,
|
||||
},
|
||||
subReplyAuthor: {
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
moreRepliesButton: {
|
||||
marginTop: spacing.xs,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
replyToText: {
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
replyToName: {
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '500',
|
||||
},
|
||||
subReplyActions: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
});
|
||||
|
||||
export default CommentItem;
|
||||
147
src/components/business/NotificationItem.tsx
Normal file
147
src/components/business/NotificationItem.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* NotificationItem 通知项组件
|
||||
* 根据通知类型显示不同图标和内容
|
||||
*
|
||||
* @deprecated 请使用 SystemMessageItem 组件代替
|
||||
* 该组件使用旧的 Notification 类型,新功能请使用 SystemMessageItem
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
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 { colors, spacing, borderRadius } from '../../theme';
|
||||
import { Notification, NotificationType } from '../../types';
|
||||
import Text from '../common/Text';
|
||||
import Avatar from '../common/Avatar';
|
||||
|
||||
interface NotificationItemProps {
|
||||
notification: Notification;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
// 通知类型到图标和颜色的映射
|
||||
const getNotificationIcon = (type: NotificationType): { icon: string; color: string } => {
|
||||
switch (type) {
|
||||
case 'like_post':
|
||||
return { icon: 'heart', color: colors.error.main };
|
||||
case 'like_comment':
|
||||
return { icon: 'heart', color: colors.error.main };
|
||||
case 'comment':
|
||||
return { icon: 'comment', color: colors.info.main };
|
||||
case 'reply':
|
||||
return { icon: 'reply', color: colors.info.main };
|
||||
case 'follow':
|
||||
return { icon: 'account-plus', color: colors.primary.main };
|
||||
case 'mention':
|
||||
return { icon: 'at', color: colors.warning.main };
|
||||
case 'system':
|
||||
return { icon: 'information', color: colors.text.secondary };
|
||||
default:
|
||||
return { icon: 'bell', color: colors.text.secondary };
|
||||
}
|
||||
};
|
||||
|
||||
const NotificationItem: React.FC<NotificationItemProps> = ({
|
||||
notification,
|
||||
onPress,
|
||||
}) => {
|
||||
// 格式化时间
|
||||
const formatTime = (dateString: string): string => {
|
||||
try {
|
||||
return formatDistanceToNow(new Date(dateString), {
|
||||
addSuffix: true,
|
||||
locale: zhCN,
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const { icon, color } = getNotificationIcon(notification.type);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.container, !notification.isRead && styles.unread]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{/* 通知图标/头像 */}
|
||||
<View style={styles.iconContainer}>
|
||||
{notification.type === 'follow' && notification.data.userId ? (
|
||||
<Avatar
|
||||
source={null}
|
||||
size={40}
|
||||
name={notification.title}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.iconWrapper, { backgroundColor: color + '20' }]}>
|
||||
<MaterialCommunityIcons name={icon as any} size={20} color={color} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 通知内容 */}
|
||||
<View style={styles.content}>
|
||||
<View style={styles.textContainer}>
|
||||
<Text
|
||||
variant="body"
|
||||
numberOfLines={2}
|
||||
style={!notification.isRead ? styles.unreadText : undefined}
|
||||
>
|
||||
{notification.content}
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
{formatTime(notification.createdAt)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 未读标记 */}
|
||||
{!notification.isRead && <View style={styles.unreadDot} />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: spacing.lg,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
unread: {
|
||||
backgroundColor: colors.primary.light + '10',
|
||||
},
|
||||
iconContainer: {
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
iconWrapper: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
textContainer: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
unreadText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
unreadDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: colors.primary.main,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
});
|
||||
|
||||
export default NotificationItem;
|
||||
911
src/components/business/PostCard.tsx
Normal file
911
src/components/business/PostCard.tsx
Normal file
@@ -0,0 +1,911 @@
|
||||
/**
|
||||
* PostCard 帖子卡片组件 - QQ频道风格(响应式版本)
|
||||
* 简洁的帖子展示,无卡片边框
|
||||
* 支持响应式布局,宽屏下优化显示
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Alert,
|
||||
useWindowDimensions,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
import { zhCN } from 'date-fns/locale';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { Post } from '../../types';
|
||||
import Text from '../common/Text';
|
||||
import Avatar from '../common/Avatar';
|
||||
import { ImageGrid, ImageGridItem, SmartImage } from '../common';
|
||||
import VotePreview from './VotePreview';
|
||||
import { useResponsive, useResponsiveValue } from '../../hooks/useResponsive';
|
||||
|
||||
interface PostCardProps {
|
||||
post: Post;
|
||||
onPress: () => void;
|
||||
onUserPress: () => void;
|
||||
onLike: () => void;
|
||||
onComment: () => void;
|
||||
onBookmark: () => void;
|
||||
onShare: () => void;
|
||||
onImagePress?: (images: ImageGridItem[], index: number) => void;
|
||||
onDelete?: () => void;
|
||||
compact?: boolean;
|
||||
index?: number;
|
||||
isAuthor?: boolean;
|
||||
isPostAuthor?: boolean; // 当前用户是否为帖子作者
|
||||
variant?: 'default' | 'grid';
|
||||
}
|
||||
|
||||
const PostCard: React.FC<PostCardProps> = ({
|
||||
post,
|
||||
onPress,
|
||||
onUserPress,
|
||||
onLike,
|
||||
onComment,
|
||||
onBookmark,
|
||||
onShare,
|
||||
onImagePress,
|
||||
onDelete,
|
||||
compact = false,
|
||||
index,
|
||||
isAuthor = false,
|
||||
isPostAuthor = false,
|
||||
variant = 'default',
|
||||
}) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
// 使用响应式 hook
|
||||
const {
|
||||
width,
|
||||
isMobile,
|
||||
isTablet,
|
||||
isDesktop,
|
||||
isWideScreen,
|
||||
isLandscape,
|
||||
orientation
|
||||
} = useResponsive();
|
||||
|
||||
const { width: windowWidth } = useWindowDimensions();
|
||||
|
||||
// 响应式字体大小
|
||||
const responsiveFontSize = useResponsiveValue({
|
||||
xs: fontSizes.md,
|
||||
sm: fontSizes.md,
|
||||
md: fontSizes.lg,
|
||||
lg: isLandscape ? fontSizes.lg : fontSizes.xl,
|
||||
xl: fontSizes.xl,
|
||||
'2xl': fontSizes.xl,
|
||||
'3xl': fontSizes['2xl'],
|
||||
'4xl': fontSizes['2xl'],
|
||||
});
|
||||
|
||||
// 响应式标题字体大小
|
||||
const responsiveTitleFontSize = useResponsiveValue({
|
||||
xs: fontSizes.lg,
|
||||
sm: fontSizes.lg,
|
||||
md: fontSizes.xl,
|
||||
lg: isLandscape ? fontSizes.xl : fontSizes['2xl'],
|
||||
xl: fontSizes['2xl'],
|
||||
'2xl': fontSizes['2xl'],
|
||||
'3xl': fontSizes['3xl'],
|
||||
'4xl': fontSizes['3xl'],
|
||||
});
|
||||
|
||||
// 响应式头像大小
|
||||
const avatarSize = useResponsiveValue({
|
||||
xs: 36,
|
||||
sm: 38,
|
||||
md: 40,
|
||||
lg: 44,
|
||||
xl: 48,
|
||||
'2xl': 48,
|
||||
'3xl': 52,
|
||||
'4xl': 56,
|
||||
});
|
||||
|
||||
// 响应式内边距 - 宽屏下增加更多内边距
|
||||
const responsivePadding = useResponsiveValue({
|
||||
xs: spacing.md,
|
||||
sm: spacing.md,
|
||||
md: spacing.lg,
|
||||
lg: spacing.xl,
|
||||
xl: spacing.xl * 1.2,
|
||||
'2xl': spacing.xl * 1.5,
|
||||
'3xl': spacing.xl * 2,
|
||||
'4xl': spacing.xl * 2.5,
|
||||
});
|
||||
|
||||
// 宽屏下额外的卡片内边距
|
||||
const cardPadding = useMemo(() => {
|
||||
if (isWideScreen) return spacing.xl;
|
||||
if (isDesktop) return spacing.lg;
|
||||
if (isTablet) return spacing.md;
|
||||
return 0; // 移动端无额外内边距
|
||||
}, [isWideScreen, isDesktop, isTablet]);
|
||||
|
||||
const formatTime = (dateString: string | undefined | null): string => {
|
||||
if (!dateString) return '';
|
||||
try {
|
||||
return formatDistanceToNow(new Date(dateString), {
|
||||
addSuffix: true,
|
||||
locale: zhCN,
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getTruncatedContent = (content: string | undefined | null, maxLength: number = 100): string => {
|
||||
if (!content) return '';
|
||||
if (content.length <= maxLength || isExpanded) return content;
|
||||
return content.substring(0, maxLength) + '...';
|
||||
};
|
||||
|
||||
// 宽屏下显示更多内容
|
||||
const getResponsiveMaxLength = () => {
|
||||
if (isWideScreen) return 300;
|
||||
if (isDesktop) return 250;
|
||||
if (isTablet) return 200;
|
||||
return 100;
|
||||
};
|
||||
|
||||
const formatNumber = (num: number): string => {
|
||||
if (num >= 10000) {
|
||||
return (num / 10000).toFixed(1) + 'w';
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1) + 'k';
|
||||
}
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
// 处理删除帖子
|
||||
const handleDelete = () => {
|
||||
if (!onDelete || isDeleting) return;
|
||||
|
||||
Alert.alert(
|
||||
'删除帖子',
|
||||
'确定要删除这篇帖子吗?删除后将无法恢复。',
|
||||
[
|
||||
{
|
||||
text: '取消',
|
||||
style: 'cancel',
|
||||
},
|
||||
{
|
||||
text: '删除',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDelete();
|
||||
} catch (error) {
|
||||
console.error('删除帖子失败:', error);
|
||||
Alert.alert('删除失败', '删除帖子时发生错误,请稍后重试');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染图片 - 使用新的 ImageGrid 组件
|
||||
const renderImages = () => {
|
||||
if (!post.images || !Array.isArray(post.images) || post.images.length === 0) return null;
|
||||
|
||||
// 转换图片数据格式
|
||||
const gridImages: ImageGridItem[] = post.images.map(img => ({
|
||||
id: img.id,
|
||||
url: img.url,
|
||||
thumbnail_url: img.thumbnail_url,
|
||||
width: img.width,
|
||||
height: img.height,
|
||||
}));
|
||||
|
||||
// 宽屏下显示更大的图片
|
||||
const imageGap = isDesktop ? 12 : isTablet ? 8 : 4;
|
||||
const imageBorderRadius = isDesktop ? borderRadius.xl : borderRadius.md;
|
||||
|
||||
return (
|
||||
<View style={styles.imagesContainer}>
|
||||
<ImageGrid
|
||||
images={gridImages}
|
||||
maxDisplayCount={isWideScreen ? 12 : 9}
|
||||
mode="auto"
|
||||
gap={imageGap}
|
||||
borderRadius={imageBorderRadius}
|
||||
showMoreOverlay={true}
|
||||
onImagePress={onImagePress}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBadges = () => {
|
||||
const badges = [];
|
||||
|
||||
if (isAuthor) {
|
||||
badges.push(
|
||||
<View key="author" style={[styles.badge, styles.authorBadge]}>
|
||||
<Text variant="caption" style={styles.badgeText}>楼主</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (post.author?.id === '1') {
|
||||
badges.push(
|
||||
<View key="admin" style={[styles.badge, styles.adminBadge]}>
|
||||
<Text variant="caption" style={styles.badgeText}>管理员</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return badges;
|
||||
};
|
||||
|
||||
const renderTopComment = () => {
|
||||
if (!post.top_comment) return null;
|
||||
|
||||
const { top_comment } = post;
|
||||
|
||||
const topCommentContainerStyles = [
|
||||
styles.topCommentContainer,
|
||||
...(isDesktop ? [styles.topCommentContainerWide] : [])
|
||||
];
|
||||
|
||||
const topCommentAuthorStyles = [
|
||||
styles.topCommentAuthor,
|
||||
...(isDesktop ? [styles.topCommentAuthorWide] : [])
|
||||
];
|
||||
|
||||
const topCommentTextStyles = [
|
||||
styles.topCommentText,
|
||||
...(isDesktop ? [styles.topCommentTextWide] : [])
|
||||
];
|
||||
|
||||
const moreCommentsStyles = [
|
||||
styles.moreComments,
|
||||
...(isDesktop ? [styles.moreCommentsWide] : [])
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={topCommentContainerStyles}>
|
||||
<View style={styles.topCommentContent}>
|
||||
<Text
|
||||
variant="caption"
|
||||
color={colors.text.secondary}
|
||||
style={topCommentAuthorStyles}
|
||||
>
|
||||
{top_comment.author?.nickname || '匿名用户'}:
|
||||
</Text>
|
||||
<Text
|
||||
variant="caption"
|
||||
color={colors.text.secondary}
|
||||
style={topCommentTextStyles}
|
||||
numberOfLines={isDesktop ? 2 : 1}
|
||||
>
|
||||
{top_comment.content}
|
||||
</Text>
|
||||
</View>
|
||||
{post.comments_count > 1 && (
|
||||
<Text
|
||||
variant="caption"
|
||||
color={colors.primary.main}
|
||||
style={moreCommentsStyles}
|
||||
>
|
||||
查看全部{formatNumber(post.comments_count)}条评论
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染投票预览
|
||||
const renderVotePreview = () => {
|
||||
if (!post.is_vote) return null;
|
||||
|
||||
return (
|
||||
<VotePreview
|
||||
onPress={onPress}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染小红书风格的两栏卡片
|
||||
const renderGridVariant = () => {
|
||||
// 获取封面图(第一张图)
|
||||
const coverImage = post.images && Array.isArray(post.images) && post.images.length > 0 ? post.images[0] : null;
|
||||
const hasImage = !!coverImage;
|
||||
|
||||
// 防御性检查:确保 author 存在
|
||||
const author = post.author || { id: '', nickname: '匿名用户', avatar: null, username: '' };
|
||||
|
||||
// 根据图片实际宽高比或使用随机值创建错落感
|
||||
// 使用帖子 ID 生成一个伪随机值,确保每次渲染一致但不同帖子有差异
|
||||
const postId = post.id || '';
|
||||
const hash = postId.split('').reduce((a, b) => a + b.charCodeAt(0), 0);
|
||||
const aspectRatios = [0.7, 0.75, 0.8, 0.85, 0.9, 1, 1.1, 1.2];
|
||||
const aspectRatio = aspectRatios[hash % aspectRatios.length];
|
||||
|
||||
// 获取正文预览(无图时显示)
|
||||
const getContentPreview = (content: string | undefined | null): string => {
|
||||
if (!content) return '';
|
||||
// 移除 Markdown 标记和多余空白
|
||||
return content
|
||||
.replace(/[#*_~`\[\]()]/g, '')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const contentPreview = getContentPreview(post.content);
|
||||
|
||||
// 响应式字体大小(网格模式)
|
||||
const gridTitleFontSize = isDesktop ? 16 : isTablet ? 15 : 14;
|
||||
const gridUsernameFontSize = isDesktop ? 14 : 12;
|
||||
const gridLikeFontSize = isDesktop ? 14 : 12;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.gridContainer,
|
||||
!hasImage && styles.gridContainerNoImage,
|
||||
isDesktop && styles.gridContainerDesktop
|
||||
]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{/* 封面图 - 只有有图片时才渲染,无图时不显示占位区域 */}
|
||||
{hasImage && (
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.8}
|
||||
onPress={() => onImagePress?.(post.images || [], 0)}
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: coverImage.thumbnail_url || coverImage.url || '' }}
|
||||
style={[styles.gridCoverImage, { aspectRatio }]}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* 无图时的正文预览区域 */}
|
||||
{!hasImage && contentPreview && (
|
||||
<View style={[styles.gridContentPreview, isDesktop ? styles.gridContentPreviewDesktop : null]}>
|
||||
<Text
|
||||
style={[styles.gridContentText, ...(isDesktop ? [styles.gridContentTextDesktop] : [])]}
|
||||
numberOfLines={isDesktop ? 8 : 6}
|
||||
>
|
||||
{contentPreview}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 投票标识 */}
|
||||
{post.is_vote && (
|
||||
<View style={styles.gridVoteBadge}>
|
||||
<MaterialCommunityIcons name="vote" size={isDesktop ? 14 : 12} color={colors.primary.contrast} />
|
||||
<Text style={styles.gridVoteBadgeText}>投票</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 标题 - 无图时显示更多行 */}
|
||||
{post.title && (
|
||||
<Text
|
||||
style={[
|
||||
styles.gridTitle,
|
||||
{ fontSize: gridTitleFontSize },
|
||||
...(hasImage ? [] : [styles.gridTitleNoImage]),
|
||||
...(isDesktop ? [styles.gridTitleDesktop] : [])
|
||||
]}
|
||||
numberOfLines={hasImage ? 2 : 3}
|
||||
>
|
||||
{post.title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 底部信息 */}
|
||||
<View style={[styles.gridFooter, isDesktop && styles.gridFooterDesktop]}>
|
||||
<TouchableOpacity style={styles.gridUserInfo} onPress={onUserPress}>
|
||||
<Avatar
|
||||
source={author.avatar}
|
||||
size={isDesktop ? 24 : 20}
|
||||
name={author.nickname}
|
||||
/>
|
||||
<Text style={[styles.gridUsername, { fontSize: gridUsernameFontSize }]} numberOfLines={1}>
|
||||
{author.nickname}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.gridLikeInfo}>
|
||||
<MaterialCommunityIcons name="heart" size={isDesktop ? 16 : 14} color="#666" />
|
||||
<Text style={[styles.gridLikeCount, { fontSize: gridLikeFontSize }]}>
|
||||
{formatNumber(post.likes_count)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
// 根据 variant 渲染不同样式
|
||||
if (variant === 'grid') {
|
||||
return renderGridVariant();
|
||||
}
|
||||
|
||||
// 防御性检查:确保 author 存在
|
||||
const author = post.author || { id: '', nickname: '匿名用户', avatar: null, username: '' };
|
||||
|
||||
// 计算响应式内容最大行数
|
||||
const contentNumberOfLines = useMemo(() => {
|
||||
if (isWideScreen) return 8;
|
||||
if (isDesktop) return 6;
|
||||
if (isTablet) return 5;
|
||||
return 3;
|
||||
}, [isWideScreen, isDesktop, isTablet]);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
paddingHorizontal: responsivePadding,
|
||||
paddingVertical: responsivePadding * 0.75,
|
||||
...(isDesktop || isWideScreen ? {
|
||||
borderRadius: borderRadius.lg,
|
||||
marginHorizontal: cardPadding,
|
||||
marginVertical: spacing.sm,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
} : {})
|
||||
}
|
||||
]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{/* 用户信息 */}
|
||||
<View style={styles.userSection}>
|
||||
<TouchableOpacity onPress={onUserPress}>
|
||||
<Avatar
|
||||
source={author.avatar}
|
||||
size={avatarSize}
|
||||
name={author.nickname}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.userInfo}>
|
||||
<View style={styles.userNameRow}>
|
||||
<TouchableOpacity onPress={onUserPress}>
|
||||
<Text
|
||||
variant="body"
|
||||
style={[
|
||||
styles.username,
|
||||
{ fontSize: isDesktop ? fontSizes.lg : fontSizes.md }
|
||||
]}
|
||||
>
|
||||
{author.nickname}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
{renderBadges()}
|
||||
</View>
|
||||
<View style={styles.postMeta}>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
|
||||
{formatTime(post.created_at || '')}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{post.is_pinned && (
|
||||
<View style={styles.pinnedTag}>
|
||||
<MaterialCommunityIcons name="pin" size={isDesktop ? 14 : 12} color={colors.warning.main} />
|
||||
<Text variant="caption" color={colors.warning.main} style={styles.pinnedText}>置顶</Text>
|
||||
</View>
|
||||
)}
|
||||
{/* 删除按钮 - 只对帖子作者显示 */}
|
||||
{isPostAuthor && onDelete && (
|
||||
<TouchableOpacity
|
||||
style={styles.deleteButton}
|
||||
onPress={handleDelete}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={isDeleting ? 'loading' : 'delete-outline'}
|
||||
size={isDesktop ? 20 : 18}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 标题 */}
|
||||
{post.title && (
|
||||
<Text
|
||||
variant="body"
|
||||
numberOfLines={compact ? 2 : undefined}
|
||||
style={[
|
||||
styles.title,
|
||||
{ fontSize: responsiveTitleFontSize, lineHeight: responsiveTitleFontSize * 1.4 }
|
||||
]}
|
||||
>
|
||||
{post.title}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* 内容 */}
|
||||
{!compact && (
|
||||
<>
|
||||
<Text
|
||||
variant="body"
|
||||
color={colors.text.secondary}
|
||||
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
|
||||
style={[
|
||||
styles.content,
|
||||
{ fontSize: responsiveFontSize, lineHeight: responsiveFontSize * 1.5 }
|
||||
]}
|
||||
>
|
||||
{getTruncatedContent(post.content, getResponsiveMaxLength())}
|
||||
</Text>
|
||||
{post.content && post.content.length > getResponsiveMaxLength() && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setIsExpanded(!isExpanded)}
|
||||
style={styles.expandButton}
|
||||
>
|
||||
<Text variant="caption" color={colors.primary.main}>
|
||||
{isExpanded ? '收起' : '展开全文'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 图片 */}
|
||||
{renderImages()}
|
||||
|
||||
{/* 投票预览 */}
|
||||
{renderVotePreview()}
|
||||
|
||||
{/* 热门评论预览 */}
|
||||
{renderTopComment()}
|
||||
|
||||
{/* 交互按钮 */}
|
||||
<View style={[styles.actionBar, isDesktop ? styles.actionBarWide : null]}>
|
||||
<View style={styles.viewCount}>
|
||||
<MaterialCommunityIcons name="eye-outline" size={isDesktop ? 18 : 16} color={colors.text.hint} />
|
||||
<Text
|
||||
variant="caption"
|
||||
color={colors.text.hint}
|
||||
style={[styles.actionText, ...(isDesktop ? [styles.actionTextWide] : [])]}
|
||||
>
|
||||
{formatNumber(post.views_count || 0)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.actionButtons}>
|
||||
<TouchableOpacity style={[styles.actionButton, ...(isDesktop ? [styles.actionButtonWide] : [])]} onPress={onLike}>
|
||||
<MaterialCommunityIcons
|
||||
name={post.is_liked ? 'heart' : 'heart-outline'}
|
||||
size={isDesktop ? 22 : 19}
|
||||
color={post.is_liked ? colors.error.main : colors.text.secondary}
|
||||
/>
|
||||
<Text
|
||||
variant="caption"
|
||||
color={post.is_liked ? colors.error.main : colors.text.secondary}
|
||||
style={[styles.actionText, ...(isDesktop ? [styles.actionTextWide] : [])]}
|
||||
>
|
||||
{post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={[styles.actionButton, ...(isDesktop ? [styles.actionButtonWide] : [])]} onPress={onComment}>
|
||||
<MaterialCommunityIcons
|
||||
name="comment-outline"
|
||||
size={isDesktop ? 22 : 19}
|
||||
color={colors.text.secondary}
|
||||
/>
|
||||
<Text variant="caption" color={colors.text.secondary} style={[styles.actionText, ...(isDesktop ? [styles.actionTextWide] : [])]}>
|
||||
{post.comments_count > 0 ? formatNumber(post.comments_count) : '评论'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={[styles.actionButton, ...(isDesktop ? [styles.actionButtonWide] : [])]} onPress={onShare}>
|
||||
<MaterialCommunityIcons
|
||||
name="share-outline"
|
||||
size={isDesktop ? 22 : 19}
|
||||
color={colors.text.secondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={[styles.actionButton, isDesktop && styles.actionButtonWide]} onPress={onBookmark}>
|
||||
<MaterialCommunityIcons
|
||||
name={post.is_favorited ? 'bookmark' : 'bookmark-outline'}
|
||||
size={isDesktop ? 22 : 19}
|
||||
color={post.is_favorited ? colors.warning.main : colors.text.secondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
// 宽屏卡片样式
|
||||
wideCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
userSection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
userInfo: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
userNameRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
username: {
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
badge: {
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
borderRadius: 2,
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
authorBadge: {
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
adminBadge: {
|
||||
backgroundColor: colors.error.main,
|
||||
},
|
||||
badgeText: {
|
||||
color: colors.text.inverse,
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '600',
|
||||
},
|
||||
postMeta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 2,
|
||||
},
|
||||
timeText: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.hint,
|
||||
},
|
||||
pinnedTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.warning.light + '20',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
},
|
||||
pinnedText: {
|
||||
marginLeft: 2,
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '500',
|
||||
},
|
||||
deleteButton: {
|
||||
padding: spacing.xs,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
marginBottom: spacing.xs,
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
content: {
|
||||
marginBottom: spacing.xs,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
expandButton: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
imagesContainer: {
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
topCommentContainer: {
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.sm,
|
||||
padding: spacing.sm,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
topCommentContainerWide: {
|
||||
padding: spacing.md,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
topCommentContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
topCommentAuthor: {
|
||||
fontWeight: '600',
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
topCommentAuthorWide: {
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
topCommentText: {
|
||||
flex: 1,
|
||||
},
|
||||
topCommentTextWide: {
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
moreComments: {
|
||||
marginTop: spacing.xs,
|
||||
fontWeight: '500',
|
||||
},
|
||||
moreCommentsWide: {
|
||||
fontSize: fontSizes.sm,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
actionBar: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
},
|
||||
actionBarWide: {
|
||||
marginTop: spacing.sm,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
viewCount: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
actionButtons: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
actionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginLeft: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
actionButtonWide: {
|
||||
marginLeft: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
actionText: {
|
||||
marginLeft: 6,
|
||||
},
|
||||
actionTextWide: {
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
|
||||
// ========== 小红书风格两栏样式 ==========
|
||||
gridContainer: {
|
||||
backgroundColor: '#FFF',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 8,
|
||||
},
|
||||
gridContainerDesktop: {
|
||||
borderRadius: 12,
|
||||
},
|
||||
gridContainerNoImage: {
|
||||
minHeight: 200,
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
gridCoverImage: {
|
||||
width: '100%',
|
||||
aspectRatio: 0.7,
|
||||
backgroundColor: '#F5F5F5',
|
||||
},
|
||||
gridCoverPlaceholder: {
|
||||
width: '100%',
|
||||
aspectRatio: 0.7,
|
||||
backgroundColor: '#F5F5F5',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
// 无图时的正文预览区域
|
||||
gridContentPreview: {
|
||||
backgroundColor: '#F8F8F8',
|
||||
margin: 4,
|
||||
padding: 8,
|
||||
borderRadius: 8,
|
||||
minHeight: 100,
|
||||
},
|
||||
gridContentPreviewDesktop: {
|
||||
margin: 8,
|
||||
padding: 12,
|
||||
borderRadius: 12,
|
||||
minHeight: 150,
|
||||
},
|
||||
gridContentText: {
|
||||
fontSize: 13,
|
||||
color: '#666',
|
||||
lineHeight: 20,
|
||||
},
|
||||
gridContentTextDesktop: {
|
||||
fontSize: 15,
|
||||
lineHeight: 24,
|
||||
},
|
||||
gridTitle: {
|
||||
color: '#333',
|
||||
lineHeight: 20,
|
||||
paddingHorizontal: 4,
|
||||
paddingTop: 8,
|
||||
minHeight: 44,
|
||||
},
|
||||
gridTitleNoImage: {
|
||||
minHeight: 0,
|
||||
paddingTop: 4,
|
||||
},
|
||||
gridTitleDesktop: {
|
||||
paddingHorizontal: 8,
|
||||
paddingTop: 12,
|
||||
lineHeight: 24,
|
||||
minHeight: 56,
|
||||
},
|
||||
gridFooter: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
gridFooterDesktop: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
gridUserInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
gridUsername: {
|
||||
color: '#666',
|
||||
marginLeft: 6,
|
||||
flex: 1,
|
||||
},
|
||||
gridLikeInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
gridLikeCount: {
|
||||
color: '#666',
|
||||
marginLeft: 4,
|
||||
},
|
||||
gridVoteBadge: {
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary.main + 'CC',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: borderRadius.full,
|
||||
gap: 4,
|
||||
},
|
||||
gridVoteBadgeText: {
|
||||
fontSize: 10,
|
||||
color: colors.primary.contrast,
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default PostCard;
|
||||
134
src/components/business/SearchBar.tsx
Normal file
134
src/components/business/SearchBar.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* SearchBar 搜索栏组件
|
||||
* 用于搜索内容
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { View, TextInput, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
|
||||
interface SearchBarProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
onSubmit: () => void;
|
||||
placeholder?: string;
|
||||
onFocus?: () => void;
|
||||
onBlur?: () => void;
|
||||
autoFocus?: boolean;
|
||||
}
|
||||
|
||||
const SearchBar: React.FC<SearchBarProps> = ({
|
||||
value,
|
||||
onChangeText,
|
||||
onSubmit,
|
||||
placeholder = '搜索...',
|
||||
onFocus,
|
||||
onBlur,
|
||||
autoFocus = false,
|
||||
}) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
onFocus?.();
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
onBlur?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, isFocused && styles.containerFocused]}>
|
||||
<View style={[styles.searchIconWrap, isFocused && styles.searchIconWrapFocused]}>
|
||||
<MaterialCommunityIcons
|
||||
name="magnify"
|
||||
size={18}
|
||||
color={isFocused ? colors.primary.main : colors.text.secondary}
|
||||
/>
|
||||
</View>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.text.hint}
|
||||
returnKeyType="search"
|
||||
onSubmitEditing={onSubmit}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
{value.length > 0 && (
|
||||
<TouchableOpacity
|
||||
onPress={() => onChangeText('')}
|
||||
style={styles.clearButton}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="close"
|
||||
size={14}
|
||||
color={colors.text.secondary}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.full,
|
||||
paddingHorizontal: spacing.xs,
|
||||
height: 46,
|
||||
borderWidth: 1,
|
||||
borderColor: '#E7E7E7',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.09,
|
||||
shadowRadius: 6,
|
||||
elevation: 2,
|
||||
},
|
||||
containerFocused: {
|
||||
borderColor: `${colors.primary.main}66`,
|
||||
shadowColor: colors.primary.main,
|
||||
shadowOpacity: 0.18,
|
||||
shadowRadius: 10,
|
||||
elevation: 4,
|
||||
},
|
||||
searchIconWrap: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
marginLeft: spacing.xs,
|
||||
marginRight: spacing.xs,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.text.secondary}12`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
searchIconWrapFocused: {
|
||||
backgroundColor: `${colors.primary.main}1A`,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
paddingVertical: spacing.sm + 1,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
clearButton: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
marginHorizontal: spacing.xs,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.text.secondary}14`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default SearchBar;
|
||||
369
src/components/business/SystemMessageItem.tsx
Normal file
369
src/components/business/SystemMessageItem.tsx
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* SystemMessageItem 系统消息项组件
|
||||
* 根据系统消息类型显示不同图标和内容
|
||||
* 参考 QQ 10000 系统消息和微信服务通知样式
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
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 { colors, spacing, borderRadius } from '../../theme';
|
||||
import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
|
||||
import Text from '../common/Text';
|
||||
import Avatar from '../common/Avatar';
|
||||
|
||||
interface SystemMessageItemProps {
|
||||
message: SystemMessageResponse;
|
||||
onPress?: () => void;
|
||||
onAvatarPress?: () => void; // 头像点击回调
|
||||
onRequestAction?: (approve: boolean) => void;
|
||||
requestActionLoading?: boolean;
|
||||
}
|
||||
|
||||
// 系统消息类型到图标和颜色的映射
|
||||
const getSystemMessageIcon = (
|
||||
systemType: SystemMessageType
|
||||
): { icon: keyof typeof MaterialCommunityIcons.glyphMap; color: string; bgColor: string } => {
|
||||
switch (systemType) {
|
||||
case 'like_post':
|
||||
case 'like_comment':
|
||||
case 'like_reply':
|
||||
case 'favorite_post':
|
||||
return {
|
||||
icon: 'heart',
|
||||
color: colors.error.main,
|
||||
bgColor: colors.error.light + '20',
|
||||
};
|
||||
case 'comment':
|
||||
return {
|
||||
icon: 'comment',
|
||||
color: colors.info.main,
|
||||
bgColor: colors.info.light + '20',
|
||||
};
|
||||
case 'reply':
|
||||
return {
|
||||
icon: 'reply',
|
||||
color: colors.success.main,
|
||||
bgColor: colors.success.light + '20',
|
||||
};
|
||||
case 'follow':
|
||||
return {
|
||||
icon: 'account-plus',
|
||||
color: '#9C27B0', // 紫色
|
||||
bgColor: '#9C27B020',
|
||||
};
|
||||
case 'mention':
|
||||
return {
|
||||
icon: 'at',
|
||||
color: colors.warning.main,
|
||||
bgColor: colors.warning.light + '20',
|
||||
};
|
||||
case 'system':
|
||||
return {
|
||||
icon: 'cog',
|
||||
color: colors.text.secondary,
|
||||
bgColor: colors.background.disabled,
|
||||
};
|
||||
case 'announcement':
|
||||
case 'announce':
|
||||
return {
|
||||
icon: 'bullhorn',
|
||||
color: colors.primary.main,
|
||||
bgColor: colors.primary.light + '20',
|
||||
};
|
||||
case 'group_invite':
|
||||
return {
|
||||
icon: 'account-multiple-plus',
|
||||
color: colors.primary.main,
|
||||
bgColor: colors.primary.light + '20',
|
||||
};
|
||||
case 'group_join_apply':
|
||||
return {
|
||||
icon: 'account-clock',
|
||||
color: colors.warning.main,
|
||||
bgColor: colors.warning.light + '20',
|
||||
};
|
||||
case 'group_join_approved':
|
||||
return {
|
||||
icon: 'check-circle',
|
||||
color: colors.success.main,
|
||||
bgColor: colors.success.light + '20',
|
||||
};
|
||||
case 'group_join_rejected':
|
||||
return {
|
||||
icon: 'close-circle',
|
||||
color: colors.error.main,
|
||||
bgColor: colors.error.light + '20',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: 'bell',
|
||||
color: colors.text.secondary,
|
||||
bgColor: colors.background.disabled,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
// 获取系统消息标题
|
||||
const getSystemMessageTitle = (message: SystemMessageResponse): string => {
|
||||
const { system_type, extra_data } = message;
|
||||
// 兼容后端返回的 actor_name 和 operator_name
|
||||
const operatorName = extra_data?.actor_name || extra_data?.operator_name;
|
||||
const groupName = extra_data?.group_name || extra_data?.target_title;
|
||||
|
||||
switch (system_type) {
|
||||
case 'like_post':
|
||||
return operatorName ? `${operatorName} 赞了你的帖子` : '有人赞了你的帖子';
|
||||
case 'like_comment':
|
||||
return operatorName ? `${operatorName} 赞了你的评论` : '有人赞了你的评论';
|
||||
case 'like_reply':
|
||||
return operatorName ? `${operatorName} 赞了你的回复` : '有人赞了你的回复';
|
||||
case 'favorite_post':
|
||||
return operatorName ? `${operatorName} 收藏了你的帖子` : '有人收藏了你的帖子';
|
||||
case 'comment':
|
||||
return operatorName ? `${operatorName} 评论了你的帖子` : '有人评论了你的帖子';
|
||||
case 'reply':
|
||||
return operatorName ? `${operatorName} 回复了你` : '有人回复了你';
|
||||
case 'follow':
|
||||
return operatorName ? `${operatorName} 关注了你` : '有人关注了你';
|
||||
case 'mention':
|
||||
return operatorName ? `${operatorName} @提到了你` : '有人@提到了你';
|
||||
case 'system':
|
||||
return '系统通知';
|
||||
case 'announcement':
|
||||
return '公告';
|
||||
case 'group_invite':
|
||||
return operatorName
|
||||
? `${operatorName} 邀请加入群聊 ${groupName || ''}`.trim()
|
||||
: `收到群邀请${groupName ? `:${groupName}` : ''}`;
|
||||
case 'group_join_apply':
|
||||
if (extra_data?.request_status === 'accepted') {
|
||||
return operatorName ? `${operatorName} 已同意` : '该请求已同意';
|
||||
}
|
||||
if (extra_data?.request_status === 'rejected') {
|
||||
return operatorName ? `${operatorName} 已拒绝` : '该请求已拒绝';
|
||||
}
|
||||
return operatorName
|
||||
? `${operatorName} 申请加入群聊 ${groupName || ''}`.trim()
|
||||
: `收到加群申请${groupName ? `:${groupName}` : ''}`;
|
||||
case 'group_join_approved':
|
||||
return '加群申请已通过';
|
||||
case 'group_join_rejected':
|
||||
return '加群申请被拒绝';
|
||||
default:
|
||||
return '通知';
|
||||
}
|
||||
};
|
||||
|
||||
const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
|
||||
message,
|
||||
onPress,
|
||||
onAvatarPress,
|
||||
onRequestAction,
|
||||
requestActionLoading = false,
|
||||
}) => {
|
||||
// 格式化时间
|
||||
const formatTime = (dateString: string): string => {
|
||||
try {
|
||||
return formatDistanceToNow(new Date(dateString), {
|
||||
addSuffix: true,
|
||||
locale: zhCN,
|
||||
});
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const { icon, color, bgColor } = getSystemMessageIcon(message.system_type);
|
||||
const title = getSystemMessageTitle(message);
|
||||
const { extra_data } = message;
|
||||
// 兼容后端返回的 actor_name 和 operator_name
|
||||
const operatorName = extra_data?.actor_name || extra_data?.operator_name;
|
||||
const operatorAvatar = extra_data?.avatar_url || extra_data?.operator_avatar;
|
||||
const groupAvatar = extra_data?.group_avatar;
|
||||
const requestStatus = extra_data?.request_status;
|
||||
const isActionable =
|
||||
(message.system_type === 'group_invite' || message.system_type === 'group_join_apply') &&
|
||||
requestStatus === 'pending' &&
|
||||
!!onRequestAction;
|
||||
|
||||
// 判断是否显示操作者头像
|
||||
const showOperatorAvatar = ['follow', 'like_post', 'like_comment', 'like_reply', 'favorite_post', 'comment', 'reply', 'mention', 'group_join_apply'].includes(
|
||||
message.system_type
|
||||
);
|
||||
const showGroupAvatar = message.system_type === 'group_invite';
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.container}
|
||||
onPress={onPress}
|
||||
activeOpacity={onPress ? 0.7 : 1}
|
||||
disabled={!onPress}
|
||||
>
|
||||
{/* 图标/头像区域 */}
|
||||
<View style={styles.iconContainer}>
|
||||
{showGroupAvatar ? (
|
||||
<Avatar
|
||||
source={groupAvatar || ''}
|
||||
size={44}
|
||||
name={extra_data?.group_name || '群聊'}
|
||||
/>
|
||||
) : showOperatorAvatar ? (
|
||||
<Avatar
|
||||
source={operatorAvatar || ''}
|
||||
size={44}
|
||||
name={operatorName}
|
||||
onPress={onAvatarPress}
|
||||
/>
|
||||
) : (
|
||||
<View style={[styles.iconWrapper, { backgroundColor: bgColor }]}>
|
||||
<MaterialCommunityIcons name={icon} size={22} color={color} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 内容区域 */}
|
||||
<View style={styles.content}>
|
||||
{/* 标题行 */}
|
||||
<View style={styles.titleRow}>
|
||||
<Text variant="body" style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
{formatTime(message.created_at)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 消息内容 */}
|
||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={2} style={styles.messageContent}>
|
||||
{message.content}
|
||||
</Text>
|
||||
|
||||
{/* 附加信息 - 优先显示 target_title,图标根据 target_type 区分 */}
|
||||
{(extra_data?.target_title || extra_data?.post_title || extra_data?.comment_preview) &&
|
||||
!['group_invite', 'group_join_apply', 'group_join_approved', 'group_join_rejected'].includes(message.system_type) &&
|
||||
(() => {
|
||||
const isCommentType = ['comment', 'reply'].includes(extra_data?.target_type ?? '');
|
||||
const previewText = extra_data?.target_title || extra_data?.post_title || extra_data?.comment_preview;
|
||||
const iconName = isCommentType ? 'comment-outline' : 'file-document-outline';
|
||||
return (
|
||||
<View style={styles.extraInfo}>
|
||||
<MaterialCommunityIcons name={iconName} size={12} color={colors.text.hint} />
|
||||
<Text variant="caption" color={colors.text.hint} numberOfLines={1} style={styles.extraText}>
|
||||
{previewText}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
})()}
|
||||
|
||||
{isActionable && (
|
||||
<View style={styles.actionsRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionBtn, styles.rejectBtn]}
|
||||
onPress={() => onRequestAction?.(false)}
|
||||
disabled={requestActionLoading}
|
||||
>
|
||||
<Text variant="caption" color={colors.error.main}>拒绝</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionBtn, styles.approveBtn]}
|
||||
onPress={() => onRequestAction?.(true)}
|
||||
disabled={requestActionLoading}
|
||||
>
|
||||
<Text variant="caption" color={colors.success.main}>同意</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 右侧箭头(如果有跳转) */}
|
||||
{onPress && (
|
||||
<View style={styles.arrowContainer}>
|
||||
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
padding: spacing.lg,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
iconContainer: {
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
iconWrapper: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
title: {
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
messageContent: {
|
||||
lineHeight: 18,
|
||||
},
|
||||
extraInfo: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xs,
|
||||
backgroundColor: colors.background.default,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.sm,
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
extraText: {
|
||||
marginLeft: spacing.xs,
|
||||
flex: 1,
|
||||
},
|
||||
actionsRow: {
|
||||
flexDirection: 'row',
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
actionBtn: {
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.sm,
|
||||
borderWidth: 1,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
rejectBtn: {
|
||||
borderColor: colors.error.light,
|
||||
backgroundColor: colors.error.light + '18',
|
||||
},
|
||||
approveBtn: {
|
||||
borderColor: colors.success.light,
|
||||
backgroundColor: colors.success.light + '18',
|
||||
},
|
||||
arrowContainer: {
|
||||
marginLeft: spacing.sm,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
height: 20,
|
||||
},
|
||||
});
|
||||
|
||||
export default SystemMessageItem;
|
||||
347
src/components/business/TabBar.tsx
Normal file
347
src/components/business/TabBar.tsx
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* TabBar 标签栏组件 - 美化版
|
||||
* 用于切换不同标签页,支持多种样式变体
|
||||
* 新增胶囊式、分段式等现代设计风格
|
||||
*/
|
||||
|
||||
import React, { ReactNode } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, ScrollView, Animated } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import Text from '../common/Text';
|
||||
|
||||
type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern';
|
||||
|
||||
interface TabBarProps {
|
||||
tabs: string[];
|
||||
activeIndex: number;
|
||||
onTabChange: (index: number) => void;
|
||||
scrollable?: boolean;
|
||||
rightContent?: ReactNode;
|
||||
variant?: TabBarVariant;
|
||||
icons?: string[];
|
||||
}
|
||||
|
||||
const TabBar: React.FC<TabBarProps> = ({
|
||||
tabs,
|
||||
activeIndex,
|
||||
onTabChange,
|
||||
scrollable = false,
|
||||
rightContent,
|
||||
variant = 'default',
|
||||
icons,
|
||||
}) => {
|
||||
const renderTabs = () => {
|
||||
return tabs.map((tab, index) => {
|
||||
const isActive = index === activeIndex;
|
||||
const icon = icons?.[index];
|
||||
|
||||
if (variant === 'modern') {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[styles.modernTab, isActive && styles.modernTabActive]}
|
||||
onPress={() => onTabChange(index)}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<View style={styles.modernTabContent}>
|
||||
{icon && (
|
||||
<MaterialCommunityIcons
|
||||
name={icon as any}
|
||||
size={18}
|
||||
color={isActive ? colors.primary.main : colors.text.secondary}
|
||||
style={styles.modernTabIcon}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
variant="body"
|
||||
color={isActive ? colors.primary.main : colors.text.secondary}
|
||||
style={isActive ? [styles.modernTabText, styles.modernTabTextActive] : styles.modernTabText}
|
||||
>
|
||||
{tab}
|
||||
</Text>
|
||||
</View>
|
||||
{isActive && <View style={styles.modernTabIndicator} />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'pill') {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[styles.pillTab, isActive && styles.pillTabActive]}
|
||||
onPress={() => onTabChange(index)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
color={isActive ? colors.text.inverse : colors.text.secondary}
|
||||
style={styles.pillTabText}
|
||||
>
|
||||
{tab}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'segmented') {
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === tabs.length - 1;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[
|
||||
styles.segmentedTab,
|
||||
isActive && styles.segmentedTabActive,
|
||||
isFirst && styles.segmentedTabFirst,
|
||||
isLast && styles.segmentedTabLast,
|
||||
]}
|
||||
onPress={() => onTabChange(index)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.segmentedTabContent}>
|
||||
{icon && (
|
||||
<MaterialCommunityIcons
|
||||
name={icon as any}
|
||||
size={16}
|
||||
color={isActive ? colors.primary.main : colors.text.secondary}
|
||||
style={styles.segmentedTabIcon}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
variant="body"
|
||||
color={isActive ? colors.primary.main : colors.text.secondary}
|
||||
style={styles.segmentedTabText}
|
||||
>
|
||||
{tab}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
// default variant
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[styles.tab, isActive && styles.activeTab]}
|
||||
onPress={() => onTabChange(index)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
color={isActive ? colors.primary.main : colors.text.secondary}
|
||||
style={isActive ? [styles.tabText, styles.activeTabText] : styles.tabText}
|
||||
>
|
||||
{tab}
|
||||
</Text>
|
||||
{isActive && <View style={styles.activeIndicator} />}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const getContainerStyle = () => {
|
||||
switch (variant) {
|
||||
case 'pill':
|
||||
return styles.pillContainer;
|
||||
case 'segmented':
|
||||
return styles.segmentedContainer;
|
||||
case 'modern':
|
||||
return styles.modernContainer;
|
||||
default:
|
||||
return styles.container;
|
||||
}
|
||||
};
|
||||
|
||||
if (scrollable) {
|
||||
return (
|
||||
<View style={getContainerStyle()}>
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.scrollableContainer}
|
||||
>
|
||||
{renderTabs()}
|
||||
</ScrollView>
|
||||
{rightContent && <View style={styles.rightContent}>{rightContent}</View>}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={getContainerStyle()}>
|
||||
{renderTabs()}
|
||||
{rightContent && <View style={styles.rightContent}>{rightContent}</View>}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// Default variant
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
alignItems: 'center',
|
||||
paddingRight: spacing.xs,
|
||||
},
|
||||
scrollableContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
flex: 1,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
position: 'relative',
|
||||
},
|
||||
activeTab: {
|
||||
// 激活状态样式
|
||||
},
|
||||
tabText: {
|
||||
fontWeight: '500',
|
||||
},
|
||||
activeTabText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
activeIndicator: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: '25%',
|
||||
right: '25%',
|
||||
height: 3,
|
||||
backgroundColor: colors.primary.main,
|
||||
borderTopLeftRadius: borderRadius.sm,
|
||||
borderTopRightRadius: borderRadius.sm,
|
||||
},
|
||||
rightContent: {
|
||||
paddingLeft: spacing.sm,
|
||||
},
|
||||
|
||||
// Pill variant
|
||||
pillContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.default,
|
||||
padding: spacing.sm,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
pillTab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
pillTabActive: {
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
pillTabText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
// Segmented variant
|
||||
segmentedContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.default,
|
||||
padding: spacing.xs,
|
||||
marginHorizontal: spacing.md,
|
||||
marginVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
},
|
||||
segmentedTab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
segmentedTabActive: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.md,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
segmentedTabFirst: {
|
||||
borderTopLeftRadius: borderRadius.md,
|
||||
borderBottomLeftRadius: borderRadius.md,
|
||||
},
|
||||
segmentedTabLast: {
|
||||
borderTopRightRadius: borderRadius.md,
|
||||
borderBottomRightRadius: borderRadius.md,
|
||||
},
|
||||
segmentedTabText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
segmentedTabContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
segmentedTabIcon: {
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
|
||||
// Modern variant - 现代化标签栏
|
||||
modernContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.xl,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginVertical: spacing.md,
|
||||
padding: spacing.xs,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 8,
|
||||
elevation: 3,
|
||||
},
|
||||
modernTab: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
position: 'relative',
|
||||
},
|
||||
modernTabActive: {
|
||||
backgroundColor: colors.primary.main + '15', // 10% opacity
|
||||
},
|
||||
modernTabContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
modernTabIcon: {
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
modernTabText: {
|
||||
fontWeight: '500',
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
modernTabTextActive: {
|
||||
fontWeight: '700',
|
||||
},
|
||||
modernTabIndicator: {
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
width: 20,
|
||||
height: 3,
|
||||
backgroundColor: colors.primary.main,
|
||||
borderRadius: borderRadius.full,
|
||||
},
|
||||
});
|
||||
|
||||
export default TabBar;
|
||||
541
src/components/business/UserProfileHeader.tsx
Normal file
541
src/components/business/UserProfileHeader.tsx
Normal file
@@ -0,0 +1,541 @@
|
||||
/**
|
||||
* UserProfileHeader 用户资料头部组件 - 美化版(响应式适配)
|
||||
* 显示用户封面、头像、昵称、简介、关注/粉丝数
|
||||
* 采用现代卡片式设计,渐变封面,悬浮头像
|
||||
* 支持互关状态显示
|
||||
* 在宽屏下显示更大的头像和封面
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
Image,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
|
||||
import { User } from '../../types';
|
||||
import Text from '../common/Text';
|
||||
import Button from '../common/Button';
|
||||
import Avatar from '../common/Avatar';
|
||||
import { useResponsive } from '../../hooks';
|
||||
|
||||
interface UserProfileHeaderProps {
|
||||
user: User;
|
||||
isCurrentUser?: boolean;
|
||||
onFollow: () => void;
|
||||
onSettings?: () => void;
|
||||
onEditProfile?: () => void;
|
||||
onMessage?: () => void;
|
||||
onMore?: () => void; // 点击更多按钮
|
||||
onPostsPress?: () => void; // 点击帖子数(可选)
|
||||
onFollowingPress?: () => void; // 点击关注数
|
||||
onFollowersPress?: () => void; // 点击粉丝数
|
||||
onAvatarPress?: () => void; // 点击头像编辑按钮
|
||||
}
|
||||
|
||||
const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
|
||||
user,
|
||||
isCurrentUser = false,
|
||||
onFollow,
|
||||
onSettings,
|
||||
onEditProfile,
|
||||
onMessage,
|
||||
onMore,
|
||||
onPostsPress,
|
||||
onFollowingPress,
|
||||
onFollowersPress,
|
||||
onAvatarPress,
|
||||
}) => {
|
||||
// 响应式布局
|
||||
const { isWideScreen, isDesktop, width } = useResponsive();
|
||||
|
||||
// 格式化数字
|
||||
const formatCount = (count: number | undefined): string => {
|
||||
if (count === undefined || count === null) {
|
||||
return '0';
|
||||
}
|
||||
if (count >= 10000) {
|
||||
return `${(count / 10000).toFixed(1)}w`;
|
||||
}
|
||||
if (count >= 1000) {
|
||||
return `${(count / 1000).toFixed(1)}k`;
|
||||
}
|
||||
return count.toString();
|
||||
};
|
||||
|
||||
// 获取帖子数量
|
||||
const getPostsCount = (): number => {
|
||||
return user.posts_count ?? 0;
|
||||
};
|
||||
|
||||
// 获取粉丝数量
|
||||
const getFollowersCount = (): number => {
|
||||
return user.followers_count ?? 0;
|
||||
};
|
||||
|
||||
// 获取关注数量
|
||||
const getFollowingCount = (): number => {
|
||||
return user.following_count ?? 0;
|
||||
};
|
||||
|
||||
// 检查是否关注
|
||||
const getIsFollowing = (): boolean => {
|
||||
return user.is_following ?? false;
|
||||
};
|
||||
|
||||
// 检查对方是否关注了我
|
||||
const getIsFollowingMe = (): boolean => {
|
||||
return user.is_following_me ?? false;
|
||||
};
|
||||
|
||||
// 获取按钮配置(类似B站的互关逻辑)
|
||||
const getButtonConfig = (): { title: string; variant: 'primary' | 'outline'; icon?: string } => {
|
||||
const isFollowing = getIsFollowing();
|
||||
const isFollowingMe = getIsFollowingMe();
|
||||
|
||||
if (isFollowing && isFollowingMe) {
|
||||
// 已互关
|
||||
return { title: '互相关注', variant: 'outline', icon: 'account-check' };
|
||||
} else if (isFollowing) {
|
||||
// 已关注但对方未回关
|
||||
return { title: '已关注', variant: 'outline', icon: 'check' };
|
||||
} else if (isFollowingMe) {
|
||||
// 对方关注了我,但我没关注对方 - 显示回关
|
||||
return { title: '回关', variant: 'primary', icon: 'plus' };
|
||||
} else {
|
||||
// 互不关注
|
||||
return { title: '关注', variant: 'primary', icon: 'plus' };
|
||||
}
|
||||
};
|
||||
|
||||
// 根据屏幕尺寸计算封面高度
|
||||
const coverHeight = isDesktop ? 240 : isWideScreen ? 200 : (width * 9) / 16;
|
||||
|
||||
// 根据屏幕尺寸计算头像大小
|
||||
const avatarSize = isDesktop ? 120 : isWideScreen ? 100 : 90;
|
||||
|
||||
const renderStatItem = ({
|
||||
value,
|
||||
label,
|
||||
onPress,
|
||||
}: {
|
||||
value: string;
|
||||
label: string;
|
||||
onPress?: () => void;
|
||||
}) => {
|
||||
const content = (
|
||||
<View style={styles.statContent}>
|
||||
<Text variant="h3" style={styles.statNumber}>{value}</Text>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.statLabel}>
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (onPress) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.statItem, styles.statItemTouchable]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
{content}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return <View style={styles.statItem}>{content}</View>;
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 渐变封面背景 */}
|
||||
<View style={[styles.coverContainer, { height: coverHeight }]}>
|
||||
<View style={styles.coverTouchable}>
|
||||
{user.cover_url ? (
|
||||
<Image
|
||||
source={{ uri: user.cover_url }}
|
||||
style={styles.coverImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<LinearGradient
|
||||
colors={['#FF8F66', '#FF6B35', '#E5521D']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradient}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 设置按钮 */}
|
||||
{isCurrentUser && onSettings && (
|
||||
<TouchableOpacity style={styles.settingsButton} onPress={onSettings}>
|
||||
<MaterialCommunityIcons name="cog-outline" size={22} color={colors.text.inverse} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* 装饰性波浪 */}
|
||||
<View style={styles.waveDecoration}>
|
||||
<View style={styles.wave} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 用户信息卡片 */}
|
||||
<View style={[
|
||||
styles.profileCard,
|
||||
isWideScreen && styles.profileCardWide,
|
||||
]}>
|
||||
{/* 悬浮头像 */}
|
||||
<View style={[
|
||||
styles.avatarWrapper,
|
||||
isWideScreen && styles.avatarWrapperWide,
|
||||
]}>
|
||||
<View style={styles.avatarContainer}>
|
||||
<Avatar
|
||||
source={user.avatar}
|
||||
size={avatarSize}
|
||||
name={user.nickname}
|
||||
/>
|
||||
{isCurrentUser && onAvatarPress && (
|
||||
<TouchableOpacity style={styles.editAvatarButton} onPress={onAvatarPress}>
|
||||
<MaterialCommunityIcons name="camera" size={14} color={colors.text.inverse} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 用户名和简介 */}
|
||||
<View style={styles.userInfo}>
|
||||
<Text variant="h2" style={[
|
||||
styles.nickname,
|
||||
isWideScreen ? styles.nicknameWide : {},
|
||||
]}>
|
||||
{user.nickname}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.username}>
|
||||
@{user.username}
|
||||
</Text>
|
||||
|
||||
{user.bio ? (
|
||||
<Text variant="body" color={colors.text.secondary} style={[
|
||||
styles.bio,
|
||||
isWideScreen ? styles.bioWide : {},
|
||||
]}>
|
||||
{user.bio}
|
||||
</Text>
|
||||
) : (
|
||||
<Text variant="body" color={colors.text.hint} style={styles.bioPlaceholder}>
|
||||
这个人很懒,还没有写简介~
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 个人信息标签 */}
|
||||
<View style={styles.metaInfo}>
|
||||
{user.location && (
|
||||
<View style={styles.metaTag}>
|
||||
<MaterialCommunityIcons name="map-marker-outline" size={12} color={colors.primary.main} />
|
||||
<Text variant="caption" color={colors.primary.main} style={styles.metaTagText}>
|
||||
{user.location}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{user.website && (
|
||||
<View style={styles.metaTag}>
|
||||
<MaterialCommunityIcons name="link-variant" size={12} color={colors.info.main} />
|
||||
<Text variant="caption" color={colors.info.main} style={styles.metaTagText}>
|
||||
{user.website.replace(/^https?:\/\//, '')}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.metaTag}>
|
||||
<MaterialCommunityIcons name="calendar-outline" size={12} color={colors.text.secondary} />
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.metaTagText}>
|
||||
加入于 {new Date(user.created_at || Date.now()).getFullYear()}年
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 统计数据 - 卡片式 */}
|
||||
<View style={[
|
||||
styles.statsCard,
|
||||
isWideScreen && styles.statsCardWide,
|
||||
]}>
|
||||
{renderStatItem({
|
||||
value: formatCount(getPostsCount()),
|
||||
label: '帖子',
|
||||
onPress: onPostsPress,
|
||||
})}
|
||||
<View style={styles.statDivider} />
|
||||
{renderStatItem({
|
||||
value: formatCount(getFollowingCount()),
|
||||
label: '关注',
|
||||
onPress: onFollowingPress,
|
||||
})}
|
||||
<View style={styles.statDivider} />
|
||||
{renderStatItem({
|
||||
value: formatCount(getFollowersCount()),
|
||||
label: '粉丝',
|
||||
onPress: onFollowersPress,
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<View style={styles.actionButtons}>
|
||||
{isCurrentUser ? (
|
||||
<View style={styles.buttonRow} />
|
||||
) : (
|
||||
<View style={StyleSheet.flatten([
|
||||
styles.buttonRow,
|
||||
isWideScreen && styles.buttonRowWide,
|
||||
])}>
|
||||
<Button
|
||||
title={getButtonConfig().title}
|
||||
onPress={onFollow}
|
||||
variant={getButtonConfig().variant}
|
||||
style={StyleSheet.flatten([
|
||||
styles.followButton,
|
||||
isWideScreen && styles.followButtonWide,
|
||||
])}
|
||||
icon={getButtonConfig().icon}
|
||||
/>
|
||||
<TouchableOpacity style={styles.messageButton} onPress={onMessage}>
|
||||
<MaterialCommunityIcons name="message-text-outline" size={20} color={colors.primary.main} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.moreButton} onPress={onMore}>
|
||||
<MaterialCommunityIcons name="dots-horizontal" size={24} color={colors.text.secondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
coverContainer: {
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
coverTouchable: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
coverImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
gradient: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
settingsButton: {
|
||||
position: 'absolute',
|
||||
top: spacing.lg,
|
||||
right: spacing.lg,
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
waveDecoration: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 40,
|
||||
},
|
||||
wave: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: colors.background.default,
|
||||
borderTopLeftRadius: 30,
|
||||
borderTopRightRadius: 30,
|
||||
},
|
||||
profileCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
marginHorizontal: spacing.md,
|
||||
marginTop: -50,
|
||||
borderRadius: borderRadius.xl,
|
||||
padding: spacing.lg,
|
||||
...shadows.md,
|
||||
},
|
||||
profileCardWide: {
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: -60,
|
||||
padding: spacing.xl,
|
||||
},
|
||||
avatarWrapper: {
|
||||
alignItems: 'center',
|
||||
marginTop: -60,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
avatarWrapperWide: {
|
||||
marginTop: -80,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
avatarContainer: {
|
||||
position: 'relative',
|
||||
padding: 4,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: 50,
|
||||
},
|
||||
editAvatarButton: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: colors.primary.main,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background.paper,
|
||||
},
|
||||
userInfo: {
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
nickname: {
|
||||
marginBottom: spacing.xs,
|
||||
fontWeight: '700',
|
||||
},
|
||||
nicknameWide: {
|
||||
fontSize: fontSizes['3xl'],
|
||||
},
|
||||
username: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
bio: {
|
||||
textAlign: 'center',
|
||||
marginTop: spacing.sm,
|
||||
lineHeight: 20,
|
||||
},
|
||||
bioWide: {
|
||||
fontSize: fontSizes.md,
|
||||
lineHeight: 24,
|
||||
maxWidth: 600,
|
||||
},
|
||||
bioPlaceholder: {
|
||||
textAlign: 'center',
|
||||
marginTop: spacing.sm,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
metaInfo: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
metaTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
metaTagText: {
|
||||
marginLeft: spacing.xs,
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
statsCard: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-around',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'transparent',
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: spacing.xs,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
statsCardWide: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
statItem: {
|
||||
flex: 1,
|
||||
minHeight: 58,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
statItemTouchable: {
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
statContent: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.xs,
|
||||
},
|
||||
statNumber: {
|
||||
fontWeight: '600',
|
||||
marginBottom: 0,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: fontSizes.xs,
|
||||
marginTop: 2,
|
||||
},
|
||||
statDivider: {
|
||||
width: 1,
|
||||
height: 24,
|
||||
backgroundColor: colors.divider + '55',
|
||||
},
|
||||
actionButtons: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
buttonRowWide: {
|
||||
justifyContent: 'center',
|
||||
gap: spacing.md,
|
||||
},
|
||||
editButton: {
|
||||
flex: 1,
|
||||
},
|
||||
followButton: {
|
||||
flex: 1,
|
||||
},
|
||||
followButtonWide: {
|
||||
flex: 0,
|
||||
minWidth: 120,
|
||||
},
|
||||
messageButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
moreButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.background.default,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
settingsButtonOnly: {
|
||||
alignSelf: 'center',
|
||||
padding: spacing.sm,
|
||||
},
|
||||
});
|
||||
|
||||
// 使用 React.memo 避免不必要的重新渲染
|
||||
const MemoizedUserProfileHeader = React.memo(UserProfileHeader);
|
||||
|
||||
export default MemoizedUserProfileHeader;
|
||||
370
src/components/business/VoteCard.tsx
Normal file
370
src/components/business/VoteCard.tsx
Normal file
@@ -0,0 +1,370 @@
|
||||
/**
|
||||
* VoteCard 投票卡片组件
|
||||
* 显示投票选项列表,支持投票和取消投票
|
||||
* 风格与现代整体UI保持一致
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { VoteOptionDTO } from '../../types';
|
||||
import Text from '../common/Text';
|
||||
|
||||
interface VoteCardProps {
|
||||
postId?: string;
|
||||
options: VoteOptionDTO[];
|
||||
totalVotes: number;
|
||||
hasVoted: boolean;
|
||||
votedOptionId?: string;
|
||||
onVote: (optionId: string) => void;
|
||||
onUnvote: () => void;
|
||||
isLoading?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const VoteCard: React.FC<VoteCardProps> = ({
|
||||
options,
|
||||
totalVotes,
|
||||
hasVoted,
|
||||
votedOptionId,
|
||||
onVote,
|
||||
onUnvote,
|
||||
isLoading = false,
|
||||
compact = false,
|
||||
}) => {
|
||||
// 动画值
|
||||
const progressAnim = React.useRef(new Animated.Value(0)).current;
|
||||
|
||||
React.useEffect(() => {
|
||||
Animated.timing(progressAnim, {
|
||||
toValue: 1,
|
||||
duration: 400,
|
||||
useNativeDriver: false,
|
||||
}).start();
|
||||
}, [hasVoted, totalVotes]);
|
||||
|
||||
// 计算百分比
|
||||
const calculatePercentage = useCallback((votes: number): number => {
|
||||
if (totalVotes === 0) return 0;
|
||||
return Math.round((votes / totalVotes) * 100);
|
||||
}, [totalVotes]);
|
||||
|
||||
// 格式化票数
|
||||
const formatVoteCount = useCallback((count: number): string => {
|
||||
if (count >= 10000) {
|
||||
return (count / 10000).toFixed(1) + '万';
|
||||
}
|
||||
if (count >= 1000) {
|
||||
return (count / 1000).toFixed(1) + 'k';
|
||||
}
|
||||
return count.toString();
|
||||
}, []);
|
||||
|
||||
// 处理投票
|
||||
const handleVote = useCallback((optionId: string) => {
|
||||
if (isLoading || hasVoted) return;
|
||||
onVote(optionId);
|
||||
}, [isLoading, hasVoted, onVote]);
|
||||
|
||||
// 处理取消投票
|
||||
const handleUnvote = useCallback(() => {
|
||||
if (isLoading || !hasVoted) return;
|
||||
onUnvote();
|
||||
}, [isLoading, hasVoted, onUnvote]);
|
||||
|
||||
// 渲染投票选项
|
||||
const renderOption = useCallback((option: VoteOptionDTO, index: number) => {
|
||||
const isVotedOption = votedOptionId === option.id;
|
||||
const percentage = calculatePercentage(option.votes_count);
|
||||
const showResults = hasVoted;
|
||||
|
||||
return (
|
||||
<View key={option.id} style={styles.optionContainer}>
|
||||
{/* 进度条背景 */}
|
||||
{showResults && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.progressBar,
|
||||
{
|
||||
width: progressAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: ['0%', `${percentage}%`],
|
||||
}),
|
||||
backgroundColor: isVotedOption
|
||||
? colors.primary.light + '40'
|
||||
: colors.background.disabled,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 选项按钮 */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.optionButton,
|
||||
isVotedOption && styles.optionButtonVoted,
|
||||
]}
|
||||
onPress={() => handleVote(option.id)}
|
||||
disabled={isLoading || hasVoted}
|
||||
activeOpacity={hasVoted ? 1 : 0.8}
|
||||
>
|
||||
{/* 选择指示器 */}
|
||||
<View style={[
|
||||
styles.optionIndicator,
|
||||
isVotedOption && styles.optionIndicatorVoted,
|
||||
]}>
|
||||
{isVotedOption && (
|
||||
<MaterialCommunityIcons
|
||||
name="check"
|
||||
size={12}
|
||||
color={colors.primary.contrast}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 选项内容 */}
|
||||
<Text
|
||||
variant={compact ? 'caption' : 'body'}
|
||||
style={compact ? [styles.optionText, styles.optionTextCompact] : styles.optionText}
|
||||
numberOfLines={compact ? 1 : 2}
|
||||
>
|
||||
{option.content}
|
||||
</Text>
|
||||
|
||||
{/* 投票结果 */}
|
||||
{showResults && (
|
||||
<View style={styles.resultContainer}>
|
||||
<Text
|
||||
variant="caption"
|
||||
style={isVotedOption ? [styles.percentage, styles.percentageVoted] : styles.percentage}
|
||||
>
|
||||
{percentage}%
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}, [hasVoted, votedOptionId, calculatePercentage, handleVote, isLoading, progressAnim, compact]);
|
||||
|
||||
// 排序后的选项(已投票的排在前面)
|
||||
const sortedOptions = React.useMemo(() => {
|
||||
if (!hasVoted) return options;
|
||||
return [...options].sort((a, b) => {
|
||||
if (a.id === votedOptionId) return -1;
|
||||
if (b.id === votedOptionId) return 1;
|
||||
return b.votes_count - a.votes_count;
|
||||
});
|
||||
}, [options, hasVoted, votedOptionId]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, compact && styles.containerCompact]}>
|
||||
{/* 投票图标和标题 */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerIcon}>
|
||||
<MaterialCommunityIcons
|
||||
name="vote"
|
||||
size={compact ? 14 : 16}
|
||||
color={colors.primary.main}
|
||||
/>
|
||||
</View>
|
||||
<Text variant={compact ? 'caption' : 'body'} style={styles.headerTitle}>
|
||||
投票
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 投票选项列表 */}
|
||||
<View style={styles.optionsList}>
|
||||
{sortedOptions.map((option, index) => renderOption(option, index))}
|
||||
</View>
|
||||
|
||||
{/* 底部信息栏 */}
|
||||
<View style={styles.footer}>
|
||||
<View style={styles.footerLeft}>
|
||||
<MaterialCommunityIcons
|
||||
name="account-group-outline"
|
||||
size={14}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.footerText}>
|
||||
{formatVoteCount(totalVotes)} 人参与
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{hasVoted && (
|
||||
<TouchableOpacity
|
||||
style={styles.unvoteButton}
|
||||
onPress={handleUnvote}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="refresh"
|
||||
size={14}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.unvoteText}>
|
||||
重选
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 加载遮罩 */}
|
||||
{isLoading && (
|
||||
<View style={styles.loadingOverlay}>
|
||||
<MaterialCommunityIcons
|
||||
name="loading"
|
||||
size={24}
|
||||
color={colors.primary.main}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
marginVertical: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
containerCompact: {
|
||||
padding: spacing.sm,
|
||||
marginVertical: spacing.xs,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
headerIcon: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: borderRadius.sm,
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
headerTitle: {
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
optionsList: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
optionContainer: {
|
||||
position: 'relative',
|
||||
borderRadius: borderRadius.md,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
progressBar: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
bottom: 0,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
optionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.background.default,
|
||||
minHeight: 44,
|
||||
borderWidth: 1,
|
||||
borderColor: 'transparent',
|
||||
},
|
||||
optionButtonVoted: {
|
||||
borderColor: colors.primary.main,
|
||||
backgroundColor: 'transparent',
|
||||
},
|
||||
optionIndicator: {
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: borderRadius.full,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.divider,
|
||||
marginRight: spacing.sm,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
optionIndicatorVoted: {
|
||||
backgroundColor: colors.primary.main,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
optionText: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
lineHeight: 20,
|
||||
},
|
||||
optionTextCompact: {
|
||||
fontSize: fontSizes.sm,
|
||||
lineHeight: 18,
|
||||
},
|
||||
resultContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginLeft: spacing.sm,
|
||||
minWidth: 40,
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
percentage: {
|
||||
color: colors.text.secondary,
|
||||
fontWeight: '500',
|
||||
},
|
||||
percentageVoted: {
|
||||
color: colors.primary.main,
|
||||
fontWeight: '700',
|
||||
},
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.divider,
|
||||
},
|
||||
footerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
unvoteButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingVertical: spacing.xs,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: borderRadius.sm,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
unvoteText: {
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
loadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: colors.background.paper + 'CC',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: borderRadius.lg,
|
||||
},
|
||||
});
|
||||
|
||||
export default VoteCard;
|
||||
203
src/components/business/VoteEditor.tsx
Normal file
203
src/components/business/VoteEditor.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* VoteEditor 投票编辑器组件
|
||||
* 用于创建帖子时编辑投票选项
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import Text from '../common/Text';
|
||||
|
||||
interface VoteEditorProps {
|
||||
options: string[];
|
||||
onAddOption: () => void;
|
||||
onRemoveOption: (index: number) => void;
|
||||
onUpdateOption: (index: number, value: string) => void;
|
||||
maxOptions?: number;
|
||||
minOptions?: number;
|
||||
maxLength?: number;
|
||||
}
|
||||
|
||||
const VoteEditor: React.FC<VoteEditorProps> = ({
|
||||
options,
|
||||
onAddOption,
|
||||
onRemoveOption,
|
||||
onUpdateOption,
|
||||
maxOptions = 10,
|
||||
minOptions = 2,
|
||||
maxLength = 50,
|
||||
}) => {
|
||||
const validOptionsCount = options.filter(opt => opt.trim() !== '').length;
|
||||
const canAddOption = options.length < maxOptions;
|
||||
const canRemoveOption = options.length > minOptions;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 标题栏 */}
|
||||
<View style={styles.header}>
|
||||
<View style={styles.headerLeft}>
|
||||
<MaterialCommunityIcons
|
||||
name="vote"
|
||||
size={18}
|
||||
color={colors.primary.main}
|
||||
/>
|
||||
<Text variant="body" style={styles.headerTitle}>
|
||||
投票选项
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="caption" color={colors.text.hint}>
|
||||
{validOptionsCount}/{maxOptions} 个有效选项
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 选项列表 */}
|
||||
<View style={styles.optionsContainer}>
|
||||
{options.map((option, index) => (
|
||||
<View key={index} style={styles.optionRow}>
|
||||
<View style={styles.optionIndex}>
|
||||
<Text variant="caption" color={colors.text.hint}>
|
||||
{index + 1}
|
||||
</Text>
|
||||
</View>
|
||||
<TextInput
|
||||
style={styles.optionInput}
|
||||
value={option}
|
||||
onChangeText={(text) => onUpdateOption(index, text)}
|
||||
placeholder={`输入选项 ${index + 1}`}
|
||||
placeholderTextColor={colors.text.hint}
|
||||
maxLength={maxLength}
|
||||
returnKeyType="done"
|
||||
/>
|
||||
{canRemoveOption && (
|
||||
<TouchableOpacity
|
||||
style={styles.removeButton}
|
||||
onPress={() => onRemoveOption(index)}
|
||||
hitSlop={{ top: 8, right: 8, bottom: 8, left: 8 }}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="close-circle"
|
||||
size={20}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
{!canRemoveOption && options.length <= minOptions && (
|
||||
<View style={styles.removeButtonPlaceholder} />
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
|
||||
{/* 添加选项按钮 */}
|
||||
{canAddOption && (
|
||||
<TouchableOpacity
|
||||
style={styles.addOptionButton}
|
||||
onPress={onAddOption}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="plus-circle-outline"
|
||||
size={20}
|
||||
color={colors.primary.main}
|
||||
/>
|
||||
<Text variant="body" color={colors.primary.main} style={styles.addOptionText}>
|
||||
添加选项
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<View style={styles.hintContainer}>
|
||||
<Text variant="caption" color={colors.text.hint}>
|
||||
至少需要 {minOptions} 个非空选项才能发布投票
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.md,
|
||||
marginBottom: spacing.md,
|
||||
padding: spacing.md,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
headerLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
headerTitle: {
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
optionsContainer: {
|
||||
gap: spacing.sm,
|
||||
},
|
||||
optionRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
optionIndex: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.background.disabled,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
optionInput: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
height: 44,
|
||||
},
|
||||
removeButton: {
|
||||
padding: spacing.xs,
|
||||
},
|
||||
removeButtonPlaceholder: {
|
||||
width: 28,
|
||||
},
|
||||
addOptionButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: spacing.sm,
|
||||
paddingVertical: spacing.sm,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
addOptionText: {
|
||||
fontWeight: '500',
|
||||
},
|
||||
hintContainer: {
|
||||
marginTop: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.divider,
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default VoteEditor;
|
||||
109
src/components/business/VotePreview.tsx
Normal file
109
src/components/business/VotePreview.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* VotePreview 投票预览组件
|
||||
* 用于帖子列表中显示投票预览,类似微博风格
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import Text from '../common/Text';
|
||||
|
||||
interface VotePreviewProps {
|
||||
totalVotes?: number;
|
||||
optionsCount?: number;
|
||||
onPress?: () => void;
|
||||
}
|
||||
|
||||
const VotePreview: React.FC<VotePreviewProps> = ({
|
||||
totalVotes = 0,
|
||||
optionsCount = 0,
|
||||
onPress,
|
||||
}) => {
|
||||
// 格式化票数
|
||||
const formatVoteCount = (count: number): string => {
|
||||
if (count >= 10000) {
|
||||
return (count / 10000).toFixed(1) + '万';
|
||||
}
|
||||
if (count >= 1000) {
|
||||
return (count / 1000).toFixed(1) + 'k';
|
||||
}
|
||||
return count.toString();
|
||||
};
|
||||
|
||||
// 判断是否有真实数据
|
||||
const hasData = totalVotes > 0 || optionsCount > 0;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.container}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.iconContainer}>
|
||||
<MaterialCommunityIcons
|
||||
name="vote"
|
||||
size={18}
|
||||
color={colors.primary.main}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.title}>
|
||||
正在进行投票
|
||||
</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
{hasData
|
||||
? `${optionsCount} 个选项 · ${formatVoteCount(totalVotes)} 人参与`
|
||||
: '点击查看详情'
|
||||
}
|
||||
</Text>
|
||||
</View>
|
||||
<MaterialCommunityIcons
|
||||
name="chevron-right"
|
||||
size={20}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary.light + '08',
|
||||
borderRadius: borderRadius.md,
|
||||
padding: spacing.md,
|
||||
marginTop: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary.light + '30',
|
||||
},
|
||||
iconContainer: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
title: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
marginBottom: 2,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.secondary,
|
||||
},
|
||||
});
|
||||
|
||||
export default VotePreview;
|
||||
14
src/components/business/index.ts
Normal file
14
src/components/business/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 业务组件导出
|
||||
*/
|
||||
|
||||
export { default as PostCard } from './PostCard';
|
||||
export { default as CommentItem } from './CommentItem';
|
||||
export { default as UserProfileHeader } from './UserProfileHeader';
|
||||
export { default as NotificationItem } from './NotificationItem';
|
||||
export { default as SystemMessageItem } from './SystemMessageItem';
|
||||
export { default as SearchBar } from './SearchBar';
|
||||
export { default as TabBar } from './TabBar';
|
||||
export { default as VoteCard } from './VoteCard';
|
||||
export { default as VoteEditor } from './VoteEditor';
|
||||
export { default as VotePreview } from './VotePreview';
|
||||
402
src/components/common/AdaptiveLayout.tsx
Normal file
402
src/components/common/AdaptiveLayout.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* 自适应布局组件
|
||||
* 支持主内容区 + 侧边栏布局,根据屏幕宽度自动调整侧边栏显示/隐藏
|
||||
* 支持移动端抽屉式侧边栏
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
StyleSheet,
|
||||
Animated,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
Pressable,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import {
|
||||
useResponsive,
|
||||
useBreakpointGTE,
|
||||
FineBreakpointKey,
|
||||
} from '../../hooks/useResponsive';
|
||||
|
||||
export interface AdaptiveLayoutProps {
|
||||
/** 主内容区 */
|
||||
children: React.ReactNode;
|
||||
/** 侧边栏内容 */
|
||||
sidebar?: React.ReactNode;
|
||||
/** 自定义头部 */
|
||||
header?: React.ReactNode;
|
||||
/** 自定义底部 */
|
||||
footer?: React.ReactNode;
|
||||
/** 布局样式 */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** 主内容区样式 */
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
/** 侧边栏样式 */
|
||||
sidebarStyle?: StyleProp<ViewStyle>;
|
||||
/** 侧边栏宽度(桌面端) */
|
||||
sidebarWidth?: number;
|
||||
/** 移动端抽屉宽度 */
|
||||
drawerWidth?: number;
|
||||
/** 显示侧边栏的断点 */
|
||||
showSidebarBreakpoint?: FineBreakpointKey;
|
||||
/** 侧边栏位置 */
|
||||
sidebarPosition?: 'left' | 'right';
|
||||
/** 是否强制显示侧边栏(覆盖响应式逻辑) */
|
||||
forceShowSidebar?: boolean;
|
||||
/** 是否强制隐藏侧边栏(覆盖响应式逻辑) */
|
||||
forceHideSidebar?: boolean;
|
||||
/** 移动端抽屉是否打开(受控模式) */
|
||||
drawerOpen?: boolean;
|
||||
/** 移动端抽屉状态变化回调 */
|
||||
onDrawerOpenChange?: (open: boolean) => void;
|
||||
/** 渲染移动端抽屉触发按钮 */
|
||||
renderDrawerTrigger?: (props: { onPress: () => void; isOpen: boolean }) => React.ReactNode;
|
||||
/** 抽屉遮罩层颜色 */
|
||||
overlayColor?: string;
|
||||
/** 抽屉动画时长(毫秒) */
|
||||
animationDuration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自适应布局组件
|
||||
*
|
||||
* 支持主内容区 + 侧边栏布局,根据屏幕宽度自动调整:
|
||||
* - 桌面端:并排显示侧边栏
|
||||
* - 移动端:侧边栏变为抽屉式,通过按钮触发
|
||||
*
|
||||
* @example
|
||||
* // 基础用法
|
||||
* <AdaptiveLayout
|
||||
* sidebar={<SidebarContent />}
|
||||
* sidebarWidth={280}
|
||||
* >
|
||||
* <MainContent />
|
||||
* </AdaptiveLayout>
|
||||
*
|
||||
* @example
|
||||
* // 自定义断点和抽屉宽度
|
||||
* <AdaptiveLayout
|
||||
* sidebar={<SidebarContent />}
|
||||
* showSidebarBreakpoint="xl"
|
||||
* sidebarWidth={320}
|
||||
* drawerWidth={280}
|
||||
* sidebarPosition="right"
|
||||
* >
|
||||
* <MainContent />
|
||||
* </AdaptiveLayout>
|
||||
*
|
||||
* @example
|
||||
* // 受控模式
|
||||
* const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
*
|
||||
* <AdaptiveLayout
|
||||
* sidebar={<SidebarContent />}
|
||||
* drawerOpen={drawerOpen}
|
||||
* onDrawerOpenChange={setDrawerOpen}
|
||||
* renderDrawerTrigger={({ onPress, isOpen }) => (
|
||||
* <Button onPress={onPress}>
|
||||
* {isOpen ? '关闭' : '菜单'}
|
||||
* </Button>
|
||||
* )}
|
||||
* >
|
||||
* <MainContent />
|
||||
* </AdaptiveLayout>
|
||||
*/
|
||||
export function AdaptiveLayout({
|
||||
children,
|
||||
sidebar,
|
||||
header,
|
||||
footer,
|
||||
style,
|
||||
contentStyle,
|
||||
sidebarStyle,
|
||||
sidebarWidth = 280,
|
||||
drawerWidth = 280,
|
||||
showSidebarBreakpoint = 'lg',
|
||||
sidebarPosition = 'left',
|
||||
forceShowSidebar,
|
||||
forceHideSidebar,
|
||||
drawerOpen: controlledDrawerOpen,
|
||||
onDrawerOpenChange,
|
||||
renderDrawerTrigger,
|
||||
overlayColor = 'rgba(0, 0, 0, 0.5)',
|
||||
animationDuration = 300,
|
||||
}: AdaptiveLayoutProps) {
|
||||
const { width, isMobile } = useResponsive();
|
||||
const shouldShowSidebar = useBreakpointGTE(showSidebarBreakpoint);
|
||||
|
||||
// 内部抽屉状态(非受控模式)
|
||||
const [internalDrawerOpen, setInternalDrawerOpen] = useState(false);
|
||||
|
||||
// 动画值
|
||||
const [slideAnim] = useState(new Animated.Value(0));
|
||||
const [fadeAnim] = useState(new Animated.Value(0));
|
||||
|
||||
// 确定最终抽屉状态
|
||||
const isDrawerOpen = controlledDrawerOpen ?? internalDrawerOpen;
|
||||
const setDrawerOpen = useCallback((open: boolean) => {
|
||||
if (onDrawerOpenChange) {
|
||||
onDrawerOpenChange(open);
|
||||
} else {
|
||||
setInternalDrawerOpen(open);
|
||||
}
|
||||
}, [onDrawerOpenChange]);
|
||||
|
||||
// 切换抽屉状态
|
||||
const toggleDrawer = useCallback(() => {
|
||||
setDrawerOpen(!isDrawerOpen);
|
||||
}, [isDrawerOpen, setDrawerOpen]);
|
||||
|
||||
// 关闭抽屉
|
||||
const closeDrawer = useCallback(() => {
|
||||
setDrawerOpen(false);
|
||||
}, [setDrawerOpen]);
|
||||
|
||||
// 抽屉动画
|
||||
useEffect(() => {
|
||||
if (isDrawerOpen) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 1,
|
||||
duration: animationDuration,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: animationDuration,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: animationDuration,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: animationDuration,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [isDrawerOpen, slideAnim, fadeAnim, animationDuration]);
|
||||
|
||||
// 计算侧边栏是否应该显示
|
||||
const isSidebarVisible = forceShowSidebar ?? (shouldShowSidebar && !forceHideSidebar);
|
||||
const isDrawerMode = !isSidebarVisible && !!sidebar;
|
||||
|
||||
// 抽屉滑动动画
|
||||
const drawerTranslateX = slideAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [
|
||||
sidebarPosition === 'left' ? -drawerWidth : drawerWidth,
|
||||
0,
|
||||
],
|
||||
});
|
||||
|
||||
// 渲染桌面端侧边栏
|
||||
const renderDesktopSidebar = () => {
|
||||
if (!sidebar || !isSidebarVisible) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.sidebar,
|
||||
{
|
||||
width: sidebarWidth,
|
||||
[sidebarPosition === 'left' ? 'marginRight' : 'marginLeft']: 16,
|
||||
},
|
||||
sidebarStyle,
|
||||
]}
|
||||
>
|
||||
{sidebar}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染移动端抽屉
|
||||
const renderMobileDrawer = () => {
|
||||
if (!sidebar || !isDrawerMode) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={isDrawerOpen}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={closeDrawer}
|
||||
>
|
||||
<View style={styles.modalContainer}>
|
||||
{/* 遮罩层 */}
|
||||
<TouchableOpacity
|
||||
style={styles.overlayTouchable}
|
||||
activeOpacity={1}
|
||||
onPress={closeDrawer}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.overlay,
|
||||
{ backgroundColor: overlayColor, opacity: fadeAnim },
|
||||
]}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 抽屉内容 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.drawer,
|
||||
{
|
||||
width: drawerWidth,
|
||||
[sidebarPosition]: 0,
|
||||
transform: [{ translateX: drawerTranslateX }],
|
||||
},
|
||||
sidebarStyle,
|
||||
]}
|
||||
>
|
||||
{sidebar}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染抽屉触发按钮
|
||||
const renderTrigger = () => {
|
||||
if (!isDrawerMode || !renderDrawerTrigger) return null;
|
||||
|
||||
return renderDrawerTrigger({
|
||||
onPress: toggleDrawer,
|
||||
isOpen: isDrawerOpen,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
{/* 头部 */}
|
||||
{header && (
|
||||
<View style={styles.header}>
|
||||
{header}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 主布局区域 */}
|
||||
<View style={styles.main}>
|
||||
{/* 左侧布局 */}
|
||||
{sidebarPosition === 'left' && renderDesktopSidebar()}
|
||||
|
||||
{/* 主内容区 */}
|
||||
<View style={[styles.content, contentStyle]}>
|
||||
{/* 抽屉触发按钮(仅在移动端抽屉模式显示) */}
|
||||
{renderTrigger()}
|
||||
{children}
|
||||
</View>
|
||||
|
||||
{/* 右侧布局 */}
|
||||
{sidebarPosition === 'right' && renderDesktopSidebar()}
|
||||
</View>
|
||||
|
||||
{/* 移动端抽屉 */}
|
||||
{renderMobileDrawer()}
|
||||
|
||||
{/* 底部 */}
|
||||
{footer && (
|
||||
<View style={styles.footer}>
|
||||
{footer}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
},
|
||||
header: {
|
||||
width: '100%',
|
||||
},
|
||||
main: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
width: '100%',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
minWidth: 0, // 防止 flex item 溢出
|
||||
},
|
||||
sidebar: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
footer: {
|
||||
width: '100%',
|
||||
},
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
overlayTouchable: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
overlay: {
|
||||
flex: 1,
|
||||
},
|
||||
drawer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: '#fff',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 8,
|
||||
elevation: 16,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 简化的侧边栏布局组件
|
||||
* 仅包含主内容和侧边栏,无头部底部
|
||||
*/
|
||||
export interface SidebarLayoutProps {
|
||||
children: React.ReactNode;
|
||||
sidebar: React.ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
sidebarStyle?: StyleProp<ViewStyle>;
|
||||
sidebarWidth?: number;
|
||||
showSidebarBreakpoint?: FineBreakpointKey;
|
||||
sidebarPosition?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export function SidebarLayout({
|
||||
children,
|
||||
sidebar,
|
||||
style,
|
||||
contentStyle,
|
||||
sidebarStyle,
|
||||
sidebarWidth = 280,
|
||||
showSidebarBreakpoint = 'lg',
|
||||
sidebarPosition = 'left',
|
||||
}: SidebarLayoutProps) {
|
||||
return (
|
||||
<AdaptiveLayout
|
||||
sidebar={sidebar}
|
||||
style={style}
|
||||
contentStyle={contentStyle}
|
||||
sidebarStyle={sidebarStyle}
|
||||
sidebarWidth={sidebarWidth}
|
||||
showSidebarBreakpoint={showSidebarBreakpoint}
|
||||
sidebarPosition={sidebarPosition}
|
||||
>
|
||||
{children}
|
||||
</AdaptiveLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdaptiveLayout;
|
||||
188
src/components/common/AppDialogHost.tsx
Normal file
188
src/components/common/AppDialogHost.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
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, colors, shadows, spacing } from '../../theme';
|
||||
|
||||
const AppDialogHost: React.FC = () => {
|
||||
const [dialog, setDialog] = useState<DialogPayload | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unbind = bindDialogListener((payload) => {
|
||||
setDialog(payload);
|
||||
});
|
||||
return unbind;
|
||||
}, []);
|
||||
|
||||
const actions = useMemo<AlertButton[]>(() => {
|
||||
if (!dialog?.actions?.length) return [{ text: '确定' }];
|
||||
return dialog.actions.slice(0, 3);
|
||||
}, [dialog]);
|
||||
|
||||
const onClose = () => {
|
||||
const cancelAction = actions.find((action) => action.style === 'cancel');
|
||||
if (cancelAction?.onPress) {
|
||||
cancelAction.onPress();
|
||||
}
|
||||
setDialog(null);
|
||||
};
|
||||
|
||||
const onActionPress = (action: AlertButton) => {
|
||||
action.onPress?.();
|
||||
setDialog(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={!!dialog}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<Pressable
|
||||
style={styles.backdrop}
|
||||
onPress={() => {
|
||||
if (dialog?.options?.cancelable ?? true) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Pressable style={styles.container}>
|
||||
<View style={styles.iconHeader}>
|
||||
<LinearGradient
|
||||
colors={['#FF6B35', '#FF8F66']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.iconBadge}
|
||||
>
|
||||
<MaterialCommunityIcons name="carrot" size={20} color="#FFFFFF" />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
<Text style={styles.title}>{dialog?.title || '提示'}</Text>
|
||||
{!!dialog?.message && <Text style={styles.message}>{dialog.message}</Text>}
|
||||
|
||||
<View style={styles.actions}>
|
||||
{actions.map((action, index) => {
|
||||
const isDestructive = action.style === 'destructive';
|
||||
const isCancel = action.style === 'cancel';
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={`${action.text || 'action'}-${index}`}
|
||||
style={[
|
||||
styles.actionButton,
|
||||
isCancel && styles.cancelButton,
|
||||
!isCancel && !isDestructive && styles.primaryButton,
|
||||
isDestructive && styles.destructiveButton,
|
||||
]}
|
||||
onPress={() => onActionPress(action)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.actionText,
|
||||
!isCancel && !isDestructive && styles.primaryText,
|
||||
isCancel && styles.cancelText,
|
||||
isDestructive && styles.destructiveText,
|
||||
]}
|
||||
>
|
||||
{action.text || '确定'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.36)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.xl,
|
||||
},
|
||||
container: {
|
||||
width: '100%',
|
||||
maxWidth: 380,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius['2xl'],
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingTop: spacing.lg,
|
||||
paddingBottom: spacing.lg,
|
||||
...shadows.lg,
|
||||
},
|
||||
iconHeader: {
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
iconBadge: {
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: borderRadius.full,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
color: colors.text.primary,
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
},
|
||||
message: {
|
||||
marginTop: spacing.md,
|
||||
color: colors.text.secondary,
|
||||
fontSize: 14,
|
||||
lineHeight: 21,
|
||||
textAlign: 'center',
|
||||
},
|
||||
actions: {
|
||||
marginTop: spacing.xl,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
actionButton: {
|
||||
height: 46,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: `${colors.primary.main}28`,
|
||||
backgroundColor: '#FFFFFF',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: colors.primary.main,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
destructiveButton: {
|
||||
backgroundColor: '#FEECEC',
|
||||
borderColor: '#FCD4D1',
|
||||
},
|
||||
actionText: {
|
||||
color: colors.text.primary,
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
primaryText: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
cancelText: {
|
||||
color: colors.text.secondary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
destructiveText: {
|
||||
color: colors.error.main,
|
||||
},
|
||||
});
|
||||
|
||||
export default AppDialogHost;
|
||||
185
src/components/common/AppPromptBar.tsx
Normal file
185
src/components/common/AppPromptBar.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { bindPromptListener, PromptPayload, PromptType } from '../../services/promptService';
|
||||
import { borderRadius, colors, shadows, spacing } from '../../theme';
|
||||
|
||||
interface PromptState extends PromptPayload {
|
||||
id: number;
|
||||
}
|
||||
|
||||
const DEFAULT_DURATION = 2200;
|
||||
|
||||
const styleMap: Record<PromptType, { backgroundColor: string; icon: React.ComponentProps<typeof MaterialCommunityIcons>['name'] }> = {
|
||||
info: { backgroundColor: '#FFFFFF', icon: 'information-outline' },
|
||||
success: { backgroundColor: '#FFFFFF', icon: 'check-circle-outline' },
|
||||
warning: { backgroundColor: '#FFFFFF', icon: 'alert-outline' },
|
||||
error: { backgroundColor: '#FFFFFF', icon: 'alert-circle-outline' },
|
||||
};
|
||||
|
||||
const iconColorMap: Record<PromptType, string> = {
|
||||
info: colors.primary.main,
|
||||
success: colors.success.main,
|
||||
warning: colors.warning.dark,
|
||||
error: colors.error.main,
|
||||
};
|
||||
|
||||
const accentColorMap: Record<PromptType, string> = {
|
||||
info: colors.primary.main,
|
||||
success: colors.success.main,
|
||||
warning: colors.warning.main,
|
||||
error: colors.error.main,
|
||||
};
|
||||
|
||||
const AppPromptBar: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [prompt, setPrompt] = useState<PromptState | null>(null);
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const animation = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const hidePrompt = useCallback(() => {
|
||||
Animated.timing(animation, {
|
||||
toValue: 0,
|
||||
duration: 180,
|
||||
useNativeDriver: true,
|
||||
}).start(({ finished }) => {
|
||||
if (finished) {
|
||||
setPrompt(null);
|
||||
}
|
||||
});
|
||||
}, [animation]);
|
||||
|
||||
useEffect(() => {
|
||||
const unbind = bindPromptListener((payload) => {
|
||||
if (hideTimerRef.current) {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
}
|
||||
|
||||
setPrompt({
|
||||
...payload,
|
||||
id: Date.now(),
|
||||
type: payload.type ?? 'info',
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (hideTimerRef.current) {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
}
|
||||
unbind();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!prompt) return;
|
||||
|
||||
animation.setValue(0);
|
||||
Animated.timing(animation, {
|
||||
toValue: 1,
|
||||
duration: 220,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
hideTimerRef.current = setTimeout(() => {
|
||||
hidePrompt();
|
||||
}, prompt.duration ?? DEFAULT_DURATION);
|
||||
}, [animation, hidePrompt, prompt]);
|
||||
|
||||
if (!prompt) return null;
|
||||
|
||||
const promptType = prompt.type ?? 'info';
|
||||
const promptStyle = styleMap[promptType];
|
||||
const iconColor = iconColorMap[promptType];
|
||||
const accentColor = accentColorMap[promptType];
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents="box-none"
|
||||
style={[
|
||||
styles.wrapper,
|
||||
{
|
||||
top: insets.top + spacing.sm,
|
||||
opacity: animation,
|
||||
transform: [
|
||||
{
|
||||
translateY: animation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [-20, 0],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.95}
|
||||
onPress={hidePrompt}
|
||||
style={[styles.card, { backgroundColor: promptStyle.backgroundColor }]}
|
||||
>
|
||||
<View style={[styles.accentBar, { backgroundColor: accentColor }]} />
|
||||
<View style={styles.iconWrap}>
|
||||
<MaterialCommunityIcons name={promptStyle.icon} size={20} color={iconColor} />
|
||||
</View>
|
||||
<View style={styles.textWrap}>
|
||||
{!!prompt.title && <Text style={styles.title}>{prompt.title}</Text>}
|
||||
<Text style={styles.message}>{prompt.message}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
position: 'absolute',
|
||||
left: spacing.md,
|
||||
right: spacing.md,
|
||||
zIndex: 9999,
|
||||
},
|
||||
card: {
|
||||
borderRadius: borderRadius.xl,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: `${colors.primary.main}22`,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
...shadows.lg,
|
||||
},
|
||||
accentBar: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 4,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.primary.main}12`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
textWrap: {
|
||||
flex: 1,
|
||||
paddingRight: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
color: colors.text.primary,
|
||||
fontWeight: '700',
|
||||
fontSize: 14,
|
||||
marginBottom: 2,
|
||||
},
|
||||
message: {
|
||||
color: colors.text.primary,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
|
||||
export default AppPromptBar;
|
||||
146
src/components/common/Avatar.tsx
Normal file
146
src/components/common/Avatar.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Avatar 头像组件
|
||||
* 支持图片URL、本地图片、首字母显示、在线状态徽章
|
||||
* 使用 expo-image 实现内存+磁盘双级缓存,头像秒加载
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ViewStyle,
|
||||
} from 'react-native';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { colors, borderRadius } from '../../theme';
|
||||
import Text from './Text';
|
||||
|
||||
type AvatarSource = string | { uri: string } | number | null;
|
||||
|
||||
interface AvatarProps {
|
||||
source?: AvatarSource;
|
||||
size?: number; // 默认40
|
||||
name?: string; // 用于显示首字母
|
||||
onPress?: () => void;
|
||||
showBadge?: boolean;
|
||||
badgeColor?: string;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
const Avatar: React.FC<AvatarProps> = ({
|
||||
source,
|
||||
size = 40,
|
||||
name,
|
||||
onPress,
|
||||
showBadge = false,
|
||||
badgeColor = colors.success.main,
|
||||
style,
|
||||
}) => {
|
||||
// 获取首字母
|
||||
const getInitial = (): string => {
|
||||
if (!name) return '?';
|
||||
const firstChar = name.charAt(0).toUpperCase();
|
||||
// 中文字符
|
||||
if (/[\u4e00-\u9fa5]/.test(firstChar)) {
|
||||
return firstChar;
|
||||
}
|
||||
return firstChar;
|
||||
};
|
||||
|
||||
// 渲染头像内容
|
||||
const renderAvatarContent = () => {
|
||||
// 如果有图片源
|
||||
if (source) {
|
||||
const imageSource =
|
||||
typeof source === 'string' ? { uri: source } : source;
|
||||
|
||||
return (
|
||||
<ExpoImage
|
||||
source={imageSource}
|
||||
style={[
|
||||
styles.image,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
},
|
||||
]}
|
||||
contentFit="cover"
|
||||
cachePolicy="memory"
|
||||
transition={150}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 显示首字母
|
||||
const fontSize = size / 2;
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.placeholder,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text color={colors.text.inverse} style={{ fontSize, lineHeight: fontSize * 1.2 }}>
|
||||
{getInitial()}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const avatarContainer = (
|
||||
<View style={[styles.container, style]}>
|
||||
{renderAvatarContent()}
|
||||
{showBadge && (
|
||||
<View
|
||||
style={[
|
||||
styles.badge,
|
||||
{
|
||||
backgroundColor: badgeColor,
|
||||
width: size * 0.3,
|
||||
height: size * 0.3,
|
||||
borderRadius: (size * 0.3) / 2,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (onPress) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
||||
{avatarContainer}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return avatarContainer;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
},
|
||||
image: {
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
placeholder: {
|
||||
backgroundColor: colors.primary.main,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background.paper,
|
||||
},
|
||||
});
|
||||
|
||||
export default Avatar;
|
||||
260
src/components/common/Button.tsx
Normal file
260
src/components/common/Button.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Button 按钮组件
|
||||
* 支持多种变体、尺寸、加载状态和图标
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
View,
|
||||
ViewStyle,
|
||||
TextStyle,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, borderRadius, spacing, fontSizes, shadows } from '../../theme';
|
||||
import Text from './Text';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'text' | 'danger';
|
||||
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
type IconPosition = 'left' | 'right';
|
||||
|
||||
interface ButtonProps {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
icon?: string; // MaterialCommunityIcons name
|
||||
iconPosition?: IconPosition;
|
||||
fullWidth?: boolean;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
title,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
icon,
|
||||
iconPosition = 'left',
|
||||
fullWidth = false,
|
||||
style,
|
||||
}) => {
|
||||
// 获取按钮样式
|
||||
const getButtonStyle = (): ViewStyle[] => {
|
||||
const baseStyle: ViewStyle[] = [styles.base, styles[`size_${size}`]];
|
||||
|
||||
// 变体样式
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
baseStyle.push(styles.primary);
|
||||
break;
|
||||
case 'secondary':
|
||||
baseStyle.push(styles.secondary);
|
||||
break;
|
||||
case 'outline':
|
||||
baseStyle.push(styles.outline);
|
||||
break;
|
||||
case 'text':
|
||||
baseStyle.push(styles.text);
|
||||
break;
|
||||
case 'danger':
|
||||
baseStyle.push(styles.danger);
|
||||
break;
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
if (disabled || loading) {
|
||||
baseStyle.push(styles.disabled);
|
||||
}
|
||||
|
||||
// 全宽度
|
||||
if (fullWidth) {
|
||||
baseStyle.push(styles.fullWidth);
|
||||
}
|
||||
|
||||
return baseStyle;
|
||||
};
|
||||
|
||||
// 获取文本样式
|
||||
const getTextStyle = (): TextStyle => {
|
||||
const baseStyle: TextStyle = {
|
||||
...styles.textBase,
|
||||
...styles[`textSize_${size}`],
|
||||
};
|
||||
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
case 'danger':
|
||||
baseStyle.color = colors.text.inverse;
|
||||
break;
|
||||
case 'secondary':
|
||||
baseStyle.color = colors.text.inverse;
|
||||
break;
|
||||
case 'outline':
|
||||
case 'text':
|
||||
baseStyle.color = colors.primary.main;
|
||||
break;
|
||||
}
|
||||
|
||||
if (disabled || loading) {
|
||||
baseStyle.color = colors.text.disabled;
|
||||
}
|
||||
|
||||
return baseStyle;
|
||||
};
|
||||
|
||||
// 获取图标大小
|
||||
const getIconSize = (): number => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return 16;
|
||||
case 'md':
|
||||
return 20;
|
||||
case 'lg':
|
||||
return 24;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取图标颜色
|
||||
const getIconColor = (): string => {
|
||||
if (disabled || loading) {
|
||||
return colors.text.disabled;
|
||||
}
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
case 'danger':
|
||||
return colors.text.inverse;
|
||||
case 'secondary':
|
||||
return colors.text.inverse;
|
||||
case 'outline':
|
||||
case 'text':
|
||||
return colors.primary.main;
|
||||
}
|
||||
};
|
||||
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[getButtonStyle(), style]}
|
||||
onPress={onPress}
|
||||
disabled={isDisabled}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={variant === 'outline' || variant === 'text'
|
||||
? colors.primary.main
|
||||
: colors.text.inverse}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.content}>
|
||||
{icon && iconPosition === 'left' && (
|
||||
<MaterialCommunityIcons
|
||||
name={icon as any}
|
||||
size={getIconSize()}
|
||||
color={getIconColor()}
|
||||
style={styles.iconLeft}
|
||||
/>
|
||||
)}
|
||||
<Text style={getTextStyle()}>{title}</Text>
|
||||
{icon && iconPosition === 'right' && (
|
||||
<MaterialCommunityIcons
|
||||
name={icon as any}
|
||||
size={getIconSize()}
|
||||
color={getIconColor()}
|
||||
style={styles.iconRight}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
content: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
// 尺寸样式
|
||||
size_sm: {
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
minHeight: 32,
|
||||
},
|
||||
size_md: {
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
minHeight: 40,
|
||||
},
|
||||
size_lg: {
|
||||
paddingVertical: spacing.lg,
|
||||
paddingHorizontal: spacing.xl,
|
||||
minHeight: 48,
|
||||
},
|
||||
// 变体样式
|
||||
primary: {
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: colors.secondary.main,
|
||||
},
|
||||
outline: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
text: {
|
||||
backgroundColor: 'transparent',
|
||||
shadowColor: 'transparent',
|
||||
elevation: 0,
|
||||
},
|
||||
danger: {
|
||||
backgroundColor: colors.error.main,
|
||||
},
|
||||
// 禁用状态
|
||||
disabled: {
|
||||
backgroundColor: colors.background.disabled,
|
||||
borderColor: colors.background.disabled,
|
||||
},
|
||||
// 全宽度
|
||||
fullWidth: {
|
||||
width: '100%',
|
||||
},
|
||||
// 文本样式
|
||||
textBase: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
textSize_sm: {
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
textSize_md: {
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
textSize_lg: {
|
||||
fontSize: fontSizes.lg,
|
||||
},
|
||||
// 图标样式
|
||||
iconLeft: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
iconRight: {
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
});
|
||||
|
||||
export default Button;
|
||||
58
src/components/common/Card.tsx
Normal file
58
src/components/common/Card.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Card 卡片组件
|
||||
* 白色背景、圆角、阴影,支持点击
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { colors, borderRadius, spacing, shadows } from '../../theme';
|
||||
|
||||
type ShadowSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
padding?: number;
|
||||
shadow?: ShadowSize;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
const Card: React.FC<CardProps> = ({
|
||||
children,
|
||||
onPress,
|
||||
padding = spacing.lg,
|
||||
shadow = 'none',
|
||||
style,
|
||||
}) => {
|
||||
const shadowStyle = shadow !== 'none' ? shadows[shadow as keyof typeof shadows] : undefined;
|
||||
|
||||
const cardStyle = [
|
||||
styles.card,
|
||||
{ padding },
|
||||
shadowStyle,
|
||||
].filter(Boolean);
|
||||
|
||||
if (onPress) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[...cardStyle, style]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.95}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return <View style={[...cardStyle, style]}>{children}</View>;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
|
||||
export default Card;
|
||||
39
src/components/common/Divider.tsx
Normal file
39
src/components/common/Divider.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Divider 分割线组件
|
||||
* 用于分隔内容
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { colors, spacing } from '../../theme';
|
||||
|
||||
interface DividerProps {
|
||||
margin?: number;
|
||||
color?: string;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
const Divider: React.FC<DividerProps> = ({
|
||||
margin = spacing.lg,
|
||||
color = colors.divider,
|
||||
style,
|
||||
}) => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.divider,
|
||||
{ marginVertical: margin, backgroundColor: color },
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
divider: {
|
||||
height: 1,
|
||||
width: '100%',
|
||||
},
|
||||
});
|
||||
|
||||
export default Divider;
|
||||
242
src/components/common/EmptyState.tsx
Normal file
242
src/components/common/EmptyState.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* EmptyState 空状态组件 - 美化版
|
||||
* 显示空数据时的占位界面,采用现代插图风格设计
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, ViewStyle, Dimensions } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import Text from './Text';
|
||||
import Button from './Button';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
style?: ViewStyle;
|
||||
variant?: 'default' | 'modern' | 'compact';
|
||||
}
|
||||
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
icon = 'folder-open-outline',
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
style,
|
||||
variant = 'modern',
|
||||
}) => {
|
||||
// 现代风格空状态
|
||||
if (variant === 'modern') {
|
||||
return (
|
||||
<View style={[styles.modernContainer, style]}>
|
||||
<View style={styles.illustrationContainer}>
|
||||
<View style={styles.iconBackground}>
|
||||
<MaterialCommunityIcons
|
||||
name={icon as any}
|
||||
size={48}
|
||||
color={colors.primary.main}
|
||||
style={styles.modernIcon}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.decorativeCircle1} />
|
||||
<View style={styles.decorativeCircle2} />
|
||||
</View>
|
||||
<Text
|
||||
variant="h3"
|
||||
color={colors.text.primary}
|
||||
style={styles.modernTitle}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text
|
||||
variant="body"
|
||||
color={colors.text.secondary}
|
||||
style={styles.modernDescription}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{actionLabel && onAction && (
|
||||
<Button
|
||||
title={actionLabel}
|
||||
onPress={onAction}
|
||||
variant="primary"
|
||||
size="md"
|
||||
style={styles.modernButton}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 紧凑风格
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<View style={[styles.compactContainer, style]}>
|
||||
<MaterialCommunityIcons
|
||||
name={icon as any}
|
||||
size={40}
|
||||
color={colors.text.disabled}
|
||||
style={styles.compactIcon}
|
||||
/>
|
||||
<Text
|
||||
variant="body"
|
||||
color={colors.text.secondary}
|
||||
style={styles.compactTitle}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 默认风格
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<MaterialCommunityIcons
|
||||
name={icon as any}
|
||||
size={64}
|
||||
color={colors.text.disabled}
|
||||
style={styles.icon}
|
||||
/>
|
||||
<Text
|
||||
variant="h3"
|
||||
color={colors.text.secondary}
|
||||
style={styles.title}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text
|
||||
variant="body"
|
||||
color={colors.text.secondary}
|
||||
style={styles.description}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{actionLabel && onAction && (
|
||||
<Button
|
||||
title={actionLabel}
|
||||
onPress={onAction}
|
||||
variant="outline"
|
||||
size="md"
|
||||
style={styles.button}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// 默认风格
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
},
|
||||
icon: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
description: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
button: {
|
||||
minWidth: 120,
|
||||
},
|
||||
|
||||
// 现代风格
|
||||
modernContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
minHeight: 280,
|
||||
},
|
||||
illustrationContainer: {
|
||||
position: 'relative',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
width: 120,
|
||||
height: 120,
|
||||
},
|
||||
iconBackground: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: borderRadius['2xl'],
|
||||
backgroundColor: colors.primary.main + '15',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: colors.primary.main,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
modernIcon: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
decorativeCircle1: {
|
||||
position: 'absolute',
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.primary.light + '30',
|
||||
top: 5,
|
||||
right: 10,
|
||||
},
|
||||
decorativeCircle2: {
|
||||
position: 'absolute',
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.primary.main + '20',
|
||||
bottom: 15,
|
||||
left: 5,
|
||||
},
|
||||
modernTitle: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes.xl,
|
||||
},
|
||||
modernDescription: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
fontSize: fontSizes.md,
|
||||
lineHeight: 22,
|
||||
maxWidth: 280,
|
||||
},
|
||||
modernButton: {
|
||||
minWidth: 140,
|
||||
borderRadius: borderRadius.lg,
|
||||
},
|
||||
|
||||
// 紧凑风格
|
||||
compactContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.lg,
|
||||
},
|
||||
compactIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
compactTitle: {
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
});
|
||||
|
||||
export default EmptyState;
|
||||
609
src/components/common/ImageGallery.tsx
Normal file
609
src/components/common/ImageGallery.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* ImageGallery 图片画廊组件
|
||||
* 支持手势滑动切换图片、双指放大、点击关闭
|
||||
* 使用 expo-image,原生支持 GIF/WebP 动图
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
StatusBar,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import {
|
||||
Gesture,
|
||||
GestureDetector,
|
||||
GestureHandlerRootView,
|
||||
} from 'react-native-gesture-handler';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
useDerivedValue,
|
||||
runOnJS,
|
||||
withTiming,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
import { colors, spacing, borderRadius, fontSizes } from '../../theme';
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
// 图片项类型
|
||||
export interface GalleryImageItem {
|
||||
id: string;
|
||||
url: string;
|
||||
thumbnailUrl?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// 组件 Props
|
||||
export interface ImageGalleryProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
/** 图片列表 */
|
||||
images: GalleryImageItem[];
|
||||
/** 初始索引 */
|
||||
initialIndex: number;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
/** 索引变化回调 */
|
||||
onIndexChange?: (index: number) => void;
|
||||
/** 是否显示指示器 */
|
||||
showIndicator?: boolean;
|
||||
/** 是否允许保存图片 */
|
||||
enableSave?: boolean;
|
||||
/** 保存图片成功回调 */
|
||||
onSave?: (url: string) => void;
|
||||
/** 背景透明度 */
|
||||
backgroundOpacity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片画廊主组件
|
||||
*/
|
||||
export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
visible,
|
||||
images,
|
||||
initialIndex,
|
||||
onClose,
|
||||
onIndexChange,
|
||||
showIndicator = true,
|
||||
enableSave = false,
|
||||
onSave,
|
||||
backgroundOpacity = 1,
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveToast, setSaveToast] = useState<'success' | 'error' | null>(null);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// 缩放相关状态
|
||||
const scale = useSharedValue(1);
|
||||
const savedScale = useSharedValue(1);
|
||||
const translateX = useSharedValue(0);
|
||||
const translateY = useSharedValue(0);
|
||||
const savedTranslateX = useSharedValue(0);
|
||||
const savedTranslateY = useSharedValue(0);
|
||||
|
||||
const validImages = useMemo(() => {
|
||||
return images.filter(img => img.url);
|
||||
}, [images]);
|
||||
|
||||
const currentImage = useMemo(() => {
|
||||
if (currentIndex < 0 || currentIndex >= validImages.length) {
|
||||
return null;
|
||||
}
|
||||
return validImages[currentIndex];
|
||||
}, [validImages, currentIndex]);
|
||||
|
||||
// 重置缩放状态
|
||||
const resetZoom = useCallback(() => {
|
||||
scale.value = withTiming(1, { duration: 200 });
|
||||
savedScale.value = 1;
|
||||
translateX.value = withTiming(0, { duration: 200 });
|
||||
translateY.value = withTiming(0, { duration: 200 });
|
||||
savedTranslateX.value = 0;
|
||||
savedTranslateY.value = 0;
|
||||
}, [scale, savedScale, translateX, translateY, savedTranslateX, savedTranslateY]);
|
||||
|
||||
// 打开/关闭时重置状态
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setCurrentIndex(initialIndex);
|
||||
setShowControls(true);
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
resetZoom();
|
||||
StatusBar.setHidden(true, 'fade');
|
||||
} else {
|
||||
StatusBar.setHidden(false, 'fade');
|
||||
}
|
||||
}, [visible, initialIndex, resetZoom]);
|
||||
|
||||
// 图片变化时重置加载状态和缩放
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
resetZoom();
|
||||
}, [currentImage?.id, resetZoom]);
|
||||
|
||||
const updateIndex = useCallback(
|
||||
(newIndex: number) => {
|
||||
const clampedIndex = Math.max(0, Math.min(validImages.length - 1, newIndex));
|
||||
setCurrentIndex(clampedIndex);
|
||||
onIndexChange?.(clampedIndex);
|
||||
},
|
||||
[validImages.length, onIndexChange]
|
||||
);
|
||||
|
||||
const toggleControls = useCallback(() => {
|
||||
setShowControls(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const goToPrev = useCallback(() => {
|
||||
if (currentIndex > 0) {
|
||||
updateIndex(currentIndex - 1);
|
||||
}
|
||||
}, [currentIndex, updateIndex]);
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
if (currentIndex < validImages.length - 1) {
|
||||
updateIndex(currentIndex + 1);
|
||||
}
|
||||
}, [currentIndex, validImages.length, updateIndex]);
|
||||
|
||||
// 显示短暂提示
|
||||
const showToast = useCallback((type: 'success' | 'error') => {
|
||||
setSaveToast(type);
|
||||
setTimeout(() => setSaveToast(null), 2500);
|
||||
}, []);
|
||||
|
||||
// 保存图片到本地相册
|
||||
const handleSaveImage = useCallback(async () => {
|
||||
if (!currentImage || saving) return;
|
||||
|
||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('无法保存', '请在系统设置中允许访问相册权限后重试。');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const urlPath = currentImage.url.split('?')[0];
|
||||
const ext = urlPath.split('.').pop()?.toLowerCase() ?? 'jpg';
|
||||
const allowedExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
const fileExt = allowedExts.includes(ext) ? ext : 'jpg';
|
||||
|
||||
const fileName = `carrot_${Date.now()}.${fileExt}`;
|
||||
const destination = new File(Paths.cache, fileName);
|
||||
|
||||
// File.downloadFileAsync 是新版 expo-file-system/next 的静态方法
|
||||
const downloaded = await File.downloadFileAsync(currentImage.url, destination);
|
||||
|
||||
await MediaLibrary.saveToLibraryAsync(downloaded.uri);
|
||||
|
||||
// 清理缓存文件
|
||||
downloaded.delete();
|
||||
|
||||
onSave?.(currentImage.url);
|
||||
showToast('success');
|
||||
} catch (err) {
|
||||
console.error('[ImageGallery] 保存图片失败:', err);
|
||||
showToast('error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [currentImage, saving, onSave, showToast]);
|
||||
|
||||
// 双指缩放手势
|
||||
const pinchGesture = Gesture.Pinch()
|
||||
.onUpdate((e) => {
|
||||
const newScale = savedScale.value * e.scale;
|
||||
// 限制缩放范围
|
||||
scale.value = Math.max(0.5, Math.min(newScale, 4));
|
||||
})
|
||||
.onEnd(() => {
|
||||
// 如果缩放小于1,回弹到1
|
||||
if (scale.value < 1) {
|
||||
scale.value = withTiming(1, { duration: 200 });
|
||||
savedScale.value = 1;
|
||||
translateX.value = withTiming(0, { duration: 200 });
|
||||
translateY.value = withTiming(0, { duration: 200 });
|
||||
savedTranslateX.value = 0;
|
||||
savedTranslateY.value = 0;
|
||||
} else {
|
||||
savedScale.value = scale.value;
|
||||
}
|
||||
});
|
||||
|
||||
// 滑动切换图片相关状态
|
||||
const swipeTranslateX = useSharedValue(0);
|
||||
|
||||
// 统一的滑动手势:放大时拖动,未放大时切换图片
|
||||
const panGesture = Gesture.Pan()
|
||||
.activeOffsetX([-10, 10]) // 水平方向需要移动10pt才激活,避免与点击冲突
|
||||
.activeOffsetY([-10, 10]) // 垂直方向也需要一定偏移才激活
|
||||
.onBegin(() => {
|
||||
swipeTranslateX.value = 0;
|
||||
})
|
||||
.onUpdate((e) => {
|
||||
if (scale.value > 1) {
|
||||
// 放大状态下:拖动图片
|
||||
translateX.value = savedTranslateX.value + e.translationX;
|
||||
translateY.value = savedTranslateY.value + e.translationY;
|
||||
} else if (validImages.length > 1) {
|
||||
// 未放大且有多张图片:切换图片的跟随效果
|
||||
const isFirst = currentIndex === 0 && e.translationX > 0;
|
||||
const isLast = currentIndex === validImages.length - 1 && e.translationX < 0;
|
||||
if (isFirst || isLast) {
|
||||
// 边界阻力效果
|
||||
swipeTranslateX.value = e.translationX * 0.3;
|
||||
} else {
|
||||
swipeTranslateX.value = e.translationX;
|
||||
}
|
||||
}
|
||||
})
|
||||
.onEnd((e) => {
|
||||
if (scale.value > 1) {
|
||||
// 放大状态下:保存拖动位置
|
||||
savedTranslateX.value = translateX.value;
|
||||
savedTranslateY.value = translateY.value;
|
||||
} else if (validImages.length > 1) {
|
||||
// 未放大状态下:判断是否切换图片
|
||||
const threshold = SCREEN_WIDTH * 0.2;
|
||||
const velocity = e.velocityX;
|
||||
|
||||
const shouldGoNext = e.translationX < -threshold || velocity < -500;
|
||||
const shouldGoPrev = e.translationX > threshold || velocity > 500;
|
||||
|
||||
if (shouldGoNext && currentIndex < validImages.length - 1) {
|
||||
// 向左滑动,显示下一张
|
||||
swipeTranslateX.value = withTiming(-SCREEN_WIDTH, { duration: 200 }, () => {
|
||||
runOnJS(updateIndex)(currentIndex + 1);
|
||||
swipeTranslateX.value = 0;
|
||||
});
|
||||
} else if (shouldGoPrev && currentIndex > 0) {
|
||||
// 向右滑动,显示上一张
|
||||
swipeTranslateX.value = withTiming(SCREEN_WIDTH, { duration: 200 }, () => {
|
||||
runOnJS(updateIndex)(currentIndex - 1);
|
||||
swipeTranslateX.value = 0;
|
||||
});
|
||||
} else {
|
||||
// 回弹到原位
|
||||
swipeTranslateX.value = withTiming(0, { duration: 200 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 点击手势(关闭 gallery)
|
||||
const tapGesture = Gesture.Tap()
|
||||
.numberOfTaps(1)
|
||||
.maxDistance(10)
|
||||
.onEnd(() => {
|
||||
runOnJS(onClose)();
|
||||
});
|
||||
|
||||
// 组合手势:
|
||||
// - pinchGesture 和 (panGesture / tapGesture) 可以同时识别
|
||||
// - panGesture 和 tapGesture 互斥(Race):短按是点击,长按/滑动是拖动
|
||||
const composedGesture = Gesture.Simultaneous(
|
||||
pinchGesture,
|
||||
Gesture.Race(panGesture, tapGesture)
|
||||
);
|
||||
|
||||
// 动画样式
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateX: translateX.value + swipeTranslateX.value },
|
||||
{ translateY: translateY.value },
|
||||
{ scale: scale.value },
|
||||
] as const,
|
||||
}));
|
||||
|
||||
if (!visible || validImages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<View style={[styles.container, { backgroundColor: `rgba(0, 0, 0, ${backgroundOpacity})` }]}>
|
||||
{/* 顶部控制栏 */}
|
||||
{showControls && (
|
||||
<View style={[styles.header, { paddingTop: insets.top + spacing.md }]}>
|
||||
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||
<MaterialCommunityIcons name="close" size={24} color="#FFF" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.headerCenter}>
|
||||
<Text style={styles.pageIndicator}>
|
||||
{currentIndex + 1} / {validImages.length}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{enableSave && (
|
||||
<TouchableOpacity
|
||||
style={styles.saveButton}
|
||||
onPress={handleSaveImage}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<ActivityIndicator size="small" color="#FFF" />
|
||||
) : (
|
||||
<MaterialCommunityIcons name="download" size={24} color="#FFF" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 图片显示区域 */}
|
||||
<GestureDetector gesture={composedGesture}>
|
||||
<View style={styles.imageContainer}>
|
||||
{loading && (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#FFF" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<MaterialCommunityIcons name="image-off" size={48} color="#999" />
|
||||
<Text style={styles.errorText}>加载失败</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{currentImage && (
|
||||
<Animated.View style={[styles.imageWrapper, animatedStyle]}>
|
||||
<ExpoImage
|
||||
source={{ uri: currentImage.url }}
|
||||
style={styles.image}
|
||||
contentFit="contain"
|
||||
cachePolicy="disk"
|
||||
priority="high"
|
||||
recyclingKey={currentImage.id}
|
||||
allowDownscaling
|
||||
onLoadStart={() => setLoading(true)}
|
||||
onLoad={() => {
|
||||
setLoading(false);
|
||||
setError(false);
|
||||
}}
|
||||
onError={() => {
|
||||
setLoading(false);
|
||||
setError(true);
|
||||
}}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
</GestureDetector>
|
||||
|
||||
{/* 左右切换按钮 */}
|
||||
{showControls && validImages.length > 1 && (
|
||||
<>
|
||||
{currentIndex > 0 && (
|
||||
<TouchableOpacity
|
||||
style={[styles.navButton, styles.navButtonLeft]}
|
||||
onPress={goToPrev}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="chevron-left" size={36} color="#FFF" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{currentIndex < validImages.length - 1 && (
|
||||
<TouchableOpacity
|
||||
style={[styles.navButton, styles.navButtonRight]}
|
||||
onPress={goToNext}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="chevron-right" size={36} color="#FFF" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 底部指示器 */}
|
||||
{showControls && showIndicator && validImages.length > 1 && (
|
||||
<View style={[styles.footer, { paddingBottom: insets.bottom + spacing.lg }]}>
|
||||
<View style={styles.dotsContainer}>
|
||||
{validImages.map((_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.dot,
|
||||
index === currentIndex && styles.activeDot,
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 保存结果 Toast */}
|
||||
{saveToast !== null && (
|
||||
<View style={[styles.toast, saveToast === 'success' ? styles.toastSuccess : styles.toastError]}>
|
||||
<MaterialCommunityIcons
|
||||
name={saveToast === 'success' ? 'check-circle-outline' : 'alert-circle-outline'}
|
||||
size={18}
|
||||
color="#FFF"
|
||||
/>
|
||||
<Text style={styles.toastText}>
|
||||
{saveToast === 'success' ? '已保存到相册' : '保存失败,请重试'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
header: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingBottom: spacing.md,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
zIndex: 10,
|
||||
},
|
||||
closeButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerCenter: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
pageIndicator: {
|
||||
color: '#FFF',
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '500',
|
||||
},
|
||||
saveButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
imageContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
imageWrapper: {
|
||||
width: SCREEN_WIDTH,
|
||||
height: SCREEN_HEIGHT,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
image: {
|
||||
width: SCREEN_WIDTH,
|
||||
height: SCREEN_HEIGHT * 0.8,
|
||||
},
|
||||
loadingContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 5,
|
||||
},
|
||||
errorContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorText: {
|
||||
color: '#999',
|
||||
fontSize: fontSizes.md,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
navButton: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
marginTop: -25,
|
||||
width: 50,
|
||||
height: 50,
|
||||
borderRadius: 25,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
navButtonLeft: {
|
||||
left: spacing.sm,
|
||||
},
|
||||
navButtonRight: {
|
||||
right: spacing.sm,
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingVertical: spacing.lg,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
dotsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.4)',
|
||||
},
|
||||
activeDot: {
|
||||
width: 20,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#FFF',
|
||||
},
|
||||
toast: {
|
||||
position: 'absolute',
|
||||
bottom: 100,
|
||||
alignSelf: 'center',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.full,
|
||||
},
|
||||
toastSuccess: {
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.9)',
|
||||
},
|
||||
toastError: {
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.9)',
|
||||
},
|
||||
toastText: {
|
||||
color: '#FFF',
|
||||
fontSize: fontSizes.sm,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
|
||||
export default ImageGallery;
|
||||
598
src/components/common/ImageGrid.tsx
Normal file
598
src/components/common/ImageGrid.tsx
Normal file
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* ImageGrid 图片网格组件
|
||||
* 支持 1-9 张图片的智能布局
|
||||
* 根据图片数量自动选择最佳展示方式
|
||||
*/
|
||||
|
||||
import React, { useMemo, useCallback, useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
ViewStyle,
|
||||
Pressable,
|
||||
Text,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { SmartImage, ImageSource } from './SmartImage';
|
||||
import { colors, spacing, borderRadius } from '../../theme';
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
|
||||
// 默认容器内边距(用于计算可用宽度)
|
||||
const DEFAULT_CONTAINER_PADDING = spacing.lg * 2; // 左右各 spacing.lg
|
||||
|
||||
// 单张图片的默认宽高比
|
||||
const SINGLE_IMAGE_DEFAULT_ASPECT_RATIO = 4 / 3; // 默认4:3比例
|
||||
|
||||
// 单张图片的最大高度
|
||||
const SINGLE_IMAGE_MAX_HEIGHT = 400;
|
||||
|
||||
// 单张图片的最小高度
|
||||
const SINGLE_IMAGE_MIN_HEIGHT = 150;
|
||||
|
||||
// 图片项类型 - 兼容 PostImageDTO 和 CommentImage
|
||||
export interface ImageGridItem {
|
||||
id?: string;
|
||||
uri?: string;
|
||||
url?: string;
|
||||
thumbnailUrl?: string;
|
||||
thumbnail_url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
// 布局模式
|
||||
export type GridLayoutMode = 'auto' | 'single' | 'horizontal' | 'grid' | 'masonry';
|
||||
|
||||
// 组件 Props
|
||||
export interface ImageGridProps {
|
||||
/** 图片列表 */
|
||||
images: ImageGridItem[];
|
||||
/** 容器样式 */
|
||||
style?: ViewStyle;
|
||||
/** 最大显示数量 */
|
||||
maxDisplayCount?: number;
|
||||
/** 布局模式 */
|
||||
mode?: GridLayoutMode;
|
||||
/** 图片间距 */
|
||||
gap?: number;
|
||||
/** 圆角大小 */
|
||||
borderRadius?: number;
|
||||
/** 是否显示更多遮罩 */
|
||||
showMoreOverlay?: boolean;
|
||||
/** 单张图片最大高度 */
|
||||
singleImageMaxHeight?: number;
|
||||
/** 网格列数(2或3) */
|
||||
gridColumns?: 2 | 3;
|
||||
/** 图片点击回调 */
|
||||
onImagePress?: (images: ImageGridItem[], index: number) => void;
|
||||
/** 测试ID */
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算图片网格尺寸
|
||||
*/
|
||||
const calculateGridDimensions = (
|
||||
count: number,
|
||||
containerWidth: number,
|
||||
gap: number,
|
||||
columns: number
|
||||
) => {
|
||||
const totalGap = (columns - 1) * gap;
|
||||
const itemSize = (containerWidth - totalGap) / columns;
|
||||
|
||||
// 计算行数
|
||||
const rows = Math.ceil(count / columns);
|
||||
|
||||
return {
|
||||
itemSize,
|
||||
rows,
|
||||
containerHeight: rows * itemSize + (rows - 1) * gap,
|
||||
};
|
||||
};
|
||||
|
||||
// ─── 单张图片子组件 ───────────────────────────────────────────────────────────
|
||||
// 独立成组件,方便用 useState 管理异步加载到的实际尺寸
|
||||
|
||||
interface SingleImageItemProps {
|
||||
image: ImageGridItem;
|
||||
containerWidth: number;
|
||||
maxHeight: number;
|
||||
borderRadiusValue: number;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const SingleImageItem: React.FC<SingleImageItemProps> = ({
|
||||
image,
|
||||
containerWidth,
|
||||
maxHeight,
|
||||
borderRadiusValue,
|
||||
onPress,
|
||||
}) => {
|
||||
const [aspectRatio, setAspectRatio] = useState<number | null>(null);
|
||||
const uri = image.uri || image.url || '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!uri) return;
|
||||
let cancelled = false;
|
||||
|
||||
// 添加超时处理,防止高分辨率图片加载卡住
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!cancelled && aspectRatio === null) {
|
||||
setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
Image.getSize(
|
||||
uri,
|
||||
(w, h) => {
|
||||
if (!cancelled) {
|
||||
clearTimeout(timeoutId);
|
||||
setAspectRatio(w / h);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) {
|
||||
clearTimeout(timeoutId);
|
||||
setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO);
|
||||
}
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [uri]);
|
||||
|
||||
const effectiveContainerWidth = containerWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING;
|
||||
|
||||
// 尺寸还没拿到时先占位,避免闪烁
|
||||
if (aspectRatio === null) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.singleContainer,
|
||||
{
|
||||
width: effectiveContainerWidth,
|
||||
height: SINGLE_IMAGE_MIN_HEIGHT,
|
||||
borderRadius: borderRadiusValue,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 适配最大边界框,保证宽高比不变
|
||||
let width: number;
|
||||
let height: number;
|
||||
if (aspectRatio > effectiveContainerWidth / maxHeight) {
|
||||
// 宽图:宽度撑满容器
|
||||
width = effectiveContainerWidth;
|
||||
height = effectiveContainerWidth / aspectRatio;
|
||||
} else {
|
||||
// 高图:高度触及上限
|
||||
height = maxHeight;
|
||||
width = maxHeight * aspectRatio;
|
||||
}
|
||||
|
||||
// 最小高度兜底
|
||||
if (height < SINGLE_IMAGE_MIN_HEIGHT) {
|
||||
height = SINGLE_IMAGE_MIN_HEIGHT;
|
||||
width = Math.min(SINGLE_IMAGE_MIN_HEIGHT * aspectRatio, effectiveContainerWidth);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={[styles.singleContainer, { width, height, borderRadius: borderRadiusValue }]}
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri }}
|
||||
style={styles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 图片网格组件
|
||||
* 智能布局:根据图片数量自动选择最佳展示方式
|
||||
*/
|
||||
export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
images,
|
||||
style,
|
||||
maxDisplayCount = 9,
|
||||
mode = 'auto',
|
||||
gap = 4,
|
||||
borderRadius: borderRadiusValue = borderRadius.md,
|
||||
showMoreOverlay = true,
|
||||
singleImageMaxHeight = 300,
|
||||
gridColumns = 3,
|
||||
onImagePress,
|
||||
testID,
|
||||
}) => {
|
||||
// 通过 onLayout 拿到容器实际宽度
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
|
||||
// 过滤有效图片 - 支持 uri 或 url 字段
|
||||
const validImages = useMemo(() => {
|
||||
const filtered = images.filter(img => img.uri || img.url || typeof img === 'string');
|
||||
return filtered;
|
||||
}, [images]);
|
||||
|
||||
// 实际显示的图片
|
||||
const displayImages = useMemo(() => {
|
||||
return validImages.slice(0, maxDisplayCount);
|
||||
}, [validImages, maxDisplayCount]);
|
||||
|
||||
// 剩余图片数量
|
||||
const remainingCount = useMemo(() => {
|
||||
return Math.max(0, validImages.length - maxDisplayCount);
|
||||
}, [validImages, maxDisplayCount]);
|
||||
|
||||
// 处理图片点击
|
||||
const handleImagePress = useCallback(
|
||||
(index: number) => {
|
||||
onImagePress?.(validImages, index);
|
||||
},
|
||||
[onImagePress, validImages]
|
||||
);
|
||||
|
||||
// 确定布局模式
|
||||
const layoutMode = useMemo(() => {
|
||||
if (mode !== 'auto') return mode;
|
||||
|
||||
const count = displayImages.length;
|
||||
if (count === 1) return 'single';
|
||||
if (count === 2) return 'horizontal';
|
||||
return 'grid';
|
||||
}, [mode, displayImages.length]);
|
||||
|
||||
// 渲染单张图片
|
||||
const renderSingleImage = () => {
|
||||
const image = displayImages[0];
|
||||
if (!image) return null;
|
||||
return (
|
||||
<SingleImageItem
|
||||
image={image}
|
||||
containerWidth={containerWidth}
|
||||
maxHeight={singleImageMaxHeight}
|
||||
borderRadiusValue={borderRadiusValue}
|
||||
onPress={() => handleImagePress(0)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染横向双图
|
||||
const renderHorizontal = () => {
|
||||
return (
|
||||
<View style={[styles.horizontalContainer, { gap }]}>
|
||||
{displayImages.map((image, index) => (
|
||||
<Pressable
|
||||
key={image.id || index}
|
||||
onPress={() => handleImagePress(index)}
|
||||
style={[
|
||||
styles.horizontalItem,
|
||||
{
|
||||
flex: 1,
|
||||
aspectRatio: 1,
|
||||
borderRadius: borderRadiusValue,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
|
||||
style={styles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
/>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染网格布局
|
||||
const renderGrid = () => {
|
||||
return (
|
||||
<View style={[styles.gridContainer, { gap }]}>
|
||||
{displayImages.map((image, index) => {
|
||||
const isLastVisible = index === displayImages.length - 1;
|
||||
const showOverlay = isLastVisible && remainingCount > 0 && showMoreOverlay;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={image.id || index}
|
||||
onPress={() => handleImagePress(index)}
|
||||
style={[
|
||||
styles.gridItem,
|
||||
gridColumns === 3 ? styles.gridItem3 : styles.gridItem2,
|
||||
{
|
||||
borderRadius: borderRadiusValue,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
|
||||
style={styles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
/>
|
||||
{showOverlay && (
|
||||
<View style={styles.moreOverlay}>
|
||||
<Text style={styles.moreText}>+{remainingCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染瀑布流布局
|
||||
const renderMasonry = () => {
|
||||
const containerWidth = SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING;
|
||||
const columns = 2;
|
||||
const itemWidth = (containerWidth - gap) / columns;
|
||||
|
||||
// 将图片分配到两列
|
||||
const leftColumn: ImageGridItem[] = [];
|
||||
const rightColumn: ImageGridItem[] = [];
|
||||
|
||||
displayImages.forEach((image, index) => {
|
||||
if (index % 2 === 0) {
|
||||
leftColumn.push(image);
|
||||
} else {
|
||||
rightColumn.push(image);
|
||||
}
|
||||
});
|
||||
|
||||
const renderColumn = (columnImages: ImageGridItem[], columnIndex: number) => {
|
||||
return (
|
||||
<View style={[styles.masonryColumn, { gap }]}>
|
||||
{columnImages.map((image, index) => {
|
||||
const actualIndex = columnIndex + index * 2;
|
||||
const aspectRatio = image.width && image.height
|
||||
? image.width / image.height
|
||||
: 1;
|
||||
const height = itemWidth / aspectRatio;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={image.id || actualIndex}
|
||||
onPress={() => handleImagePress(actualIndex)}
|
||||
style={[
|
||||
styles.masonryItem,
|
||||
{
|
||||
width: itemWidth,
|
||||
height: Math.max(height, itemWidth * 0.7),
|
||||
borderRadius: borderRadiusValue,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
|
||||
style={styles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.masonryContainer, { gap }]}>
|
||||
{renderColumn(leftColumn, 0)}
|
||||
{renderColumn(rightColumn, 1)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 根据布局模式渲染
|
||||
const renderContent = () => {
|
||||
switch (layoutMode) {
|
||||
case 'single':
|
||||
return renderSingleImage();
|
||||
case 'horizontal':
|
||||
return renderHorizontal();
|
||||
case 'masonry':
|
||||
return renderMasonry();
|
||||
case 'grid':
|
||||
default:
|
||||
return renderGrid();
|
||||
}
|
||||
};
|
||||
|
||||
// 如果没有图片,返回null
|
||||
if (displayImages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[styles.container, style]}
|
||||
testID={testID}
|
||||
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}
|
||||
>
|
||||
{renderContent()}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 紧凑模式 - 用于评论等小空间场景
|
||||
export interface CompactImageGridProps extends Omit<ImageGridProps, 'mode' | 'gridColumns'> {
|
||||
/** 最大尺寸限制 */
|
||||
maxWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 紧凑图片网格
|
||||
* 适用于评论等空间有限的场景
|
||||
*/
|
||||
export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
|
||||
maxWidth,
|
||||
gap = 4,
|
||||
borderRadius: borderRadiusValue = borderRadius.sm,
|
||||
...props
|
||||
}) => {
|
||||
const containerWidth = maxWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING - 36 - spacing.sm; // 36是头像宽度
|
||||
|
||||
const renderCompactGrid = () => {
|
||||
const { images } = props;
|
||||
const count = images.length;
|
||||
|
||||
if (count === 0) return null;
|
||||
|
||||
if (count === 1) {
|
||||
const image = images[0];
|
||||
const size = Math.min(containerWidth * 0.6, 150);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => props.onImagePress?.(images, 0)}
|
||||
style={[
|
||||
styles.compactItem,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: borderRadiusValue,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
|
||||
style={styles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
// 多张图片使用小网格
|
||||
const columns = count <= 4 ? 2 : 3;
|
||||
const { itemSize } = calculateGridDimensions(count, containerWidth, gap, columns);
|
||||
|
||||
return (
|
||||
<View style={[styles.compactGrid, { gap }]}>
|
||||
{images.slice(0, 6).map((image, index) => (
|
||||
<Pressable
|
||||
key={image.id || index}
|
||||
onPress={() => props.onImagePress?.(images, index)}
|
||||
style={[
|
||||
styles.compactItem,
|
||||
{
|
||||
width: itemSize,
|
||||
height: itemSize,
|
||||
borderRadius: borderRadiusValue,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
|
||||
style={styles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
/>
|
||||
{index === 5 && images.length > 6 && (
|
||||
<View style={styles.moreOverlay}>
|
||||
<Text style={styles.moreText}>+{images.length - 6}</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return <View style={styles.compactContainer}>{renderCompactGrid()}</View>;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
fullSize: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
// 单图样式
|
||||
singleContainer: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
// 横向布局样式
|
||||
horizontalContainer: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
horizontalItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
// 网格布局样式
|
||||
gridContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
gridItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
aspectRatio: 1,
|
||||
},
|
||||
gridItem2: {
|
||||
width: '48%', // 2列布局,每列约48%宽度,留有余量避免换行
|
||||
},
|
||||
gridItem3: {
|
||||
width: '31%', // 3列布局,每列约31%宽度,留有余量避免换行
|
||||
},
|
||||
// 瀑布流样式
|
||||
masonryContainer: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
masonryColumn: {
|
||||
flex: 1,
|
||||
},
|
||||
masonryItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
// 更多遮罩 - 类似微博的灰色蒙版
|
||||
moreOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
moreText: {
|
||||
color: colors.text.inverse,
|
||||
fontSize: fontSizes.xl,
|
||||
fontWeight: '500',
|
||||
},
|
||||
// 紧凑模式样式
|
||||
compactContainer: {
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
compactGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
compactItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
});
|
||||
|
||||
// 导入字体大小
|
||||
import { fontSizes } from '../../theme';
|
||||
|
||||
export default ImageGrid;
|
||||
169
src/components/common/Input.tsx
Normal file
169
src/components/common/Input.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Input 输入框组件
|
||||
* 支持标签、错误提示、图标、多行输入等
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ViewStyle,
|
||||
TextStyle,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, borderRadius, spacing, fontSizes } from '../../theme';
|
||||
import Text from './Text';
|
||||
|
||||
interface InputProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
secureTextEntry?: boolean;
|
||||
multiline?: boolean;
|
||||
numberOfLines?: number;
|
||||
leftIcon?: string;
|
||||
rightIcon?: string;
|
||||
onRightIconPress?: () => void;
|
||||
editable?: boolean;
|
||||
style?: ViewStyle;
|
||||
inputStyle?: TextStyle;
|
||||
autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters';
|
||||
keyboardType?: 'default' | 'email-address' | 'numeric' | 'phone-pad';
|
||||
autoCorrect?: boolean;
|
||||
}
|
||||
|
||||
const Input: React.FC<InputProps> = ({
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
label,
|
||||
error,
|
||||
secureTextEntry = false,
|
||||
multiline = false,
|
||||
numberOfLines = 1,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
onRightIconPress,
|
||||
editable = true,
|
||||
style,
|
||||
inputStyle,
|
||||
autoCapitalize = 'sentences',
|
||||
keyboardType = 'default',
|
||||
autoCorrect = true,
|
||||
}) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const getBorderColor = () => {
|
||||
if (error) return colors.error.main;
|
||||
if (isFocused) return colors.primary.main;
|
||||
return colors.divider;
|
||||
};
|
||||
|
||||
const containerStyle = [
|
||||
styles.container,
|
||||
{ borderColor: getBorderColor() },
|
||||
multiline && { minHeight: 100 },
|
||||
style,
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
{label && (
|
||||
<Text variant="label" color={colors.text.secondary} style={styles.label}>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
<View style={containerStyle}>
|
||||
{leftIcon && (
|
||||
<MaterialCommunityIcons
|
||||
name={leftIcon as any}
|
||||
size={20}
|
||||
color={colors.text.secondary}
|
||||
style={styles.leftIcon}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
multiline && styles.multilineInput,
|
||||
inputStyle,
|
||||
]}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.text.hint}
|
||||
secureTextEntry={secureTextEntry}
|
||||
multiline={multiline}
|
||||
numberOfLines={numberOfLines}
|
||||
editable={editable}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
autoCapitalize={autoCapitalize}
|
||||
keyboardType={keyboardType}
|
||||
autoCorrect={autoCorrect}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<TouchableOpacity
|
||||
onPress={onRightIconPress}
|
||||
disabled={!onRightIconPress}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={rightIcon as any}
|
||||
size={20}
|
||||
color={colors.text.secondary}
|
||||
style={styles.rightIcon}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
{error && (
|
||||
<Text variant="caption" color={colors.error.main} style={styles.error}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
width: '100%',
|
||||
},
|
||||
label: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderWidth: 1,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
paddingVertical: spacing.md,
|
||||
minHeight: 44,
|
||||
},
|
||||
multilineInput: {
|
||||
textAlignVertical: 'top',
|
||||
minHeight: 100,
|
||||
},
|
||||
leftIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
rightIcon: {
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
error: {
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
});
|
||||
|
||||
export default Input;
|
||||
65
src/components/common/Loading.tsx
Normal file
65
src/components/common/Loading.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Loading 加载组件
|
||||
* 支持不同尺寸、全屏模式
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, ActivityIndicator, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { colors } from '../../theme';
|
||||
|
||||
type LoadingSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface LoadingProps {
|
||||
size?: LoadingSize;
|
||||
color?: string;
|
||||
fullScreen?: boolean;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
const Loading: React.FC<LoadingProps> = ({
|
||||
size = 'md',
|
||||
color = colors.primary.main,
|
||||
fullScreen = false,
|
||||
style,
|
||||
}) => {
|
||||
const getSize = (): 'small' | 'large' | undefined => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return 'small';
|
||||
case 'lg':
|
||||
return 'large';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
if (fullScreen) {
|
||||
return (
|
||||
<View style={[styles.fullScreen, style]}>
|
||||
<ActivityIndicator size={getSize()} color={color} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<ActivityIndicator size={getSize()} color={color} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
fullScreen: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
});
|
||||
|
||||
export default Loading;
|
||||
62
src/components/common/ResponsiveContainer.tsx
Normal file
62
src/components/common/ResponsiveContainer.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 响应式容器组件
|
||||
* 在宽屏时居中显示并限制最大宽度,在移动端占满宽度
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, StyleProp, ViewStyle } from 'react-native';
|
||||
import { useResponsive } from '../../hooks/useResponsive';
|
||||
|
||||
export interface ResponsiveContainerProps {
|
||||
children: React.ReactNode;
|
||||
maxWidth?: number; // 默认 1200
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应式容器组件
|
||||
*
|
||||
* 根据屏幕尺寸自动调整布局:
|
||||
* - 移动端:占满宽度
|
||||
* - 平板及以上:居中显示,限制最大宽度
|
||||
*
|
||||
* @param props - 组件属性
|
||||
* @param props.children - 子元素
|
||||
* @param props.maxWidth - 最大宽度,默认 1200
|
||||
* @param props.style - 自定义样式
|
||||
*
|
||||
* @example
|
||||
* <ResponsiveContainer>
|
||||
* <YourContent />
|
||||
* </ResponsiveContainer>
|
||||
*/
|
||||
export function ResponsiveContainer({
|
||||
children,
|
||||
maxWidth = 1200,
|
||||
style,
|
||||
}: ResponsiveContainerProps) {
|
||||
const { isWideScreen, width } = useResponsive();
|
||||
|
||||
// 在宽屏时限制最大宽度
|
||||
const containerWidth = isWideScreen ? Math.min(width, maxWidth) : width;
|
||||
|
||||
// 计算水平 padding
|
||||
const horizontalPadding = isWideScreen ? (width - containerWidth) / 2 : 0;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
width: '100%',
|
||||
maxWidth: isWideScreen ? maxWidth : undefined,
|
||||
paddingHorizontal: isWideScreen ? Math.max(horizontalPadding, 16) : 16,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResponsiveContainer;
|
||||
168
src/components/common/ResponsiveGrid.tsx
Normal file
168
src/components/common/ResponsiveGrid.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 响应式网格布局组件
|
||||
* 根据断点自动调整列数,支持间距配置
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import {
|
||||
useResponsive,
|
||||
useColumnCount,
|
||||
useResponsiveSpacing,
|
||||
FineBreakpointKey,
|
||||
} from '../../hooks/useResponsive';
|
||||
|
||||
export interface ResponsiveGridProps {
|
||||
/** 子元素 */
|
||||
children: React.ReactNode[];
|
||||
/** 自定义样式 */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** 容器样式 */
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
/** 项目样式 */
|
||||
itemStyle?: StyleProp<ViewStyle>;
|
||||
/** 列数配置,根据断点设置 */
|
||||
columns?: Partial<Record<FineBreakpointKey, number>>;
|
||||
/** 间距配置 */
|
||||
gap?: Partial<Record<FineBreakpointKey, number>>;
|
||||
/** 行间距(默认等于 gap) */
|
||||
rowGap?: Partial<Record<FineBreakpointKey, number>>;
|
||||
/** 列间距(默认等于 gap) */
|
||||
columnGap?: Partial<Record<FineBreakpointKey, number>>;
|
||||
/** 是否启用等宽列 */
|
||||
equalColumns?: boolean;
|
||||
/** 自定义列宽计算函数 */
|
||||
getColumnWidth?: (containerWidth: number, columns: number, gap: number) => number;
|
||||
/** 渲染空状态 */
|
||||
renderEmpty?: () => React.ReactNode;
|
||||
/** key 提取函数 */
|
||||
keyExtractor?: (item: React.ReactNode, index: number) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应式网格布局组件
|
||||
*
|
||||
* 根据屏幕宽度自动调整列数,支持平板/桌面端的多列布局
|
||||
*
|
||||
* @example
|
||||
* <ResponsiveGrid
|
||||
* columns={{ xs: 1, sm: 2, md: 3, lg: 4 }}
|
||||
* gap={{ xs: 8, md: 16 }}
|
||||
* >
|
||||
* {items.map(item => <Card key={item.id} {...item} />)}
|
||||
* </ResponsiveGrid>
|
||||
*/
|
||||
export function ResponsiveGrid({
|
||||
children,
|
||||
style,
|
||||
containerStyle,
|
||||
itemStyle,
|
||||
columns: columnsConfig,
|
||||
gap: gapConfig,
|
||||
rowGap: rowGapConfig,
|
||||
columnGap: columnGapConfig,
|
||||
equalColumns = true,
|
||||
getColumnWidth,
|
||||
renderEmpty,
|
||||
keyExtractor,
|
||||
}: ResponsiveGridProps) {
|
||||
const { width } = useResponsive();
|
||||
const columns = useColumnCount(columnsConfig);
|
||||
const defaultGap = useResponsiveSpacing(gapConfig);
|
||||
const rowGap = useResponsiveSpacing(rowGapConfig ?? gapConfig);
|
||||
const columnGap = useResponsiveSpacing(columnGapConfig ?? gapConfig);
|
||||
|
||||
// 计算列宽
|
||||
const columnWidth = useMemo(() => {
|
||||
if (getColumnWidth) {
|
||||
return getColumnWidth(width, columns, columnGap);
|
||||
}
|
||||
|
||||
if (equalColumns) {
|
||||
// 等宽列计算:(总宽度 - (列数 - 1) * 列间距) / 列数
|
||||
return (width - (columns - 1) * columnGap) / columns;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [width, columns, columnGap, equalColumns, getColumnWidth]);
|
||||
|
||||
// 将子元素分组为行
|
||||
const rows = useMemo(() => {
|
||||
const items = React.Children.toArray(children);
|
||||
if (items.length === 0) return [];
|
||||
|
||||
const result: React.ReactNode[][] = [];
|
||||
for (let i = 0; i < items.length; i += columns) {
|
||||
result.push(items.slice(i, i + columns));
|
||||
}
|
||||
return result;
|
||||
}, [children, columns]);
|
||||
|
||||
// 空状态处理
|
||||
if (rows.length === 0 && renderEmpty) {
|
||||
return (
|
||||
<View style={[styles.container, containerStyle]}>
|
||||
{renderEmpty()}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, containerStyle]}>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<View
|
||||
key={`row-${rowIndex}`}
|
||||
style={[
|
||||
styles.row,
|
||||
{
|
||||
marginBottom: rowIndex < rows.length - 1 ? rowGap : 0,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{row.map((child, colIndex) => {
|
||||
const index = rowIndex * columns + colIndex;
|
||||
const key = keyExtractor?.(child, index) ?? `grid-item-${index}`;
|
||||
|
||||
return (
|
||||
<View
|
||||
key={key}
|
||||
style={[
|
||||
styles.item,
|
||||
{
|
||||
width: columnWidth,
|
||||
marginRight: colIndex < row.length - 1 ? columnGap : 0,
|
||||
},
|
||||
itemStyle,
|
||||
]}
|
||||
>
|
||||
{child}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
item: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export default ResponsiveGrid;
|
||||
212
src/components/common/ResponsiveStack.tsx
Normal file
212
src/components/common/ResponsiveStack.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 响应式堆叠布局组件
|
||||
* 移动端垂直堆叠,平板/桌面端水平排列
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
StyleSheet,
|
||||
FlexAlignType,
|
||||
} from 'react-native';
|
||||
import {
|
||||
useResponsive,
|
||||
useResponsiveSpacing,
|
||||
FineBreakpointKey,
|
||||
useBreakpointGTE,
|
||||
} from '../../hooks/useResponsive';
|
||||
|
||||
export type StackDirection = 'horizontal' | 'vertical' | 'responsive';
|
||||
export type StackAlignment = 'start' | 'center' | 'end' | 'stretch' | 'baseline';
|
||||
export type StackJustify = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly';
|
||||
|
||||
export interface ResponsiveStackProps {
|
||||
/** 子元素 */
|
||||
children: React.ReactNode;
|
||||
/** 布局方向 */
|
||||
direction?: StackDirection;
|
||||
/** 切换为水平布局的断点(仅在 direction='responsive' 时有效) */
|
||||
horizontalBreakpoint?: FineBreakpointKey;
|
||||
/** 间距 */
|
||||
gap?: Partial<Record<FineBreakpointKey, number>> | number;
|
||||
/** 是否允许换行 */
|
||||
wrap?: boolean;
|
||||
/** 对齐方式(交叉轴) */
|
||||
align?: StackAlignment;
|
||||
/** 分布方式(主轴) */
|
||||
justify?: StackJustify;
|
||||
/** 自定义样式 */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** 子元素样式 */
|
||||
itemStyle?: StyleProp<ViewStyle>;
|
||||
/** 是否反转顺序 */
|
||||
reverse?: boolean;
|
||||
/** 是否等分空间 */
|
||||
equalItem?: boolean;
|
||||
}
|
||||
|
||||
const alignMap: Record<StackAlignment, FlexAlignType> = {
|
||||
start: 'flex-start',
|
||||
center: 'center',
|
||||
end: 'flex-end',
|
||||
stretch: 'stretch',
|
||||
baseline: 'baseline',
|
||||
};
|
||||
|
||||
const justifyMap: Record<StackJustify, 'flex-start' | 'center' | 'flex-end' | 'space-between' | 'space-around' | 'space-evenly'> = {
|
||||
start: 'flex-start',
|
||||
center: 'center',
|
||||
end: 'flex-end',
|
||||
between: 'space-between',
|
||||
around: 'space-around',
|
||||
evenly: 'space-evenly',
|
||||
};
|
||||
|
||||
/**
|
||||
* 响应式堆叠布局组件
|
||||
*
|
||||
* - 移动端垂直堆叠
|
||||
* - 平板/桌面端水平排列
|
||||
* - 支持间距和换行配置
|
||||
*
|
||||
* @example
|
||||
* // 基础用法 - 自动响应式
|
||||
* <ResponsiveStack>
|
||||
* <Item1 />
|
||||
* <Item2 />
|
||||
* <Item3 />
|
||||
* </ResponsiveStack>
|
||||
*
|
||||
* @example
|
||||
* // 自定义断点和间距
|
||||
* <ResponsiveStack
|
||||
* direction="responsive"
|
||||
* horizontalBreakpoint="md"
|
||||
* gap={{ xs: 8, md: 16, lg: 24 }}
|
||||
* align="center"
|
||||
* justify="between"
|
||||
* >
|
||||
* <Item1 />
|
||||
* <Item2 />
|
||||
* </ResponsiveStack>
|
||||
*
|
||||
* @example
|
||||
* // 固定水平方向
|
||||
* <ResponsiveStack direction="horizontal" wrap gap={16}>
|
||||
* {items.map(item => <Tag key={item.id} {...item} />)}
|
||||
* </ResponsiveStack>
|
||||
*/
|
||||
export function ResponsiveStack({
|
||||
children,
|
||||
direction = 'responsive',
|
||||
horizontalBreakpoint = 'lg',
|
||||
gap: gapConfig,
|
||||
wrap = false,
|
||||
align = 'stretch',
|
||||
justify = 'start',
|
||||
style,
|
||||
itemStyle,
|
||||
reverse = false,
|
||||
equalItem = false,
|
||||
}: ResponsiveStackProps) {
|
||||
const { isMobile, isTablet } = useResponsive();
|
||||
const isHorizontalBreakpoint = useBreakpointGTE(horizontalBreakpoint);
|
||||
|
||||
// 计算间距
|
||||
const gap = useMemo(() => {
|
||||
if (typeof gapConfig === 'number') {
|
||||
return gapConfig;
|
||||
}
|
||||
// 使用 hook 获取响应式间距
|
||||
return gapConfig;
|
||||
}, [gapConfig]);
|
||||
|
||||
const responsiveGap = useResponsiveSpacing(typeof gap === 'number' ? undefined : gap);
|
||||
const finalGap = typeof gap === 'number' ? gap : responsiveGap;
|
||||
|
||||
// 确定布局方向
|
||||
const isHorizontal = useMemo(() => {
|
||||
if (direction === 'horizontal') return true;
|
||||
if (direction === 'vertical') return false;
|
||||
// direction === 'responsive'
|
||||
return isHorizontalBreakpoint;
|
||||
}, [direction, isHorizontalBreakpoint]);
|
||||
|
||||
// 构建容器样式
|
||||
const containerStyle = useMemo((): ViewStyle => {
|
||||
const flexDirection = isHorizontal
|
||||
? (reverse ? 'row-reverse' : 'row')
|
||||
: (reverse ? 'column-reverse' : 'column');
|
||||
|
||||
return {
|
||||
flexDirection,
|
||||
flexWrap: wrap ? 'wrap' : 'nowrap',
|
||||
alignItems: alignMap[align],
|
||||
justifyContent: justifyMap[justify],
|
||||
gap: finalGap,
|
||||
};
|
||||
}, [isHorizontal, reverse, wrap, align, justify, finalGap]);
|
||||
|
||||
// 处理子元素
|
||||
const processedChildren = useMemo(() => {
|
||||
const childrenArray = React.Children.toArray(children);
|
||||
|
||||
return childrenArray.map((child, index) => {
|
||||
if (!React.isValidElement(child)) {
|
||||
return child;
|
||||
}
|
||||
|
||||
const childStyle: ViewStyle = {};
|
||||
|
||||
if (equalItem) {
|
||||
childStyle.flex = 1;
|
||||
}
|
||||
|
||||
// 如果不是最后一个元素,添加间距
|
||||
// 注意:使用 gap 后不需要手动添加 margin
|
||||
|
||||
return (
|
||||
<View
|
||||
key={child.key ?? `stack-item-${index}`}
|
||||
style={[equalItem && styles.equalItem, itemStyle, childStyle]}
|
||||
>
|
||||
{child}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
}, [children, equalItem, itemStyle]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, containerStyle, style]}>
|
||||
{processedChildren}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
},
|
||||
equalItem: {
|
||||
flex: 1,
|
||||
minWidth: 0, // 防止 flex item 溢出
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 水平堆叠组件(快捷方式)
|
||||
*/
|
||||
export function HStack(props: Omit<ResponsiveStackProps, 'direction'>) {
|
||||
return <ResponsiveStack {...props} direction="horizontal" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 垂直堆叠组件(快捷方式)
|
||||
*/
|
||||
export function VStack(props: Omit<ResponsiveStackProps, 'direction'>) {
|
||||
return <ResponsiveStack {...props} direction="vertical" />;
|
||||
}
|
||||
|
||||
export default ResponsiveStack;
|
||||
277
src/components/common/SmartImage.tsx
Normal file
277
src/components/common/SmartImage.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* SmartImage 智能图片组件
|
||||
* 支持加载状态、错误处理、自适应尺寸
|
||||
* 基于 expo-image 封装,原生支持 GIF/WebP 动图
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
ViewStyle,
|
||||
ImageStyle,
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
StyleProp,
|
||||
} from 'react-native';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, borderRadius } from '../../theme';
|
||||
|
||||
// 图片加载状态
|
||||
export type ImageLoadState = 'loading' | 'success' | 'error';
|
||||
|
||||
// 图片源类型 - 兼容多种数据格式
|
||||
export interface ImageSource {
|
||||
uri?: string;
|
||||
url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
// SmartImage Props
|
||||
export interface SmartImageProps {
|
||||
/** 图片源 */
|
||||
source: ImageSource | string;
|
||||
/** 容器样式 */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** 图片样式 */
|
||||
imageStyle?: StyleProp<ImageStyle>;
|
||||
/** 图片填充模式 */
|
||||
resizeMode?: 'cover' | 'contain' | 'stretch' | 'repeat' | 'center';
|
||||
/** 是否显示加载指示器 */
|
||||
showLoading?: boolean;
|
||||
/** 是否显示错误占位图 */
|
||||
showError?: boolean;
|
||||
/** 圆角大小 */
|
||||
borderRadius?: number;
|
||||
/** 点击回调 */
|
||||
onPress?: () => void;
|
||||
/** 长按回调 */
|
||||
onLongPress?: () => void;
|
||||
/** 加载完成回调 */
|
||||
onLoad?: () => void;
|
||||
/** 加载失败回调 */
|
||||
onError?: (error: any) => void;
|
||||
/** 测试ID */
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能图片组件
|
||||
* 自动处理加载状态、错误状态
|
||||
*/
|
||||
export const SmartImage: React.FC<SmartImageProps> = ({
|
||||
source,
|
||||
style,
|
||||
imageStyle,
|
||||
resizeMode = 'cover',
|
||||
showLoading = true,
|
||||
showError = true,
|
||||
borderRadius: borderRadiusValue = 0,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onLoad,
|
||||
onError,
|
||||
testID,
|
||||
}) => {
|
||||
const [loadState, setLoadState] = useState<ImageLoadState>('loading');
|
||||
|
||||
// 解析图片源 - 支持 uri 或 url 字段
|
||||
const imageUri = typeof source === 'string'
|
||||
? source
|
||||
: (source.uri || source.url || '');
|
||||
|
||||
// 处理加载开始
|
||||
const handleLoadStart = useCallback(() => {
|
||||
setLoadState('loading');
|
||||
}, []);
|
||||
|
||||
// 处理加载完成
|
||||
const handleLoad = useCallback(() => {
|
||||
setLoadState('success');
|
||||
onLoad?.();
|
||||
}, [onLoad]);
|
||||
|
||||
// 处理加载错误
|
||||
const handleError = useCallback(
|
||||
(error: any) => {
|
||||
setLoadState('error');
|
||||
onError?.(error);
|
||||
},
|
||||
[onError]
|
||||
);
|
||||
|
||||
// 重试加载
|
||||
const handleRetry = useCallback(() => {
|
||||
setLoadState('loading');
|
||||
}, []);
|
||||
|
||||
// 渲染加载指示器
|
||||
const renderLoading = () => {
|
||||
if (!showLoading || loadState !== 'loading') return null;
|
||||
|
||||
return (
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="small" color={colors.primary.main} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染错误占位图
|
||||
const renderError = () => {
|
||||
if (!showError || loadState !== 'error') return null;
|
||||
|
||||
return (
|
||||
<Pressable style={styles.overlay} onPress={handleRetry}>
|
||||
<View style={styles.errorContainer}>
|
||||
<MaterialCommunityIcons
|
||||
name="image-off-outline"
|
||||
size={24}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
// 容器样式
|
||||
const containerStyle: ViewStyle = {
|
||||
borderRadius: borderRadiusValue,
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
// 图片源配置
|
||||
const imageSource = imageUri && imageUri.trim() !== '' ? { uri: imageUri } : undefined;
|
||||
|
||||
// 如果没有有效的图片源,显示错误占位
|
||||
if (!imageSource) {
|
||||
return (
|
||||
<Pressable
|
||||
style={[containerStyle, style as ViewStyle, { backgroundColor: colors.background.disabled }]}
|
||||
testID={testID}
|
||||
>
|
||||
<View style={styles.errorContainer}>
|
||||
<MaterialCommunityIcons
|
||||
name="image-off-outline"
|
||||
size={24}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[containerStyle, style as ViewStyle]}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
disabled={!onPress && !onLongPress}
|
||||
testID={testID}
|
||||
>
|
||||
<ExpoImage
|
||||
source={imageSource}
|
||||
style={[styles.image, imageStyle as ImageStyle]}
|
||||
contentFit={
|
||||
resizeMode === 'stretch' ? 'fill' :
|
||||
resizeMode === 'repeat' ? 'cover' :
|
||||
resizeMode === 'center' ? 'scale-down' :
|
||||
resizeMode
|
||||
}
|
||||
cachePolicy="memory-disk"
|
||||
onLoadStart={handleLoadStart}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
{renderLoading()}
|
||||
{renderError()}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
// 预定义尺寸变体
|
||||
export interface ImageVariantProps extends Omit<SmartImageProps, 'style'> {
|
||||
/** 尺寸变体 */
|
||||
variant?: 'thumbnail' | 'small' | 'medium' | 'large' | 'full';
|
||||
/** 自定义尺寸 */
|
||||
size?: number;
|
||||
/** 宽高比 */
|
||||
aspectRatio?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 变体图片组件
|
||||
* 提供预定义的尺寸变体
|
||||
*/
|
||||
export const VariantImage: React.FC<ImageVariantProps> = ({
|
||||
variant = 'medium',
|
||||
size,
|
||||
aspectRatio,
|
||||
imageStyle,
|
||||
...props
|
||||
}) => {
|
||||
const getVariantStyle = (): ViewStyle => {
|
||||
switch (variant) {
|
||||
case 'thumbnail':
|
||||
return { width: size || 40, height: size || 40 };
|
||||
case 'small':
|
||||
return { width: size || 80, height: size || 80 };
|
||||
case 'medium':
|
||||
return { width: size || 120, height: size || 120 };
|
||||
case 'large':
|
||||
return { width: size || 200, height: size || 200 };
|
||||
case 'full':
|
||||
return { flex: 1 };
|
||||
default:
|
||||
return { width: size || 120, height: size || 120 };
|
||||
}
|
||||
};
|
||||
|
||||
const variantStyle = getVariantStyle();
|
||||
|
||||
// 如果有宽高比,调整高度
|
||||
const finalStyle: ViewStyle = { ...variantStyle };
|
||||
if (aspectRatio && finalStyle.width && !size) {
|
||||
finalStyle.height = (finalStyle.width as number) / aspectRatio;
|
||||
}
|
||||
|
||||
return (
|
||||
<SmartImage
|
||||
{...props}
|
||||
style={finalStyle}
|
||||
imageStyle={[styles.variantImage, imageStyle]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
image: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
variantImage: {
|
||||
flex: 1,
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
loadingContainer: {
|
||||
padding: 8,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
},
|
||||
errorContainer: {
|
||||
padding: 12,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
});
|
||||
|
||||
export default SmartImage;
|
||||
96
src/components/common/Text.tsx
Normal file
96
src/components/common/Text.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Text 文本组件
|
||||
* 提供统一的文本样式
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle } from 'react-native';
|
||||
import { colors, fontSizes } from '../../theme';
|
||||
|
||||
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
|
||||
|
||||
interface CustomTextProps extends Omit<TextProps, 'style'> {
|
||||
children: React.ReactNode;
|
||||
variant?: TextVariant;
|
||||
color?: string;
|
||||
numberOfLines?: number;
|
||||
onPress?: () => void;
|
||||
style?: TextStyle | TextStyle[];
|
||||
}
|
||||
|
||||
const variantStyles: Record<TextVariant, object> = {
|
||||
h1: {
|
||||
fontSize: fontSizes['4xl'],
|
||||
fontWeight: '700',
|
||||
lineHeight: fontSizes['4xl'] * 1.4,
|
||||
},
|
||||
h2: {
|
||||
fontSize: fontSizes['3xl'],
|
||||
fontWeight: '600',
|
||||
lineHeight: fontSizes['3xl'] * 1.4,
|
||||
},
|
||||
h3: {
|
||||
fontSize: fontSizes['2xl'],
|
||||
fontWeight: '600',
|
||||
lineHeight: fontSizes['2xl'] * 1.3,
|
||||
},
|
||||
body: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '400',
|
||||
lineHeight: fontSizes.md * 1.5,
|
||||
},
|
||||
caption: {
|
||||
fontSize: fontSizes.sm,
|
||||
fontWeight: '400',
|
||||
lineHeight: fontSizes.sm * 1.4,
|
||||
},
|
||||
label: {
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '500',
|
||||
lineHeight: fontSizes.xs * 1.4,
|
||||
},
|
||||
};
|
||||
|
||||
const Text: React.FC<CustomTextProps> = ({
|
||||
children,
|
||||
variant = 'body',
|
||||
color,
|
||||
numberOfLines,
|
||||
onPress,
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const textStyle = [
|
||||
styles.base,
|
||||
variantStyles[variant],
|
||||
color ? { color } : { color: colors.text.primary },
|
||||
style,
|
||||
];
|
||||
|
||||
if (onPress) {
|
||||
return (
|
||||
<RNText
|
||||
style={textStyle}
|
||||
numberOfLines={numberOfLines}
|
||||
onPress={onPress}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</RNText>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RNText style={textStyle} numberOfLines={numberOfLines} {...props}>
|
||||
{children}
|
||||
</RNText>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
fontFamily: undefined, // 使用系统默认字体
|
||||
},
|
||||
});
|
||||
|
||||
export default Text;
|
||||
145
src/components/common/VideoPlayerModal.tsx
Normal file
145
src/components/common/VideoPlayerModal.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* VideoPlayerModal 视频播放弹窗组件
|
||||
* 全屏模态播放视频,基于 expo-video
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
StatusBar,
|
||||
} 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';
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
export interface VideoPlayerModalProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
/** 视频 URL */
|
||||
url: string;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频播放器内容 - 独立组件,仅在弹窗可见时挂载,避免预加载
|
||||
*/
|
||||
const VideoPlayerContent: React.FC<{ url: string; onClose: () => void }> = ({ url, onClose }) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const player = useVideoPlayer(url, (p) => {
|
||||
p.loop = false;
|
||||
p.play();
|
||||
});
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
player.pause();
|
||||
onClose();
|
||||
}, [player, onClose]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar hidden />
|
||||
|
||||
{/* 顶部关闭按钮 */}
|
||||
<View style={[styles.header, { paddingTop: insets.top + spacing.md }]}>
|
||||
<TouchableOpacity style={styles.closeButton} onPress={handleClose}>
|
||||
<MaterialCommunityIcons name="close" size={24} color="#FFF" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.title}>视频</Text>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
|
||||
{/* 视频播放区域 */}
|
||||
<View style={styles.videoContainer}>
|
||||
<VideoView
|
||||
player={player}
|
||||
style={styles.video}
|
||||
contentFit="contain"
|
||||
nativeControls
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 视频播放弹窗 - 仅在可见时渲染播放器,避免提前加载视频资源
|
||||
*/
|
||||
export const VideoPlayerModal: React.FC<VideoPlayerModalProps> = ({
|
||||
visible,
|
||||
url,
|
||||
onClose,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={false}
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
statusBarTranslucent
|
||||
supportedOrientations={['portrait', 'landscape']}
|
||||
>
|
||||
{visible && url ? (
|
||||
<VideoPlayerContent url={url} onClose={onClose} />
|
||||
) : (
|
||||
<View style={styles.container} />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingBottom: spacing.md,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
},
|
||||
closeButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
color: '#FFF',
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
placeholder: {
|
||||
width: 40,
|
||||
},
|
||||
videoContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
video: {
|
||||
width: SCREEN_WIDTH,
|
||||
height: SCREEN_HEIGHT,
|
||||
},
|
||||
});
|
||||
|
||||
export default VideoPlayerModal;
|
||||
33
src/components/common/index.ts
Normal file
33
src/components/common/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 通用组件导出
|
||||
*/
|
||||
|
||||
export { default as Avatar } from './Avatar';
|
||||
export { default as Button } from './Button';
|
||||
export { default as Card } from './Card';
|
||||
export { default as Input } from './Input';
|
||||
export { default as Loading } from './Loading';
|
||||
export { default as Text } from './Text';
|
||||
export { default as EmptyState } from './EmptyState';
|
||||
export { default as Divider } from './Divider';
|
||||
export { default as ResponsiveContainer } from './ResponsiveContainer';
|
||||
|
||||
// 响应式布局组件
|
||||
export { default as ResponsiveGrid } from './ResponsiveGrid';
|
||||
export { default as ResponsiveStack, HStack, VStack } from './ResponsiveStack';
|
||||
export { default as AdaptiveLayout, SidebarLayout } from './AdaptiveLayout';
|
||||
|
||||
// 图片相关组件
|
||||
export { default as SmartImage } from './SmartImage';
|
||||
export { default as ImageGrid, CompactImageGrid } from './ImageGrid';
|
||||
export { default as ImageGallery } from './ImageGallery';
|
||||
|
||||
// 类型导出
|
||||
export type { SmartImageProps, ImageLoadState, ImageSource } from './SmartImage';
|
||||
export type { ImageGridProps, ImageGridItem, GridLayoutMode, CompactImageGridProps } from './ImageGrid';
|
||||
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';
|
||||
38
src/hooks/index.ts
Normal file
38
src/hooks/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Hooks 导出
|
||||
*/
|
||||
|
||||
// 响应式相关 Hooks
|
||||
export {
|
||||
useResponsive,
|
||||
BREAKPOINTS,
|
||||
FINE_BREAKPOINTS,
|
||||
useResponsiveValue,
|
||||
useResponsiveStyle,
|
||||
useBreakpointGTE,
|
||||
useBreakpointLT,
|
||||
useBreakpointBetween,
|
||||
usePlatform,
|
||||
useMediaQuery,
|
||||
useColumnCount,
|
||||
useResponsiveSpacing,
|
||||
isBreakpointGTE,
|
||||
isBreakpointLT,
|
||||
} from './useResponsive';
|
||||
export type {
|
||||
ResponsiveInfo,
|
||||
BreakpointKey,
|
||||
BreakpointValue,
|
||||
FineBreakpointKey,
|
||||
ResponsiveValue,
|
||||
} from './useResponsive';
|
||||
|
||||
export {
|
||||
usePrefetch,
|
||||
prefetchPosts,
|
||||
prefetchConversations,
|
||||
prefetchUserInfo,
|
||||
prefetchOnAppLaunch,
|
||||
prefetchMessageScreen,
|
||||
prefetchHomeScreen,
|
||||
} from './usePrefetch';
|
||||
363
src/hooks/usePrefetch.ts
Normal file
363
src/hooks/usePrefetch.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* 数据预取Hook
|
||||
* 用于关键数据的预加载,提升用户体验
|
||||
*
|
||||
* 特性:
|
||||
* - 应用启动时预取关键数据
|
||||
* - 页面导航时预取相关数据
|
||||
* - 智能预取策略(基于用户行为)
|
||||
* - 并发控制和优先级管理
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { postManager } from '../stores/postManager';
|
||||
import { groupManager } from '../stores/groupManager';
|
||||
import { userManager } from '../stores/userManager';
|
||||
import { messageManager } from '../stores/messageManager';
|
||||
|
||||
// ==================== 预取配置 ====================
|
||||
|
||||
/** 预取任务优先级 */
|
||||
enum Priority {
|
||||
HIGH = 0,
|
||||
MEDIUM = 1,
|
||||
LOW = 2,
|
||||
}
|
||||
|
||||
/** 预取任务接口 */
|
||||
interface PrefetchTask {
|
||||
key: string;
|
||||
executor: () => Promise<any>;
|
||||
priority: Priority;
|
||||
}
|
||||
|
||||
/** 预取服务类 */
|
||||
class PrefetchService {
|
||||
/** 预取任务队列 */
|
||||
private queue: PrefetchTask[] = [];
|
||||
|
||||
/** 是否正在处理队列 */
|
||||
private isProcessing = false;
|
||||
|
||||
/** 最大并发数 */
|
||||
private readonly MAX_CONCURRENCY = 3;
|
||||
|
||||
/** 当前活跃的任务数 */
|
||||
private activeCount = 0;
|
||||
|
||||
/** 是否启用调试日志 */
|
||||
private readonly DEBUG = __DEV__ || false;
|
||||
|
||||
/**
|
||||
* 添加预取任务到队列
|
||||
* @param task 预取任务
|
||||
*/
|
||||
schedule(task: PrefetchTask): void {
|
||||
// 检查是否已有相同key的任务
|
||||
const existingIndex = this.queue.findIndex(t => t.key === task.key);
|
||||
if (existingIndex >= 0) {
|
||||
// 如果新任务优先级更高,替换旧任务
|
||||
if (task.priority < this.queue[existingIndex].priority) {
|
||||
this.queue[existingIndex] = task;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 按优先级插入队列
|
||||
let insertIndex = this.queue.findIndex(t => t.priority > task.priority);
|
||||
if (insertIndex === -1) {
|
||||
insertIndex = this.queue.length;
|
||||
}
|
||||
this.queue.splice(insertIndex, 0, task);
|
||||
|
||||
if (this.DEBUG) {
|
||||
console.log(`[Prefetch] 添加任务: ${task.key}, 优先级: ${Priority[task.priority]}`);
|
||||
}
|
||||
|
||||
this.processQueue();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理预取队列
|
||||
*/
|
||||
private async processQueue(): Promise<void> {
|
||||
if (this.isProcessing || this.queue.length === 0) return;
|
||||
|
||||
this.isProcessing = true;
|
||||
|
||||
while (this.queue.length > 0 && this.activeCount < this.MAX_CONCURRENCY) {
|
||||
const task = this.queue.shift();
|
||||
if (!task) break;
|
||||
|
||||
this.activeCount++;
|
||||
|
||||
// 异步执行任务
|
||||
this.executeTask(task).finally(() => {
|
||||
this.activeCount--;
|
||||
this.processQueue();
|
||||
});
|
||||
}
|
||||
|
||||
this.isProcessing = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行单个预取任务
|
||||
*/
|
||||
private async executeTask(task: PrefetchTask): Promise<void> {
|
||||
try {
|
||||
if (this.DEBUG) {
|
||||
console.log(`[Prefetch] 执行任务: ${task.key}`);
|
||||
}
|
||||
await task.executor();
|
||||
if (this.DEBUG) {
|
||||
console.log(`[Prefetch] 任务完成: ${task.key}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (this.DEBUG) {
|
||||
console.warn(`[Prefetch] 任务失败: ${task.key}`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空预取队列
|
||||
*/
|
||||
clearQueue(): void {
|
||||
this.queue = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取队列长度
|
||||
*/
|
||||
getQueueLength(): number {
|
||||
return this.queue.length;
|
||||
}
|
||||
}
|
||||
|
||||
/** 全局预取服务实例 */
|
||||
const prefetchService = new PrefetchService();
|
||||
|
||||
// ==================== 预取函数 ====================
|
||||
|
||||
/**
|
||||
* 预取帖子数据
|
||||
* @param types 帖子类型数组
|
||||
*/
|
||||
function prefetchPosts(types: string[] = ['recommend', 'hot']): void {
|
||||
types.forEach((type, index) => {
|
||||
prefetchService.schedule({
|
||||
key: `posts:${type}:1`,
|
||||
executor: () => postManager.getPosts(type, 1, 20),
|
||||
priority: index === 0 ? Priority.HIGH : Priority.MEDIUM,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 预取会话数据
|
||||
*/
|
||||
function prefetchConversations(): void {
|
||||
prefetchService.schedule({
|
||||
key: 'conversations:list',
|
||||
executor: async () => {
|
||||
await messageManager.initialize();
|
||||
await messageManager.fetchConversations(true);
|
||||
return messageManager.getConversations();
|
||||
},
|
||||
priority: Priority.HIGH,
|
||||
});
|
||||
|
||||
// 同时预取未读数
|
||||
prefetchService.schedule({
|
||||
key: 'conversations:unread',
|
||||
executor: async () => {
|
||||
await messageManager.initialize();
|
||||
await messageManager.fetchUnreadCount();
|
||||
return messageManager.getUnreadCount();
|
||||
},
|
||||
priority: Priority.HIGH,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 预取用户信息
|
||||
*/
|
||||
function prefetchUserInfo(): void {
|
||||
prefetchService.schedule({
|
||||
key: 'users:me',
|
||||
executor: () => userManager.getCurrentUser(),
|
||||
priority: Priority.HIGH,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 预取会话详情
|
||||
* @param conversationIds 会话ID数组
|
||||
*/
|
||||
function prefetchConversationDetails(conversationIds: string[]): void {
|
||||
conversationIds.forEach(id => {
|
||||
prefetchService.schedule({
|
||||
key: `conversations:detail:${id}`,
|
||||
executor: async () => {
|
||||
await messageManager.initialize();
|
||||
return messageManager.fetchConversationDetail(id);
|
||||
},
|
||||
priority: Priority.LOW,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 预取群组成员
|
||||
* @param groupIds 群组ID数组
|
||||
*/
|
||||
function prefetchGroupMembers(groupIds: string[]): void {
|
||||
groupIds.forEach(id => {
|
||||
prefetchService.schedule({
|
||||
key: `groups:members:${id}`,
|
||||
executor: async () => {
|
||||
const response = await groupManager.getMembers(id, 1, 50);
|
||||
return response.list || [];
|
||||
},
|
||||
priority: Priority.LOW,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用启动时预取
|
||||
* 预取最关键的数据
|
||||
*/
|
||||
function prefetchOnAppLaunch(): void {
|
||||
if (__DEV__) {
|
||||
console.log('[Prefetch] 开始应用启动预取');
|
||||
}
|
||||
|
||||
// 高优先级:用户信息
|
||||
prefetchUserInfo();
|
||||
|
||||
// 高优先级:会话列表和未读数
|
||||
prefetchConversations();
|
||||
|
||||
// 中优先级:帖子列表
|
||||
prefetchPosts(['recommend']);
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入消息页面时预取
|
||||
*/
|
||||
function prefetchMessageScreen(): void {
|
||||
// 获取当前会话列表,预取前几个会话的详情
|
||||
const conversations = messageManager.getConversations();
|
||||
const topConversationIds = conversations.slice(0, 5).map((c: any) => c.id);
|
||||
|
||||
// 预取会话详情
|
||||
prefetchConversationDetails(topConversationIds);
|
||||
|
||||
// 预取群组成员(如果是群聊)
|
||||
const groupIds = conversations
|
||||
.filter((c: any) => c.type === 'group' && c.group?.id)
|
||||
.slice(0, 3)
|
||||
.map((c: any) => String(c.group!.id));
|
||||
|
||||
prefetchGroupMembers(groupIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 进入首页时预取
|
||||
*/
|
||||
function prefetchHomeScreen(): void {
|
||||
prefetchPosts(['recommend', 'hot', 'latest']);
|
||||
}
|
||||
|
||||
// ==================== React Hook ====================
|
||||
|
||||
/**
|
||||
* 数据预取Hook
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const { prefetchOnAppLaunch, prefetchMessageScreen } = usePrefetch();
|
||||
*
|
||||
* useEffect(() => {
|
||||
* prefetchOnAppLaunch();
|
||||
* }, []);
|
||||
* ```
|
||||
*/
|
||||
export function usePrefetch() {
|
||||
/** 是否已完成初始预取 */
|
||||
const hasInitialPrefetched = useRef(false);
|
||||
|
||||
/** 预取帖子 */
|
||||
const prefetchPostsData = useCallback((types?: string[]) => {
|
||||
prefetchPosts(types);
|
||||
}, []);
|
||||
|
||||
/** 预取会话 */
|
||||
const prefetchConversationsData = useCallback(() => {
|
||||
prefetchConversations();
|
||||
}, []);
|
||||
|
||||
/** 预取用户信息 */
|
||||
const prefetchUserData = useCallback(() => {
|
||||
prefetchUserInfo();
|
||||
}, []);
|
||||
|
||||
/** 应用启动预取 */
|
||||
const doPrefetchOnAppLaunch = useCallback(() => {
|
||||
if (hasInitialPrefetched.current) return;
|
||||
hasInitialPrefetched.current = true;
|
||||
prefetchOnAppLaunch();
|
||||
}, []);
|
||||
|
||||
/** 消息页面预取 */
|
||||
const doPrefetchMessageScreen = useCallback(() => {
|
||||
prefetchMessageScreen();
|
||||
}, []);
|
||||
|
||||
/** 首页预取 */
|
||||
const doPrefetchHomeScreen = useCallback(() => {
|
||||
prefetchHomeScreen();
|
||||
}, []);
|
||||
|
||||
/** 清空预取队列 */
|
||||
const clearPrefetchQueue = useCallback(() => {
|
||||
prefetchService.clearQueue();
|
||||
}, []);
|
||||
|
||||
/** 获取队列长度 */
|
||||
const getQueueLength = useCallback(() => {
|
||||
return prefetchService.getQueueLength();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
// 基础预取方法
|
||||
prefetchPosts: prefetchPostsData,
|
||||
prefetchConversations: prefetchConversationsData,
|
||||
prefetchUserInfo: prefetchUserData,
|
||||
|
||||
// 场景预取方法
|
||||
prefetchOnAppLaunch: doPrefetchOnAppLaunch,
|
||||
prefetchMessageScreen: doPrefetchMessageScreen,
|
||||
prefetchHomeScreen: doPrefetchHomeScreen,
|
||||
|
||||
// 工具方法
|
||||
clearPrefetchQueue,
|
||||
getQueueLength,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 导出 ====================
|
||||
|
||||
export {
|
||||
prefetchPosts,
|
||||
prefetchConversations,
|
||||
prefetchUserInfo,
|
||||
prefetchOnAppLaunch,
|
||||
prefetchMessageScreen,
|
||||
prefetchHomeScreen,
|
||||
prefetchService,
|
||||
};
|
||||
|
||||
export default usePrefetch;
|
||||
485
src/hooks/useResponsive.ts
Normal file
485
src/hooks/useResponsive.ts
Normal file
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* 响应式设计 Hook
|
||||
* 提供屏幕尺寸、断点、方向、平台检测等响应式信息
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { Dimensions, ScaledSize, Platform } from 'react-native';
|
||||
|
||||
// ==================== 断点定义 ====================
|
||||
export const BREAKPOINTS = {
|
||||
mobile: 0, // 手机
|
||||
tablet: 768, // 平板竖屏
|
||||
desktop: 1024, // 平板横屏/桌面
|
||||
wide: 1440, // 宽屏桌面
|
||||
} as const;
|
||||
|
||||
export type BreakpointKey = keyof typeof BREAKPOINTS;
|
||||
export type BreakpointValue = typeof BREAKPOINTS[BreakpointKey];
|
||||
|
||||
// ==================== 更细粒度的断点定义 ====================
|
||||
export const FINE_BREAKPOINTS = {
|
||||
xs: 0, // 超小屏手机
|
||||
sm: 375, // 小屏手机 (iPhone SE, 小屏安卓)
|
||||
md: 414, // 中屏手机 (iPhone Pro Max, 大屏安卓)
|
||||
lg: 768, // 平板竖屏 / 大折叠屏手机展开
|
||||
xl: 1024, // 平板横屏 / 小桌面
|
||||
'2xl': 1280, // 桌面
|
||||
'3xl': 1440, // 大桌面
|
||||
'4xl': 1920, // 超大屏
|
||||
} as const;
|
||||
|
||||
export type FineBreakpointKey = keyof typeof FINE_BREAKPOINTS;
|
||||
|
||||
// ==================== 响应式值类型 ====================
|
||||
export type ResponsiveValue<T> = T | Partial<Record<FineBreakpointKey, T>>;
|
||||
|
||||
// ==================== 返回值类型 ====================
|
||||
export interface ResponsiveInfo {
|
||||
// 基础尺寸
|
||||
width: number;
|
||||
height: number;
|
||||
|
||||
// 基础断点
|
||||
breakpoint: BreakpointKey;
|
||||
isMobile: boolean;
|
||||
isTablet: boolean;
|
||||
isDesktop: boolean;
|
||||
isWide: boolean;
|
||||
isWideScreen: boolean;
|
||||
|
||||
// 细粒度断点
|
||||
fineBreakpoint: FineBreakpointKey;
|
||||
isXS: boolean;
|
||||
isSM: boolean;
|
||||
isMD: boolean;
|
||||
isLG: boolean;
|
||||
isXL: boolean;
|
||||
is2XL: boolean;
|
||||
is3XL: boolean;
|
||||
is4XL: boolean;
|
||||
|
||||
// 方向
|
||||
orientation: 'portrait' | 'landscape';
|
||||
isPortrait: boolean;
|
||||
isLandscape: boolean;
|
||||
|
||||
// 平台检测
|
||||
platform: {
|
||||
OS: 'ios' | 'android' | 'windows' | 'macos' | 'web';
|
||||
isWeb: boolean;
|
||||
isIOS: boolean;
|
||||
isAndroid: boolean;
|
||||
isNative: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 获取当前断点 ====================
|
||||
function getBreakpoint(width: number): BreakpointKey {
|
||||
if (width >= BREAKPOINTS.wide) {
|
||||
return 'wide';
|
||||
}
|
||||
if (width >= BREAKPOINTS.desktop) {
|
||||
return 'desktop';
|
||||
}
|
||||
if (width >= BREAKPOINTS.tablet) {
|
||||
return 'tablet';
|
||||
}
|
||||
return 'mobile';
|
||||
}
|
||||
|
||||
// ==================== 获取细粒度断点 ====================
|
||||
function getFineBreakpoint(width: number): FineBreakpointKey {
|
||||
if (width >= FINE_BREAKPOINTS['4xl']) return '4xl';
|
||||
if (width >= FINE_BREAKPOINTS['3xl']) return '3xl';
|
||||
if (width >= FINE_BREAKPOINTS['2xl']) return '2xl';
|
||||
if (width >= FINE_BREAKPOINTS.xl) return 'xl';
|
||||
if (width >= FINE_BREAKPOINTS.lg) return 'lg';
|
||||
if (width >= FINE_BREAKPOINTS.md) return 'md';
|
||||
if (width >= FINE_BREAKPOINTS.sm) return 'sm';
|
||||
return 'xs';
|
||||
}
|
||||
|
||||
// ==================== 获取屏幕方向 ====================
|
||||
function getOrientation(width: number, height: number): 'portrait' | 'landscape' {
|
||||
return width > height ? 'landscape' : 'portrait';
|
||||
}
|
||||
|
||||
// ==================== useWindowDimensions Hook ====================
|
||||
/**
|
||||
* 获取窗口尺寸,支持屏幕旋转响应
|
||||
* 替代 Dimensions.get('window'),提供实时尺寸更新
|
||||
*/
|
||||
function useWindowDimensions(): ScaledSize {
|
||||
const [dimensions, setDimensions] = useState(() => Dimensions.get('window'));
|
||||
|
||||
useEffect(() => {
|
||||
const subscription = Dimensions.addEventListener('change', ({ window }) => {
|
||||
setDimensions(window);
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return dimensions;
|
||||
}
|
||||
|
||||
// ==================== useResponsive Hook ====================
|
||||
/**
|
||||
* 响应式设计 Hook
|
||||
* 提供屏幕尺寸、断点、方向、平台检测等响应式信息
|
||||
*
|
||||
* @returns ResponsiveInfo 响应式信息对象
|
||||
*
|
||||
* @example
|
||||
* const {
|
||||
* width, height,
|
||||
* breakpoint, isMobile, isTablet, isDesktop, isWide,
|
||||
* fineBreakpoint, isXS, isSM, isMD, isLG, isXL,
|
||||
* orientation, isPortrait, isLandscape,
|
||||
* platform: { isWeb, isIOS, isAndroid }
|
||||
* } = useResponsive();
|
||||
*/
|
||||
export function useResponsive(): ResponsiveInfo {
|
||||
const windowDimensions = useWindowDimensions();
|
||||
const { width, height } = windowDimensions;
|
||||
|
||||
const breakpoint = getBreakpoint(width);
|
||||
const fineBreakpoint = getFineBreakpoint(width);
|
||||
const orientation = getOrientation(width, height);
|
||||
|
||||
const isMobile = breakpoint === 'mobile';
|
||||
const isTablet = breakpoint === 'tablet';
|
||||
const isDesktop = breakpoint === 'desktop';
|
||||
const isWide = breakpoint === 'wide';
|
||||
const isWideScreen = isTablet || isDesktop || isWide;
|
||||
|
||||
const isXS = fineBreakpoint === 'xs';
|
||||
const isSM = fineBreakpoint === 'sm';
|
||||
const isMD = fineBreakpoint === 'md';
|
||||
const isLG = fineBreakpoint === 'lg';
|
||||
const isXL = fineBreakpoint === 'xl';
|
||||
const is2XL = fineBreakpoint === '2xl';
|
||||
const is3XL = fineBreakpoint === '3xl';
|
||||
const is4XL = fineBreakpoint === '4xl';
|
||||
|
||||
const isPortrait = orientation === 'portrait';
|
||||
const isLandscape = orientation === 'landscape';
|
||||
|
||||
const platform = useMemo(() => ({
|
||||
OS: Platform.OS,
|
||||
isWeb: Platform.OS === 'web',
|
||||
isIOS: Platform.OS === 'ios',
|
||||
isAndroid: Platform.OS === 'android',
|
||||
isNative: Platform.OS !== 'web',
|
||||
}), []);
|
||||
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
breakpoint,
|
||||
isMobile,
|
||||
isTablet,
|
||||
isDesktop,
|
||||
isWide,
|
||||
isWideScreen,
|
||||
fineBreakpoint,
|
||||
isXS,
|
||||
isSM,
|
||||
isMD,
|
||||
isLG,
|
||||
isXL,
|
||||
is2XL,
|
||||
is3XL,
|
||||
is4XL,
|
||||
orientation,
|
||||
isPortrait,
|
||||
isLandscape,
|
||||
platform,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== 响应式值选择器 ====================
|
||||
/**
|
||||
* 根据当前断点从响应式值对象中选择合适的值
|
||||
*
|
||||
* @param value - 响应式值,可以是单一值或断点映射对象
|
||||
* @param currentBreakpoint - 当前细粒度断点
|
||||
* @returns 选中的值
|
||||
*
|
||||
* @example
|
||||
* const padding = useResponsiveValue({ xs: 8, md: 16, lg: 24 });
|
||||
* // 在 xs 屏幕返回 8,md 屏幕返回 16,lg 及以上返回 24
|
||||
*/
|
||||
export function useResponsiveValue<T>(value: ResponsiveValue<T>): T {
|
||||
const { fineBreakpoint } = useResponsive();
|
||||
|
||||
return useMemo(() => {
|
||||
// 如果不是对象,直接返回
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
return value as T;
|
||||
}
|
||||
|
||||
const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs'];
|
||||
const valueMap = value as Partial<Record<FineBreakpointKey, T>>;
|
||||
|
||||
// 从当前断点开始向下查找
|
||||
const currentIndex = breakpointOrder.indexOf(fineBreakpoint);
|
||||
for (let i = currentIndex; i < breakpointOrder.length; i++) {
|
||||
const bp = breakpointOrder[i];
|
||||
if (bp in valueMap) {
|
||||
return valueMap[bp]!;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没找到,返回 xs 的值或第一个值
|
||||
return (valueMap.xs ?? Object.values(valueMap)[0]) as T;
|
||||
}, [value, fineBreakpoint]);
|
||||
}
|
||||
|
||||
// ==================== 响应式样式生成器 ====================
|
||||
/**
|
||||
* 根据断点生成响应式样式
|
||||
*
|
||||
* @param styles - 响应式样式对象
|
||||
* @returns 当前断点对应的样式
|
||||
*
|
||||
* @example
|
||||
* const containerStyle = useResponsiveStyle({
|
||||
* padding: { xs: 8, md: 16, lg: 24 },
|
||||
* fontSize: { xs: 14, lg: 16 }
|
||||
* });
|
||||
*/
|
||||
export function useResponsiveStyle<T extends Record<string, ResponsiveValue<unknown>>>(
|
||||
styles: T
|
||||
): { [K in keyof T]: T[K] extends ResponsiveValue<infer V> ? V : never } {
|
||||
const { fineBreakpoint } = useResponsive();
|
||||
|
||||
return useMemo(() => {
|
||||
const result = {} as { [K in keyof T]: T[K] extends ResponsiveValue<infer V> ? V : never };
|
||||
|
||||
for (const key in styles) {
|
||||
const value = styles[key];
|
||||
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
||||
(result as Record<string, unknown>)[key] = value;
|
||||
} else {
|
||||
const valueMap = value as Partial<Record<FineBreakpointKey, unknown>>;
|
||||
const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs'];
|
||||
const currentIndex = breakpointOrder.indexOf(fineBreakpoint);
|
||||
|
||||
let selectedValue: unknown = undefined;
|
||||
for (let i = currentIndex; i < breakpointOrder.length; i++) {
|
||||
const bp = breakpointOrder[i];
|
||||
if (bp in valueMap) {
|
||||
selectedValue = valueMap[bp];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
(result as Record<string, unknown>)[key] = selectedValue ?? valueMap.xs ?? Object.values(valueMap)[0];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [styles, fineBreakpoint]);
|
||||
}
|
||||
|
||||
// ==================== 断点比较工具 ====================
|
||||
/**
|
||||
* 检查当前断点是否大于等于目标断点
|
||||
*
|
||||
* @param current - 当前断点
|
||||
* @param target - 目标断点
|
||||
* @returns 是否满足条件
|
||||
*/
|
||||
export function isBreakpointGTE(
|
||||
current: FineBreakpointKey,
|
||||
target: FineBreakpointKey
|
||||
): boolean {
|
||||
const order: FineBreakpointKey[] = ['xs', 'sm', 'md', 'lg', 'xl', '2xl', '3xl', '4xl'];
|
||||
return order.indexOf(current) >= order.indexOf(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查当前断点是否小于目标断点
|
||||
*
|
||||
* @param current - 当前断点
|
||||
* @param target - 目标断点
|
||||
* @returns 是否满足条件
|
||||
*/
|
||||
export function isBreakpointLT(
|
||||
current: FineBreakpointKey,
|
||||
target: FineBreakpointKey
|
||||
): boolean {
|
||||
return !isBreakpointGTE(current, target);
|
||||
}
|
||||
|
||||
// ==================== 断点范围检查 Hook ====================
|
||||
/**
|
||||
* 检查当前是否在指定断点范围内
|
||||
*
|
||||
* @param options - 断点范围选项
|
||||
* @returns 是否在范围内
|
||||
*
|
||||
* @example
|
||||
* const isMediumUp = useBreakpointGTE('md');
|
||||
* const isMobileOnly = useBreakpointLT('lg');
|
||||
*/
|
||||
export function useBreakpointGTE(target: FineBreakpointKey): boolean {
|
||||
const { fineBreakpoint } = useResponsive();
|
||||
return isBreakpointGTE(fineBreakpoint, target);
|
||||
}
|
||||
|
||||
export function useBreakpointLT(target: FineBreakpointKey): boolean {
|
||||
const { fineBreakpoint } = useResponsive();
|
||||
return isBreakpointLT(fineBreakpoint, target);
|
||||
}
|
||||
|
||||
export function useBreakpointBetween(
|
||||
min: FineBreakpointKey,
|
||||
max: FineBreakpointKey
|
||||
): boolean {
|
||||
const { fineBreakpoint } = useResponsive();
|
||||
return isBreakpointGTE(fineBreakpoint, min) && !isBreakpointGTE(fineBreakpoint, max);
|
||||
}
|
||||
|
||||
// ==================== 平台检测 Hook ====================
|
||||
/**
|
||||
* 平台检测 Hook
|
||||
* 提供便捷的平台检测方法
|
||||
*
|
||||
* @example
|
||||
* const { isWeb, isIOS, isAndroid, isNative } = usePlatform();
|
||||
*/
|
||||
export function usePlatform() {
|
||||
const { platform } = useResponsive();
|
||||
return platform;
|
||||
}
|
||||
|
||||
// ==================== 媒体查询模拟 ====================
|
||||
/**
|
||||
* 模拟 CSS 媒体查询
|
||||
*
|
||||
* @param query - 查询条件
|
||||
* @returns 是否匹配
|
||||
*
|
||||
* @example
|
||||
* const isMinWidth768 = useMediaQuery({ minWidth: 768 });
|
||||
* const isMaxWidth1024 = useMediaQuery({ maxWidth: 1024 });
|
||||
* const isPortrait = useMediaQuery({ orientation: 'portrait' });
|
||||
*/
|
||||
interface MediaQueryOptions {
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
orientation?: 'portrait' | 'landscape';
|
||||
}
|
||||
|
||||
export function useMediaQuery(query: MediaQueryOptions): boolean {
|
||||
const { width, height, orientation: currentOrientation } = useResponsive();
|
||||
|
||||
return useMemo(() => {
|
||||
if (query.minWidth !== undefined && width < query.minWidth) return false;
|
||||
if (query.maxWidth !== undefined && width > query.maxWidth) return false;
|
||||
if (query.minHeight !== undefined && height < query.minHeight) return false;
|
||||
if (query.maxHeight !== undefined && height > query.maxHeight) return false;
|
||||
if (query.orientation !== undefined && currentOrientation !== query.orientation) return false;
|
||||
return true;
|
||||
}, [width, height, currentOrientation, query]);
|
||||
}
|
||||
|
||||
// ==================== 列数计算工具 ====================
|
||||
/**
|
||||
* 根据容器宽度计算合适的列数
|
||||
*
|
||||
* @param containerWidth - 容器宽度
|
||||
* @param options - 配置选项
|
||||
* @returns 列数
|
||||
*
|
||||
* @example
|
||||
* const columns = useColumnCount({
|
||||
* xs: 1,
|
||||
* sm: 2,
|
||||
* md: 3,
|
||||
* lg: 4
|
||||
* });
|
||||
*/
|
||||
export function useColumnCount(
|
||||
columnConfig: Partial<Record<FineBreakpointKey, number>> = {}
|
||||
): number {
|
||||
const { fineBreakpoint } = useResponsive();
|
||||
|
||||
const defaultConfig: Record<FineBreakpointKey, number> = {
|
||||
xs: 1,
|
||||
sm: 1,
|
||||
md: 2,
|
||||
lg: 3,
|
||||
xl: 4,
|
||||
'2xl': 4,
|
||||
'3xl': 5,
|
||||
'4xl': 6,
|
||||
};
|
||||
|
||||
return useMemo(() => {
|
||||
const config = { ...defaultConfig, ...columnConfig };
|
||||
const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs'];
|
||||
const currentIndex = breakpointOrder.indexOf(fineBreakpoint);
|
||||
|
||||
for (let i = currentIndex; i < breakpointOrder.length; i++) {
|
||||
const bp = breakpointOrder[i];
|
||||
if (config[bp] !== undefined) {
|
||||
return config[bp];
|
||||
}
|
||||
}
|
||||
|
||||
return 1;
|
||||
}, [fineBreakpoint, columnConfig]);
|
||||
}
|
||||
|
||||
// ==================== 间距计算工具 ====================
|
||||
/**
|
||||
* 响应式间距 Hook
|
||||
*
|
||||
* @param spacingConfig - 间距配置
|
||||
* @returns 当前断点对应的间距值
|
||||
*
|
||||
* @example
|
||||
* const gap = useResponsiveSpacing({ xs: 8, md: 16, lg: 24 });
|
||||
*/
|
||||
export function useResponsiveSpacing(
|
||||
spacingConfig: Partial<Record<FineBreakpointKey, number>> = {}
|
||||
): number {
|
||||
const { fineBreakpoint } = useResponsive();
|
||||
|
||||
const defaultConfig: Record<FineBreakpointKey, number> = {
|
||||
xs: 8,
|
||||
sm: 8,
|
||||
md: 12,
|
||||
lg: 16,
|
||||
xl: 20,
|
||||
'2xl': 24,
|
||||
'3xl': 32,
|
||||
'4xl': 40,
|
||||
};
|
||||
|
||||
return useMemo(() => {
|
||||
const config = { ...defaultConfig, ...spacingConfig };
|
||||
const breakpointOrder: FineBreakpointKey[] = ['4xl', '3xl', '2xl', 'xl', 'lg', 'md', 'sm', 'xs'];
|
||||
const currentIndex = breakpointOrder.indexOf(fineBreakpoint);
|
||||
|
||||
for (let i = currentIndex; i < breakpointOrder.length; i++) {
|
||||
const bp = breakpointOrder[i];
|
||||
if (config[bp] !== undefined) {
|
||||
return config[bp];
|
||||
}
|
||||
}
|
||||
|
||||
return defaultConfig.xs;
|
||||
}, [fineBreakpoint, spacingConfig]);
|
||||
}
|
||||
|
||||
export default useResponsive;
|
||||
1039
src/navigation/MainNavigator.tsx
Normal file
1039
src/navigation/MainNavigator.tsx
Normal file
File diff suppressed because it is too large
Load Diff
110
src/navigation/types.ts
Normal file
110
src/navigation/types.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* 导航类型定义
|
||||
* 定义所有导航栈的类型参数
|
||||
*/
|
||||
|
||||
import { NavigatorScreenParams } from '@react-navigation/native';
|
||||
import type { SystemMessageResponse } from '../types/dto';
|
||||
|
||||
// ==================== 主Tab导航参数 ====================
|
||||
export type MainTabParamList = {
|
||||
HomeTab: NavigatorScreenParams<HomeStackParamList>;
|
||||
MessageTab: NavigatorScreenParams<MessageStackParamList>;
|
||||
ProfileTab: NavigatorScreenParams<ProfileStackParamList>;
|
||||
};
|
||||
|
||||
// ==================== 首页Stack ====================
|
||||
export type HomeStackParamList = {
|
||||
Home: undefined;
|
||||
PostDetail: { postId: string; scrollToComments?: boolean };
|
||||
Search: undefined;
|
||||
UserProfile: { userId: string };
|
||||
FollowList: { userId: string; type: 'following' | 'followers' };
|
||||
};
|
||||
|
||||
// ==================== 消息Stack ====================
|
||||
export type MessageStackParamList = {
|
||||
MessageList: undefined;
|
||||
Chat: {
|
||||
conversationId: string;
|
||||
userId?: string;
|
||||
isGroupChat?: boolean;
|
||||
groupId?: number;
|
||||
groupName?: string;
|
||||
};
|
||||
Notifications: undefined;
|
||||
CreateGroup: undefined;
|
||||
JoinGroup: undefined;
|
||||
GroupInfo: { groupId: number; conversationId?: string };
|
||||
GroupMembers: { groupId: number };
|
||||
GroupRequestDetail: { message: SystemMessageResponse };
|
||||
GroupInviteDetail: { message: SystemMessageResponse };
|
||||
PrivateChatInfo: {
|
||||
conversationId: string;
|
||||
userId: string;
|
||||
userName?: string;
|
||||
userAvatar?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
// ==================== 个人中心Stack ====================
|
||||
export type ProfileStackParamList = {
|
||||
Profile: undefined;
|
||||
Settings: undefined;
|
||||
EditProfile: undefined;
|
||||
AccountSecurity: undefined;
|
||||
MyPosts: undefined;
|
||||
Bookmarks: undefined;
|
||||
NotificationSettings: undefined;
|
||||
BlockedUsers: undefined;
|
||||
};
|
||||
|
||||
// ==================== 认证Stack ====================
|
||||
export type AuthStackParamList = {
|
||||
Login: undefined;
|
||||
Register: undefined;
|
||||
ForgotPassword: undefined;
|
||||
};
|
||||
|
||||
// ==================== 根导航 ====================
|
||||
export type RootStackParamList = {
|
||||
Main: NavigatorScreenParams<MainTabParamList>;
|
||||
Auth: undefined;
|
||||
PostDetail: { postId: string; scrollToComments?: boolean };
|
||||
UserProfile: { userId: string };
|
||||
CreatePost: undefined;
|
||||
Chat: {
|
||||
conversationId: string;
|
||||
userId?: string;
|
||||
isGroupChat?: boolean;
|
||||
groupId?: number;
|
||||
groupName?: string;
|
||||
};
|
||||
FollowList: { userId: string; type: 'following' | 'followers' };
|
||||
CreateGroup: undefined;
|
||||
JoinGroup: undefined;
|
||||
GroupInfo: { groupId: number; conversationId?: string };
|
||||
GroupMembers: { groupId: number };
|
||||
GroupRequestDetail: { message: SystemMessageResponse };
|
||||
GroupInviteDetail: { message: SystemMessageResponse };
|
||||
PrivateChatInfo: {
|
||||
conversationId: string;
|
||||
userId: string;
|
||||
userName?: string;
|
||||
userAvatar?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
// ==================== 全局类型声明 ====================
|
||||
declare global {
|
||||
namespace ReactNavigation {
|
||||
interface RootParamList extends RootStackParamList {}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 屏幕名称类型(用于路由)====================
|
||||
export type HomeScreenNames = keyof HomeStackParamList;
|
||||
export type MessageScreenNames = keyof MessageStackParamList;
|
||||
export type ProfileScreenNames = keyof ProfileStackParamList;
|
||||
export type MainTabScreenNames = keyof MainTabParamList;
|
||||
export type RootScreenNames = keyof RootStackParamList;
|
||||
308
src/screens/auth/ForgotPasswordScreen.tsx
Normal file
308
src/screens/auth/ForgotPasswordScreen.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { authService, resolveAuthApiError } from '../../services/authService';
|
||||
import { showPrompt } from '../../services/promptService';
|
||||
|
||||
type ForgotPasswordNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>;
|
||||
|
||||
export const ForgotPasswordScreen: React.FC = () => {
|
||||
const navigation = useNavigation<ForgotPasswordNavigationProp>();
|
||||
const [email, setEmail] = useState('');
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) {
|
||||
return;
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => (prev <= 1 ? 0 : prev - 1));
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [countdown]);
|
||||
|
||||
const validateEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
|
||||
const handleSendCode = async () => {
|
||||
if (!validateEmail(email.trim())) {
|
||||
showPrompt({ title: '提示', message: '请输入正确的邮箱地址', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (countdown > 0) {
|
||||
return;
|
||||
}
|
||||
setSendingCode(true);
|
||||
try {
|
||||
const ok = await authService.sendPasswordResetCode(email.trim());
|
||||
if (ok) {
|
||||
setCountdown(60);
|
||||
showPrompt({ title: '发送成功', message: '验证码已发送,请查收邮箱', type: 'success' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
showPrompt({ title: '发送失败', message: resolveAuthApiError(error, '验证码发送失败'), type: 'error' });
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
if (!validateEmail(email.trim())) {
|
||||
showPrompt({ title: '提示', message: '请输入正确的邮箱地址', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (!verificationCode.trim()) {
|
||||
showPrompt({ title: '提示', message: '请输入验证码', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
showPrompt({ title: '提示', message: '新密码至少 6 位', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
showPrompt({ title: '提示', message: '两次输入的密码不一致', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const ok = await authService.resetPassword({
|
||||
email: email.trim(),
|
||||
verification_code: verificationCode.trim(),
|
||||
new_password: newPassword,
|
||||
});
|
||||
if (ok) {
|
||||
showPrompt({ title: '重置成功', message: '密码已重置,请重新登录', type: 'success' });
|
||||
navigation.goBack();
|
||||
}
|
||||
} catch (error: any) {
|
||||
showPrompt({ title: '重置失败', message: resolveAuthApiError(error, '请稍后重试'), type: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<LinearGradient colors={['#FF6B35', '#FF8F66', '#FFB088']} style={styles.gradient}>
|
||||
<KeyboardAvoidingView behavior={Platform.OS === 'ios' ? 'padding' : 'height'} style={styles.keyboardView}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent} keyboardShouldPersistTaps="handled">
|
||||
<View style={styles.formCard}>
|
||||
<Text style={styles.title}>找回密码</Text>
|
||||
<Text style={styles.subtitle}>请输入邮箱并完成验证码验证</Text>
|
||||
|
||||
<View style={styles.inputWrapper}>
|
||||
<MaterialCommunityIcons name="email-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="邮箱"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.codeRow}>
|
||||
<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="验证码"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={verificationCode}
|
||||
onChangeText={setVerificationCode}
|
||||
keyboardType="number-pad"
|
||||
maxLength={6}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.sendCodeButton, (sendingCode || countdown > 0) && styles.sendCodeButtonDisabled]}
|
||||
onPress={handleSendCode}
|
||||
disabled={sendingCode || countdown > 0}
|
||||
>
|
||||
{sendingCode ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.sendCodeButtonText}>{countdown > 0 ? `${countdown}s` : '发送验证码'}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputWrapper}>
|
||||
<MaterialCommunityIcons name="lock-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="新密码(至少6位)"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={newPassword}
|
||||
onChangeText={setNewPassword}
|
||||
secureTextEntry={!showPassword}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<TouchableOpacity onPress={() => setShowPassword((v) => !v)}>
|
||||
<MaterialCommunityIcons name={showPassword ? 'eye-off-outline' : 'eye-outline'} size={20} color={colors.text.hint} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputWrapper}>
|
||||
<MaterialCommunityIcons name="lock-check-outline" size={22} color={colors.primary.main} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="确认新密码"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
secureTextEntry={!showConfirmPassword}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<TouchableOpacity onPress={() => setShowConfirmPassword((v) => !v)}>
|
||||
<MaterialCommunityIcons name={showConfirmPassword ? 'eye-off-outline' : 'eye-outline'} size={20} color={colors.text.hint} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.submitButton, loading && styles.submitButtonDisabled]}
|
||||
onPress={handleResetPassword}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <ActivityIndicator size="small" color="#fff" /> : <Text style={styles.submitButtonText}>重置密码</Text>}
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity style={styles.backButton} onPress={() => navigation.goBack()}>
|
||||
<Text style={styles.backButtonText}>返回登录</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</KeyboardAvoidingView>
|
||||
</LinearGradient>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = 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 default ForgotPasswordScreen;
|
||||
559
src/screens/auth/LoginScreen.tsx
Normal file
559
src/screens/auth/LoginScreen.tsx
Normal file
@@ -0,0 +1,559 @@
|
||||
/**
|
||||
* 登录页 LoginScreen(响应式适配)
|
||||
* 胡萝卜BBS - 用户登录
|
||||
* 现代化设计 - 渐变背景 + 动画效果
|
||||
* 登录表单在桌面端居中显示,限制最大宽度
|
||||
* 支持横屏模式下的布局调整
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme';
|
||||
import { useAuthStore } from '../../stores';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { useResponsive, useResponsiveValue } from '../../hooks';
|
||||
import { ResponsiveContainer } from '../../components/common';
|
||||
import { showPrompt } from '../../services/promptService';
|
||||
|
||||
type LoginNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>;
|
||||
|
||||
export const LoginScreen: React.FC = () => {
|
||||
const navigation = useNavigation<LoginNavigationProp>();
|
||||
const login = useAuthStore((state) => state.login);
|
||||
const storeError = useAuthStore((state) => state.error);
|
||||
const setStoreError = useAuthStore((state) => state.setError);
|
||||
|
||||
// 响应式布局
|
||||
const { isWideScreen, isLandscape, isDesktop, width } = useResponsive();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
// 动画值
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const slideAnim = useRef(new Animated.Value(50)).current;
|
||||
const scaleAnim = useRef(new Animated.Value(0.9)).current;
|
||||
const inputFadeAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
// 响应式值
|
||||
const logoSize = useResponsiveValue({ xs: 64, sm: 72, md: 80, lg: 96, xl: 110 });
|
||||
const logoContainerSize = useResponsiveValue({ xs: 110, sm: 120, md: 130, lg: 150, xl: 170 });
|
||||
const appNameSize = useResponsiveValue({ xs: 28, sm: 30, md: 32, lg: 36, xl: 40 });
|
||||
const formMaxWidth = isDesktop ? 480 : isWideScreen ? 420 : undefined;
|
||||
|
||||
// 启动入场动画
|
||||
useEffect(() => {
|
||||
Animated.sequence([
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
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,
|
||||
}),
|
||||
]).start();
|
||||
}, []);
|
||||
|
||||
// 表单验证
|
||||
const validateForm = (): boolean => {
|
||||
if (!username.trim()) {
|
||||
showPrompt({ title: '提示', message: '请输入用户名', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (!password) {
|
||||
showPrompt({ title: '提示', message: '请输入密码', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
showPrompt({ title: '提示', message: '密码长度不能少于6位', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// store error 同步到本地,方便在输入时清除
|
||||
useEffect(() => {
|
||||
if (storeError) {
|
||||
setErrorMsg(storeError);
|
||||
}
|
||||
}, [storeError]);
|
||||
|
||||
const clearError = () => {
|
||||
setErrorMsg(null);
|
||||
setStoreError(null);
|
||||
};
|
||||
|
||||
// 处理登录
|
||||
const handleLogin = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
clearError();
|
||||
setLoading(true);
|
||||
try {
|
||||
const success = await login({ username, password });
|
||||
if (!success) {
|
||||
// authStore 已将错误写入 storeError,useEffect 会同步到 errorMsg
|
||||
// 若 storeError 为空则显示通用提示
|
||||
if (!useAuthStore.getState().error) {
|
||||
setErrorMsg('登录失败,请稍后重试');
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
setErrorMsg(error.message || '登录失败,请检查用户名和密码');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 跳转到注册页
|
||||
const handleGoToRegister = () => {
|
||||
navigation.navigate('Register' as any);
|
||||
};
|
||||
|
||||
// 渲染表单内容
|
||||
const renderFormContent = () => (
|
||||
<>
|
||||
{/* Logo和标题区域 */}
|
||||
<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>
|
||||
|
||||
{/* 表单卡片 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.formCard,
|
||||
formMaxWidth && { maxWidth: formMaxWidth, alignSelf: 'center', width: '100%' },
|
||||
{
|
||||
opacity: inputFadeAnim,
|
||||
transform: [{ translateY: slideAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text style={styles.formTitle}>欢迎回来</Text>
|
||||
|
||||
{/* 用户名输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="account-outline"
|
||||
size={22}
|
||||
color={colors.primary.main}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
]}
|
||||
placeholder="用户名 / 邮箱 / 手机号"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={username}
|
||||
onChangeText={(v) => { setUsername(v); clearError(); }}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="next"
|
||||
/>
|
||||
{username.length > 0 && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setUsername('')}
|
||||
style={styles.clearButton}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="close-circle"
|
||||
size={20}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 密码输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="lock-outline"
|
||||
size={22}
|
||||
color={colors.primary.main}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
]}
|
||||
placeholder="请输入密码"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={password}
|
||||
onChangeText={(v) => { setPassword(v); clearError(); }}
|
||||
secureTextEntry={!showPassword}
|
||||
autoCapitalize="none"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleLogin}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowPassword(!showPassword)}
|
||||
style={styles.clearButton}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
|
||||
size={20}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 忘记密码 */}
|
||||
<TouchableOpacity style={styles.forgotPassword} onPress={() => navigation.navigate('ForgotPassword' as any)}>
|
||||
<Text style={styles.forgotPasswordText}>忘记密码?</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 内联错误提示 */}
|
||||
{errorMsg && (
|
||||
<View style={styles.errorBox}>
|
||||
<MaterialCommunityIcons
|
||||
name="alert-circle-outline"
|
||||
size={16}
|
||||
color={colors.error?.main ?? '#D32F2F'}
|
||||
style={{ marginRight: 6, marginTop: 1 }}
|
||||
/>
|
||||
<Text style={styles.errorText}>{errorMsg}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<TouchableOpacity
|
||||
style={[styles.loginButton, loading && styles.loginButtonDisabled]}
|
||||
onPress={handleLogin}
|
||||
disabled={loading}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#FF6B35', '#FF8F66']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[
|
||||
styles.loginButtonGradient,
|
||||
isWideScreen && styles.loginButtonGradientWide,
|
||||
]}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#FFF" />
|
||||
) : (
|
||||
<Text style={styles.loginButtonText}>登 录</Text>
|
||||
)}
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* 底部注册提示 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.footerSection,
|
||||
{ opacity: inputFadeAnim },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.footerText}>还没有账号?</Text>
|
||||
<TouchableOpacity onPress={handleGoToRegister}>
|
||||
<Text style={styles.registerLink}>立即注册</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={['#FF6B35', '#FF8F66', '#FFB088']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradient}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.keyboardView}
|
||||
>
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={600}>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
isLandscape && styles.scrollContentLandscape,
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* 装饰性背景元素 */}
|
||||
<View style={styles.decorCircle1} />
|
||||
<View style={styles.decorCircle2} />
|
||||
|
||||
{renderFormContent()}
|
||||
</ScrollView>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
isLandscape && styles.scrollContentLandscape,
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* 装饰性背景元素 */}
|
||||
<View style={styles.decorCircle1} />
|
||||
<View style={styles.decorCircle2} />
|
||||
|
||||
{renderFormContent()}
|
||||
</ScrollView>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
</LinearGradient>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
gradient: {
|
||||
flex: 1,
|
||||
},
|
||||
keyboardView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing['2xl'],
|
||||
},
|
||||
scrollContentLandscape: {
|
||||
paddingVertical: spacing.lg,
|
||||
},
|
||||
// 装饰性背景元素
|
||||
decorCircle1: {
|
||||
position: 'absolute',
|
||||
top: -100,
|
||||
right: -100,
|
||||
width: 300,
|
||||
height: 300,
|
||||
borderRadius: 150,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
decorCircle2: {
|
||||
position: 'absolute',
|
||||
bottom: 100,
|
||||
left: -150,
|
||||
width: 250,
|
||||
height: 250,
|
||||
borderRadius: 125,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
// 头部区域
|
||||
headerSection: {
|
||||
alignItems: 'center',
|
||||
marginTop: spacing['3xl'],
|
||||
marginBottom: spacing['3xl'],
|
||||
},
|
||||
headerSectionLandscape: {
|
||||
marginTop: spacing.lg,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
logoContainer: {
|
||||
backgroundColor: 'rgba(255,255,255,0.25)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(255,255,255,0.4)',
|
||||
...shadows.md,
|
||||
},
|
||||
appName: {
|
||||
fontWeight: '800',
|
||||
color: '#FFFFFF',
|
||||
marginBottom: spacing.sm,
|
||||
textShadowColor: 'rgba(0,0,0,0.1)',
|
||||
textShadowOffset: { width: 0, height: 2 },
|
||||
textShadowRadius: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: fontSizes.md,
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
textAlign: 'center',
|
||||
paddingHorizontal: spacing.xl,
|
||||
},
|
||||
// 表单卡片
|
||||
formCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius['2xl'],
|
||||
padding: spacing['2xl'],
|
||||
marginBottom: spacing.xl,
|
||||
...shadows.md,
|
||||
},
|
||||
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: borderRadius.lg,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.divider,
|
||||
paddingHorizontal: spacing.md,
|
||||
height: 56,
|
||||
},
|
||||
inputWrapperWide: {
|
||||
height: 60,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
inputWide: {
|
||||
fontSize: fontSizes.lg,
|
||||
},
|
||||
clearButton: {
|
||||
padding: spacing.xs,
|
||||
},
|
||||
forgotPassword: {
|
||||
alignSelf: 'flex-end',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
forgotPasswordText: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.primary.main,
|
||||
fontWeight: '500',
|
||||
},
|
||||
errorBox: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
backgroundColor: '#FFEBEE',
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
marginBottom: spacing.md,
|
||||
borderLeftWidth: 3,
|
||||
borderLeftColor: '#D32F2F',
|
||||
},
|
||||
errorText: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.sm,
|
||||
color: '#D32F2F',
|
||||
lineHeight: 20,
|
||||
},
|
||||
loginButton: {
|
||||
borderRadius: borderRadius.lg,
|
||||
overflow: 'hidden',
|
||||
...shadows.md,
|
||||
},
|
||||
loginButtonGradient: {
|
||||
height: 52,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
loginButtonGradientWide: {
|
||||
height: 56,
|
||||
},
|
||||
loginButtonDisabled: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
loginButtonText: {
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
// 底部区域
|
||||
footerSection: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: 'auto',
|
||||
paddingTop: spacing.xl,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: fontSizes.md,
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
},
|
||||
registerLink: {
|
||||
fontSize: fontSizes.md,
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '700',
|
||||
marginLeft: spacing.xs,
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
});
|
||||
|
||||
export default LoginScreen;
|
||||
843
src/screens/auth/RegisterScreen.tsx
Normal file
843
src/screens/auth/RegisterScreen.tsx
Normal file
@@ -0,0 +1,843 @@
|
||||
/**
|
||||
* 注册页 RegisterScreen(响应式适配)
|
||||
* 胡萝卜BBS - 用户注册
|
||||
* 现代化设计 - 渐变背景 + 动画效果
|
||||
* 注册表单在桌面端居中显示,限制最大宽度
|
||||
* 支持横屏模式下的布局调整
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
ScrollView,
|
||||
ActivityIndicator,
|
||||
Animated,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { colors, spacing, borderRadius, shadows, fontSizes } from '../../theme';
|
||||
import { authService, resolveAuthApiError } from '../../services/authService';
|
||||
import { useAuthStore } from '../../stores';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { useResponsive, useResponsiveValue } from '../../hooks';
|
||||
import { ResponsiveContainer } from '../../components/common';
|
||||
import { showPrompt } from '../../services/promptService';
|
||||
|
||||
type RegisterNavigationProp = NativeStackNavigationProp<RootStackParamList, 'Auth'>;
|
||||
|
||||
export const RegisterScreen: React.FC = () => {
|
||||
const navigation = useNavigation<RegisterNavigationProp>();
|
||||
const register = useAuthStore((state) => state.register);
|
||||
|
||||
// 响应式布局
|
||||
const { isWideScreen, isLandscape, isDesktop } = useResponsive();
|
||||
|
||||
const [username, setUsername] = useState('');
|
||||
const [nickname, setNickname] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [agreedToTerms, setAgreedToTerms] = useState(true);
|
||||
|
||||
// 动画值
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
const slideAnim = useRef(new Animated.Value(50)).current;
|
||||
const scaleAnim = useRef(new Animated.Value(0.95)).current;
|
||||
const inputFadeAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
// 响应式值
|
||||
const iconSize = useResponsiveValue({ xs: 48, sm: 52, md: 56, lg: 64, xl: 72 });
|
||||
const iconContainerSize = useResponsiveValue({ xs: 80, sm: 88, md: 96, lg: 108, xl: 120 });
|
||||
const titleSize = useResponsiveValue({ xs: 24, sm: 26, md: 28, lg: 30, xl: 32 });
|
||||
const formMaxWidth = isDesktop ? 520 : isWideScreen ? 480 : undefined;
|
||||
|
||||
// 启动入场动画
|
||||
useEffect(() => {
|
||||
Animated.sequence([
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 500,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(scaleAnim, {
|
||||
toValue: 1,
|
||||
duration: 500,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]),
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: 400,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(inputFadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 300,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) {
|
||||
return;
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => (prev <= 1 ? 0 : prev - 1));
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [countdown]);
|
||||
|
||||
// 表单验证
|
||||
const validateForm = (): boolean => {
|
||||
if (!username.trim()) {
|
||||
showPrompt({ title: '提示', message: '请输入用户名', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (username.length < 3 || username.length > 20) {
|
||||
showPrompt({ title: '提示', message: '用户名长度需在3-20个字符之间', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(username)) {
|
||||
showPrompt({ title: '提示', message: '用户名只能包含字母、数字和下划线', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (!nickname.trim()) {
|
||||
showPrompt({ title: '提示', message: '请输入昵称', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (nickname.length < 2 || nickname.length > 20) {
|
||||
showPrompt({ title: '提示', message: '昵称长度需在2-20个字符之间', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (!email.trim()) {
|
||||
showPrompt({ title: '提示', message: '请输入邮箱', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
showPrompt({ title: '提示', message: '请输入正确的邮箱地址', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (!verificationCode.trim()) {
|
||||
showPrompt({ title: '提示', message: '请输入邮箱验证码', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
// 手机号验证(如果填写了)
|
||||
if (phone && !/^1[3-9]\d{9}$/.test(phone)) {
|
||||
showPrompt({ title: '提示', message: '请输入正确的手机号', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (!password) {
|
||||
showPrompt({ title: '提示', message: '请输入密码', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (password.length < 6) {
|
||||
showPrompt({ title: '提示', message: '密码长度不能少于6位', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
showPrompt({ title: '提示', message: '两次输入的密码不一致', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
if (!agreedToTerms) {
|
||||
showPrompt({ title: '提示', message: '请同意用户协议和隐私政策', type: 'warning' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleSendCode = async () => {
|
||||
if (!email.trim()) {
|
||||
showPrompt({ title: '提示', message: '请先输入邮箱', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
showPrompt({ title: '提示', message: '请输入正确的邮箱地址', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (countdown > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSendingCode(true);
|
||||
try {
|
||||
const ok = await authService.sendRegisterCode(email.trim());
|
||||
if (ok) {
|
||||
setCountdown(60);
|
||||
showPrompt({ title: '发送成功', message: '验证码已发送,请查收邮箱', type: 'success' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
showPrompt({ title: '发送失败', message: resolveAuthApiError(error, '验证码发送失败,请稍后重试'), type: 'error' });
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理注册
|
||||
const handleRegister = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const success = await register({
|
||||
username,
|
||||
password,
|
||||
nickname,
|
||||
email: email.trim(),
|
||||
verification_code: verificationCode.trim(),
|
||||
phone: phone || undefined,
|
||||
});
|
||||
if (success) {
|
||||
// 注册成功,导航会自动切换到主页
|
||||
} else {
|
||||
const msg = useAuthStore.getState().error || '注册失败,请稍后重试';
|
||||
showPrompt({ title: '注册失败', message: msg, type: 'error' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
showPrompt({ title: '注册失败', message: resolveAuthApiError(error, '请稍后重试'), type: 'error' });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 跳转到登录页
|
||||
const handleGoToLogin = () => {
|
||||
navigation.goBack();
|
||||
};
|
||||
|
||||
// 渲染表单内容
|
||||
const renderFormContent = () => (
|
||||
<>
|
||||
{/* 标题区域 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.headerSection,
|
||||
isLandscape && styles.headerSectionLandscape,
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
transform: [{ scale: scaleAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[
|
||||
styles.iconContainer,
|
||||
{ width: iconContainerSize, height: iconContainerSize, borderRadius: iconContainerSize / 2 }
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="account-plus"
|
||||
size={iconSize}
|
||||
color="#FFF"
|
||||
/>
|
||||
</View>
|
||||
<Text style={[styles.title, { fontSize: titleSize }]}>创建账号</Text>
|
||||
<Text style={styles.subtitle}>加入胡萝卜,开始你的旅程</Text>
|
||||
</Animated.View>
|
||||
|
||||
{/* 表单卡片 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.formCard,
|
||||
formMaxWidth && { maxWidth: formMaxWidth, alignSelf: 'center', width: '100%' },
|
||||
{
|
||||
opacity: inputFadeAnim,
|
||||
transform: [{ translateY: slideAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* 用户名输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="account-outline"
|
||||
size={22}
|
||||
color={colors.primary.main}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
]}
|
||||
placeholder="用户名(3-20个字符)"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={username}
|
||||
onChangeText={setUsername}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
maxLength={20}
|
||||
/>
|
||||
{username.length > 0 && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setUsername('')}
|
||||
style={styles.clearButton}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="close-circle"
|
||||
size={20}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 昵称输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="emoticon-outline"
|
||||
size={22}
|
||||
color={colors.primary.main}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
]}
|
||||
placeholder="昵称(2-20个字符)"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={nickname}
|
||||
onChangeText={setNickname}
|
||||
maxLength={20}
|
||||
/>
|
||||
{nickname.length > 0 && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setNickname('')}
|
||||
style={styles.clearButton}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="close-circle"
|
||||
size={20}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 邮箱输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="email-outline"
|
||||
size={22}
|
||||
color={colors.primary.main}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
]}
|
||||
placeholder="邮箱(必填)"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
{email.length > 0 && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setEmail('')}
|
||||
style={styles.clearButton}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="close-circle"
|
||||
size={20}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 邮箱验证码 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={styles.codeRow}>
|
||||
<View style={[styles.inputWrapper, styles.codeInputWrapper, isWideScreen && styles.inputWrapperWide]}>
|
||||
<MaterialCommunityIcons
|
||||
name="shield-key-outline"
|
||||
size={22}
|
||||
color={colors.primary.main}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[styles.input, isWideScreen && styles.inputWide]}
|
||||
placeholder="邮箱验证码"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={verificationCode}
|
||||
onChangeText={setVerificationCode}
|
||||
keyboardType="number-pad"
|
||||
maxLength={6}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.sendCodeButton,
|
||||
(sendingCode || countdown > 0) && styles.sendCodeButtonDisabled,
|
||||
]}
|
||||
onPress={handleSendCode}
|
||||
disabled={sendingCode || countdown > 0}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={styles.sendCodeButtonText}>
|
||||
{sendingCode ? '发送中...' : countdown > 0 ? `${countdown}s` : '发送验证码'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 手机号输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="phone-outline"
|
||||
size={22}
|
||||
color={colors.primary.main}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
]}
|
||||
placeholder="手机号(选填)"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={phone}
|
||||
onChangeText={setPhone}
|
||||
keyboardType="phone-pad"
|
||||
maxLength={11}
|
||||
/>
|
||||
{phone.length > 0 && (
|
||||
<TouchableOpacity
|
||||
onPress={() => setPhone('')}
|
||||
style={styles.clearButton}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="close-circle"
|
||||
size={20}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 密码输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="lock-outline"
|
||||
size={22}
|
||||
color={colors.primary.main}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
]}
|
||||
placeholder="密码(至少6位)"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry={!showPassword}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowPassword(!showPassword)}
|
||||
style={styles.clearButton}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={showPassword ? 'eye-off-outline' : 'eye-outline'}
|
||||
size={20}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 确认密码输入框 */}
|
||||
<View style={styles.inputContainer}>
|
||||
<View style={[
|
||||
styles.inputWrapper,
|
||||
isWideScreen && styles.inputWrapperWide,
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name="lock-check-outline"
|
||||
size={22}
|
||||
color={colors.primary.main}
|
||||
style={styles.inputIcon}
|
||||
/>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
isWideScreen && styles.inputWide,
|
||||
]}
|
||||
placeholder="确认密码"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
secureTextEntry={!showConfirmPassword}
|
||||
autoCapitalize="none"
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleRegister}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
onPress={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||
style={styles.clearButton}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={showConfirmPassword ? 'eye-off-outline' : 'eye-outline'}
|
||||
size={20}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 服务条款 */}
|
||||
<TouchableOpacity
|
||||
style={styles.termsContainer}
|
||||
onPress={() => setAgreedToTerms(!agreedToTerms)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={[styles.checkbox, agreedToTerms && styles.checkboxChecked]}>
|
||||
<MaterialCommunityIcons
|
||||
name={agreedToTerms ? "checkbox-marked" : "checkbox-blank-outline"}
|
||||
size={24}
|
||||
color={agreedToTerms ? colors.primary.main : colors.text.hint}
|
||||
/>
|
||||
</View>
|
||||
<Text style={styles.termsText}>
|
||||
我已阅读并同意
|
||||
<Text style={styles.termsLink}>《用户协议》</Text>
|
||||
和
|
||||
<Text style={styles.termsLink}>《隐私政策》</Text>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 注册按钮 */}
|
||||
<TouchableOpacity
|
||||
style={[styles.registerButton, loading && styles.registerButtonDisabled]}
|
||||
onPress={handleRegister}
|
||||
disabled={loading}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={['#FF6B35', '#FF8F66']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 0 }}
|
||||
style={[
|
||||
styles.registerButtonGradient,
|
||||
isWideScreen && styles.registerButtonGradientWide,
|
||||
]}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator size="small" color="#FFF" />
|
||||
) : (
|
||||
<Text style={styles.registerButtonText}>注 册</Text>
|
||||
)}
|
||||
</LinearGradient>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
{/* 底部登录提示 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.footerSection,
|
||||
{ opacity: inputFadeAnim },
|
||||
]}
|
||||
>
|
||||
<Text style={styles.footerText}>已有账号?</Text>
|
||||
<TouchableOpacity onPress={handleGoToLogin}>
|
||||
<Text style={styles.loginLink}>立即登录</Text>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<LinearGradient
|
||||
colors={['#FF6B35', '#FF8F66', '#FFB088']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradient}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.keyboardView}
|
||||
>
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={600}>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
isLandscape && styles.scrollContentLandscape,
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* 装饰性背景元素 */}
|
||||
<View style={styles.decorCircle1} />
|
||||
<View style={styles.decorCircle2} />
|
||||
|
||||
{renderFormContent()}
|
||||
</ScrollView>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
isLandscape && styles.scrollContentLandscape,
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* 装饰性背景元素 */}
|
||||
<View style={styles.decorCircle1} />
|
||||
<View style={styles.decorCircle2} />
|
||||
|
||||
{renderFormContent()}
|
||||
</ScrollView>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
</LinearGradient>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
gradient: {
|
||||
flex: 1,
|
||||
},
|
||||
keyboardView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.xl,
|
||||
},
|
||||
scrollContentLandscape: {
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
// 装饰性背景元素
|
||||
decorCircle1: {
|
||||
position: 'absolute',
|
||||
top: -80,
|
||||
right: -80,
|
||||
width: 250,
|
||||
height: 250,
|
||||
borderRadius: 125,
|
||||
backgroundColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
decorCircle2: {
|
||||
position: 'absolute',
|
||||
bottom: 150,
|
||||
left: -120,
|
||||
width: 200,
|
||||
height: 200,
|
||||
borderRadius: 100,
|
||||
backgroundColor: 'rgba(255,255,255,0.08)',
|
||||
},
|
||||
// 头部区域
|
||||
headerSection: {
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xl,
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
headerSectionLandscape: {
|
||||
marginTop: spacing.md,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
iconContainer: {
|
||||
backgroundColor: 'rgba(255,255,255,0.25)',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.md,
|
||||
borderWidth: 2,
|
||||
borderColor: 'rgba(255,255,255,0.4)',
|
||||
...shadows.md,
|
||||
},
|
||||
title: {
|
||||
fontWeight: '800',
|
||||
color: '#FFFFFF',
|
||||
marginBottom: spacing.xs,
|
||||
textShadowColor: 'rgba(0,0,0,0.1)',
|
||||
textShadowOffset: { width: 0, height: 2 },
|
||||
textShadowRadius: 4,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: fontSizes.md,
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
},
|
||||
// 表单卡片
|
||||
formCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius['2xl'],
|
||||
padding: spacing.xl,
|
||||
marginBottom: spacing.lg,
|
||||
...shadows.md,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
codeRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1.5,
|
||||
borderColor: colors.divider,
|
||||
paddingHorizontal: spacing.md,
|
||||
height: 52,
|
||||
},
|
||||
inputWrapperWide: {
|
||||
height: 56,
|
||||
},
|
||||
codeInputWrapper: {
|
||||
flex: 1,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
inputWide: {
|
||||
fontSize: fontSizes.lg,
|
||||
},
|
||||
clearButton: {
|
||||
padding: spacing.xs,
|
||||
},
|
||||
sendCodeButton: {
|
||||
height: 52,
|
||||
minWidth: 110,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.primary.main,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
sendCodeButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
sendCodeButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: fontSizes.sm,
|
||||
fontWeight: '600',
|
||||
},
|
||||
// 服务条款
|
||||
termsContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
checkbox: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
checkboxChecked: {
|
||||
// 选中状态的额外样式
|
||||
},
|
||||
termsText: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.secondary,
|
||||
flex: 1,
|
||||
lineHeight: 20,
|
||||
},
|
||||
termsLink: {
|
||||
color: colors.primary.main,
|
||||
fontWeight: '500',
|
||||
},
|
||||
// 注册按钮
|
||||
registerButton: {
|
||||
borderRadius: borderRadius.lg,
|
||||
overflow: 'hidden',
|
||||
...shadows.md,
|
||||
},
|
||||
registerButtonGradient: {
|
||||
height: 50,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
registerButtonGradientWide: {
|
||||
height: 54,
|
||||
},
|
||||
registerButtonDisabled: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
registerButtonText: {
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '700',
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
// 底部区域
|
||||
footerSection: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginTop: 'auto',
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.md,
|
||||
},
|
||||
footerText: {
|
||||
fontSize: fontSizes.md,
|
||||
color: 'rgba(255,255,255,0.9)',
|
||||
},
|
||||
loginLink: {
|
||||
fontSize: fontSizes.md,
|
||||
color: '#FFFFFF',
|
||||
fontWeight: '700',
|
||||
marginLeft: spacing.xs,
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
});
|
||||
|
||||
export default RegisterScreen;
|
||||
8
src/screens/auth/index.ts
Normal file
8
src/screens/auth/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* 认证屏幕导出
|
||||
* 胡萝卜BBS - 登录注册模块
|
||||
*/
|
||||
|
||||
export { LoginScreen } from './LoginScreen';
|
||||
export { RegisterScreen } from './RegisterScreen';
|
||||
export { ForgotPasswordScreen } from './ForgotPasswordScreen';
|
||||
939
src/screens/create/CreatePostScreen.tsx
Normal file
939
src/screens/create/CreatePostScreen.tsx
Normal file
@@ -0,0 +1,939 @@
|
||||
/**
|
||||
* 发帖页 CreatePostScreen(响应式适配)
|
||||
* 胡萝卜BBS - 发布新帖子
|
||||
* 参考微博发帖界面设计
|
||||
* 表单在宽屏下居中显示
|
||||
* 图片选择器在宽屏下显示更大的预览
|
||||
* 投票编辑器在宽屏下优化布局
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Animated,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
|
||||
import { Text, Button, ResponsiveContainer } from '../../components/common';
|
||||
import { postService, showPrompt, voteService } from '../../services';
|
||||
import { ApiError } from '../../services/api';
|
||||
import { uploadService } from '../../services/uploadService';
|
||||
import VoteEditor from '../../components/business/VoteEditor';
|
||||
import { useResponsive, useResponsiveValue } from '../../hooks';
|
||||
|
||||
const MAX_TITLE_LENGTH = 100;
|
||||
const MAX_CONTENT_LENGTH = 2000;
|
||||
|
||||
// 表情面板高度
|
||||
const EMOJI_PANEL_HEIGHT = 280;
|
||||
|
||||
// 常用表情列表
|
||||
const EMOJIS = [
|
||||
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂',
|
||||
'🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩',
|
||||
'😘', '😗', '😚', '😙', '🥲', '😋', '😛', '😜',
|
||||
'🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐',
|
||||
'🤨', '😐', '😑', '😶', '😏', '😒', '🙄',
|
||||
'😬', '🤥', '😌', '😔', '😪', '🤤', '😴', '😷',
|
||||
'🤒', '🤕', '🤢', '🤮', '🤧', '🥵', '🥶', '🥴',
|
||||
'😵', '🤯', '🤠', '🥳', '🥸', '😎', '🤓',
|
||||
'🧐', '😕', '😟', '🙁', '☹️', '😮', '😯', '😲',
|
||||
'😳', '🥺', '😦', '😧', '😨', '😰', '😥', '😢',
|
||||
'😭', '😱', '😖', '😣', '😞', '😓', '😩', '😫',
|
||||
'🥱', '😤', '😡', '😠', '🤬', '😈', '👿', '💀',
|
||||
'👋', '🤚', '🖐️', '✋', '🖖', '👌', '🤌', '🤏',
|
||||
'✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆',
|
||||
'👍', '👎', '✊', '👊', '🤛', '🤜', '👏', '🙌',
|
||||
'👐', '🤲', '🤝', '🙏', '✍️', '💪', '🦾', '🦵',
|
||||
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍',
|
||||
'🤎', '💔', '❤️\u200d🔥', '❤️\u200d🩹', '💕', '💞', '💓', '💗',
|
||||
'💖', '💘', '💝', '🎉', '🎊', '🎁', '🎈', '✨',
|
||||
'🔥', '💯', '💢', '💥', '💫', '💦', '💨', '🕳️',
|
||||
];
|
||||
|
||||
// 动画值
|
||||
const AnimatedTouchable = Animated.createAnimatedComponent(TouchableOpacity);
|
||||
|
||||
const getPublishErrorMessage = (error: unknown): string => {
|
||||
if (error instanceof ApiError && error.message?.trim()) {
|
||||
return error.message.trim();
|
||||
}
|
||||
return '发布失败,请重试';
|
||||
};
|
||||
|
||||
export const CreatePostScreen: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
|
||||
// 响应式布局
|
||||
const { isWideScreen, width } = useResponsive();
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [images, setImages] = useState<{ uri: string; uploading?: boolean }[]>([]);
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [showTagInput, setShowTagInput] = useState(false);
|
||||
const [showEmojiPanel, setShowEmojiPanel] = useState(false);
|
||||
const [posting, setPosting] = useState(false);
|
||||
|
||||
// 投票相关状态
|
||||
const [isVotePost, setIsVotePost] = useState(false);
|
||||
const [voteOptions, setVoteOptions] = useState<string[]>(['', '']); // 默认2个选项
|
||||
|
||||
// 内容输入框引用
|
||||
const contentInputRef = React.useRef<TextInput>(null);
|
||||
const [selection, setSelection] = useState({ start: 0, end: 0 });
|
||||
|
||||
// 动画值
|
||||
const fadeAnim = React.useRef(new Animated.Value(0)).current;
|
||||
const slideAnim = React.useRef(new Animated.Value(20)).current;
|
||||
|
||||
// 响应式图片网格配置
|
||||
const imagesPerRow = useResponsiveValue({ xs: 3, sm: 3, md: 4, lg: 5, xl: 6 });
|
||||
const imageGap = 8;
|
||||
const availableWidth = isWideScreen
|
||||
? Math.min(width, 800) - spacing.lg * 2
|
||||
: width - spacing.lg * 2;
|
||||
const imageSize = (availableWidth - imageGap * (imagesPerRow - 1)) / imagesPerRow;
|
||||
|
||||
React.useEffect(() => {
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: 250,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}, []);
|
||||
|
||||
// 选择图片
|
||||
const handlePickImage = async () => {
|
||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
|
||||
if (!permissionResult.granted) {
|
||||
Alert.alert('权限不足', '需要访问相册权限来选择图片');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: 'images',
|
||||
allowsMultipleSelection: true,
|
||||
selectionLimit: 0,
|
||||
quality: 1,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets) {
|
||||
const newImages = result.assets.map(asset => ({ uri: asset.uri, uploading: true }));
|
||||
setImages(prev => [...prev, ...newImages]);
|
||||
|
||||
// 上传图片
|
||||
for (let i = 0; i < newImages.length; i++) {
|
||||
const asset = result.assets[i];
|
||||
const uploadResult = await uploadService.uploadImage({
|
||||
uri: asset.uri,
|
||||
type: asset.mimeType || 'image/jpeg',
|
||||
});
|
||||
|
||||
if (uploadResult) {
|
||||
setImages(prev => {
|
||||
const updated = [...prev];
|
||||
const index = prev.findIndex(img => img.uri === asset.uri);
|
||||
if (index !== -1) {
|
||||
updated[index] = { uri: uploadResult.url, uploading: false };
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} else {
|
||||
Alert.alert('上传失败', '图片上传失败,请重试');
|
||||
setImages(prev => prev.filter(img => img.uri !== asset.uri));
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 拍照
|
||||
const handleTakePhoto = async () => {
|
||||
const permissionResult = await ImagePicker.requestCameraPermissionsAsync();
|
||||
|
||||
if (!permissionResult.granted) {
|
||||
Alert.alert('权限不足', '需要访问相机权限来拍照');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchCameraAsync({
|
||||
allowsEditing: true,
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
const asset = result.assets[0];
|
||||
setImages(prev => [...prev, { uri: asset.uri, uploading: true }]);
|
||||
|
||||
// 上传图片
|
||||
const uploadResult = await uploadService.uploadImage({
|
||||
uri: asset.uri,
|
||||
type: asset.mimeType || 'image/jpeg',
|
||||
});
|
||||
|
||||
if (uploadResult) {
|
||||
setImages(prev => {
|
||||
const updated = [...prev];
|
||||
const index = prev.findIndex(img => img.uri === asset.uri);
|
||||
if (index !== -1) {
|
||||
updated[index] = { uri: uploadResult.url, uploading: false };
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
} else {
|
||||
Alert.alert('上传失败', '图片上传失败,请重试');
|
||||
setImages(prev => prev.filter(img => img.uri !== asset.uri));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 删除图片
|
||||
const handleRemoveImage = (index: number) => {
|
||||
setImages(images.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
// 添加标签
|
||||
const handleAddTag = useCallback(() => {
|
||||
const tag = tagInput.trim().replace(/^#/, '');
|
||||
if (tag && !tags.includes(tag) && tags.length < 5) {
|
||||
setTags([...tags, tag]);
|
||||
setTagInput('');
|
||||
setShowTagInput(false);
|
||||
}
|
||||
}, [tagInput, tags]);
|
||||
|
||||
// 删除标签
|
||||
const handleRemoveTag = (tag: string) => {
|
||||
setTags(tags.filter(t => t !== tag));
|
||||
};
|
||||
|
||||
// 插入表情
|
||||
const handleInsertEmoji = (emoji: string) => {
|
||||
const newContent = content.slice(0, selection.start) + emoji + content.slice(selection.end);
|
||||
setContent(newContent);
|
||||
const newPosition = selection.start + emoji.length;
|
||||
setSelection({ start: newPosition, end: newPosition });
|
||||
};
|
||||
|
||||
// 关闭所有面板
|
||||
const closeAllPanels = () => {
|
||||
setShowEmojiPanel(false);
|
||||
setShowTagInput(false);
|
||||
};
|
||||
|
||||
// 切换表情面板
|
||||
const handleToggleEmojiPanel = () => {
|
||||
closeAllPanels();
|
||||
setShowEmojiPanel(!showEmojiPanel);
|
||||
};
|
||||
|
||||
// 切换标签输入
|
||||
const handleToggleTagInput = () => {
|
||||
closeAllPanels();
|
||||
setShowTagInput(!showTagInput);
|
||||
};
|
||||
|
||||
// 切换投票模式
|
||||
const handleToggleVote = () => {
|
||||
closeAllPanels();
|
||||
setIsVotePost(!isVotePost);
|
||||
};
|
||||
|
||||
// 添加投票选项
|
||||
const handleAddVoteOption = () => {
|
||||
if (voteOptions.length < 10) {
|
||||
setVoteOptions([...voteOptions, '']);
|
||||
}
|
||||
};
|
||||
|
||||
// 删除投票选项
|
||||
const handleRemoveVoteOption = (index: number) => {
|
||||
if (voteOptions.length > 2) {
|
||||
setVoteOptions(voteOptions.filter((_, i) => i !== index));
|
||||
}
|
||||
};
|
||||
|
||||
// 更新投票选项
|
||||
const handleUpdateVoteOption = (index: number, value: string) => {
|
||||
const newOptions = [...voteOptions];
|
||||
newOptions[index] = value;
|
||||
setVoteOptions(newOptions);
|
||||
};
|
||||
|
||||
// 发布帖子
|
||||
const handlePost = async () => {
|
||||
if (!content.trim()) {
|
||||
Alert.alert('错误', '请输入帖子内容');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否有图片正在上传
|
||||
const uploadingImages = images.filter(img => img.uploading);
|
||||
if (uploadingImages.length > 0) {
|
||||
Alert.alert('请稍候', '图片正在上传中,请稍后再试');
|
||||
return;
|
||||
}
|
||||
|
||||
setPosting(true);
|
||||
|
||||
try {
|
||||
const imageUrls = images.map(img => img.uri);
|
||||
|
||||
if (isVotePost) {
|
||||
// 验证投票选项
|
||||
const validOptions = voteOptions.filter(opt => opt.trim() !== '');
|
||||
if (validOptions.length < 2) {
|
||||
Alert.alert('错误', '投票选项至少需要2个');
|
||||
setPosting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 创建投票帖子
|
||||
await voteService.createVotePost({
|
||||
title: title.trim() || '无标题',
|
||||
content: content.trim(),
|
||||
images: imageUrls,
|
||||
vote_options: validOptions,
|
||||
});
|
||||
showPrompt({
|
||||
type: 'info',
|
||||
title: '审核中',
|
||||
message: '投票帖已提交,内容审核中,稍后展示',
|
||||
duration: 2600,
|
||||
});
|
||||
navigation.goBack();
|
||||
} else {
|
||||
// 创建普通帖子
|
||||
await postService.createPost({
|
||||
title: title.trim() || '无标题',
|
||||
content: content.trim(),
|
||||
images: imageUrls,
|
||||
});
|
||||
showPrompt({
|
||||
type: 'info',
|
||||
title: '审核中',
|
||||
message: '帖子已提交,内容审核中,稍后展示',
|
||||
duration: 2600,
|
||||
});
|
||||
navigation.goBack();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('发布帖子失败:', error);
|
||||
Alert.alert('错误', getPublishErrorMessage(error));
|
||||
} finally {
|
||||
setPosting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染图片网格
|
||||
const renderImageGrid = () => {
|
||||
if (images.length === 0) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.imageGrid}>
|
||||
{images.map((img, index) => (
|
||||
<Animated.View
|
||||
key={`${img.uri}-${index}`}
|
||||
style={[
|
||||
styles.imageGridItem,
|
||||
{
|
||||
width: imageSize,
|
||||
height: imageSize,
|
||||
opacity: fadeAnim,
|
||||
transform: [{ scale: fadeAnim }]
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Image source={{ uri: img.uri }} style={styles.gridImage} resizeMode="cover" />
|
||||
{img.uploading && (
|
||||
<View style={styles.uploadingOverlay}>
|
||||
<MaterialCommunityIcons name="loading" size={20} color={colors.text.inverse} />
|
||||
</View>
|
||||
)}
|
||||
<TouchableOpacity
|
||||
style={styles.removeImageButton}
|
||||
onPress={() => handleRemoveImage(index)}
|
||||
disabled={img.uploading}
|
||||
hitSlop={{ top: 10, right: 10, bottom: 10, left: 10 }}
|
||||
>
|
||||
<View style={styles.removeImageButtonInner}>
|
||||
<MaterialCommunityIcons name="close" size={12} color={colors.text.inverse} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
))}
|
||||
{/* 添加图片按钮 */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.addImageGridButton,
|
||||
{ width: imageSize, height: imageSize }
|
||||
]}
|
||||
onPress={handlePickImage}
|
||||
>
|
||||
<MaterialCommunityIcons name="plus" size={32} color={colors.text.hint} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染标签
|
||||
const renderTags = () => {
|
||||
if (tags.length === 0 && !showTagInput) return null;
|
||||
|
||||
return (
|
||||
<View style={styles.tagsSection}>
|
||||
<View style={styles.tagsContainer}>
|
||||
{tags.map((tag, index) => (
|
||||
<View key={index} style={styles.tag}>
|
||||
<MaterialCommunityIcons name="pound" size={12} color={colors.primary.main} />
|
||||
<Text variant="caption" color={colors.primary.main} style={styles.tagText}>{tag}</Text>
|
||||
{/* 独立的删除按钮 */}
|
||||
<TouchableOpacity
|
||||
style={styles.tagDeleteButton}
|
||||
onPress={() => handleRemoveTag(tag)}
|
||||
hitSlop={{ top: 5, right: 5, bottom: 5, left: 5 }}
|
||||
>
|
||||
<MaterialCommunityIcons name="close-circle" size={14} color={colors.primary.main} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
))}
|
||||
{tags.length < 5 && showTagInput && (
|
||||
<View style={styles.tagInputWrapper}>
|
||||
<MaterialCommunityIcons name="pound" size={14} color={colors.primary.main} />
|
||||
<TextInput
|
||||
style={styles.tagInput}
|
||||
value={tagInput}
|
||||
onChangeText={setTagInput}
|
||||
placeholder="输入话题"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
onSubmitEditing={handleAddTag}
|
||||
returnKeyType="done"
|
||||
autoFocus
|
||||
maxLength={20}
|
||||
/>
|
||||
<TouchableOpacity onPress={() => { setTagInput(''); setShowTagInput(false); }} style={styles.tagCancelButton}>
|
||||
<MaterialCommunityIcons name="close" size={16} color={colors.text.secondary} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity onPress={handleAddTag} style={styles.tagConfirmButton}>
|
||||
<MaterialCommunityIcons name="check" size={16} color={colors.primary.main} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
{tags.length < 5 && !showTagInput && (
|
||||
<TouchableOpacity style={styles.addTagButton} onPress={() => setShowTagInput(true)}>
|
||||
<MaterialCommunityIcons name="plus" size={14} color={colors.text.secondary} />
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.addTagText}>话题</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染投票编辑器
|
||||
const renderVoteEditor = () => {
|
||||
if (!isVotePost) return null;
|
||||
|
||||
return (
|
||||
<View style={isWideScreen && styles.voteEditorWide}>
|
||||
<VoteEditor
|
||||
options={voteOptions}
|
||||
onAddOption={handleAddVoteOption}
|
||||
onRemoveOption={handleRemoveVoteOption}
|
||||
onUpdateOption={handleUpdateVoteOption}
|
||||
maxOptions={10}
|
||||
minOptions={2}
|
||||
maxLength={50}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染内容输入区
|
||||
const renderContentSection = () => (
|
||||
<View style={styles.contentSection}>
|
||||
{/* 标题输入 */}
|
||||
<TextInput
|
||||
style={[
|
||||
styles.titleInput,
|
||||
isWideScreen && styles.titleInputWide,
|
||||
]}
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="添加标题(可选)"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
maxLength={MAX_TITLE_LENGTH}
|
||||
/>
|
||||
|
||||
{/* 正文输入 */}
|
||||
<TextInput
|
||||
ref={contentInputRef}
|
||||
style={[
|
||||
styles.contentInput,
|
||||
isWideScreen && styles.contentInputWide,
|
||||
]}
|
||||
value={content}
|
||||
onChangeText={setContent}
|
||||
placeholder="分享新鲜事..."
|
||||
placeholderTextColor={colors.text.hint}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
maxLength={MAX_CONTENT_LENGTH}
|
||||
onSelectionChange={(e) => setSelection(e.nativeEvent.selection)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 渲染表情面板
|
||||
const renderEmojiPanel = () => {
|
||||
if (!showEmojiPanel) return null;
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.emojiPanel,
|
||||
isWideScreen && styles.emojiPanelWide,
|
||||
]}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.emojiGrid}
|
||||
>
|
||||
{EMOJIS.map((emoji, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={styles.emojiItem}
|
||||
onPress={() => handleInsertEmoji(emoji)}
|
||||
>
|
||||
<Text style={styles.emojiText}>{emoji}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染底部工具栏
|
||||
const renderToolbar = () => (
|
||||
<View style={styles.toolbar}>
|
||||
{/* 左侧功能按钮组 */}
|
||||
<View style={styles.toolbarLeft}>
|
||||
<TouchableOpacity
|
||||
style={styles.toolbarButton}
|
||||
onPress={handlePickImage}
|
||||
disabled={posting}
|
||||
>
|
||||
<View style={styles.toolbarButtonInner}>
|
||||
<MaterialCommunityIcons
|
||||
name="image-outline"
|
||||
size={24}
|
||||
color={colors.text.secondary}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.toolbarButton}
|
||||
onPress={handleTakePhoto}
|
||||
disabled={posting}
|
||||
>
|
||||
<View style={styles.toolbarButtonInner}>
|
||||
<MaterialCommunityIcons
|
||||
name="camera-outline"
|
||||
size={24}
|
||||
color={colors.text.secondary}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.toolbarButton}
|
||||
onPress={handleToggleTagInput}
|
||||
disabled={tags.length >= 5 || posting}
|
||||
>
|
||||
<View style={styles.toolbarButtonInner}>
|
||||
<MaterialCommunityIcons
|
||||
name="pound"
|
||||
size={24}
|
||||
color={tags.length >= 5 ? colors.text.disabled : (showTagInput ? colors.primary.main : colors.text.secondary)}
|
||||
/>
|
||||
{tags.length > 0 && (
|
||||
<View style={styles.tagBadge}>
|
||||
<Text style={styles.tagBadgeText}>{tags.length}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.toolbarButton}
|
||||
onPress={handleToggleEmojiPanel}
|
||||
disabled={posting}
|
||||
>
|
||||
<View style={styles.toolbarButtonInner}>
|
||||
<MaterialCommunityIcons
|
||||
name={showEmojiPanel ? "emoticon-happy" : "emoticon-happy-outline"}
|
||||
size={24}
|
||||
color={showEmojiPanel ? colors.primary.main : colors.text.secondary}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 投票按钮 */}
|
||||
<TouchableOpacity
|
||||
style={styles.toolbarButton}
|
||||
onPress={handleToggleVote}
|
||||
disabled={posting}
|
||||
>
|
||||
<View style={styles.toolbarButtonInner}>
|
||||
<MaterialCommunityIcons
|
||||
name={isVotePost ? "vote" : "vote-outline"}
|
||||
size={24}
|
||||
color={isVotePost ? colors.primary.main : colors.text.secondary}
|
||||
/>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 右侧发布按钮 */}
|
||||
<View style={styles.toolbarRight}>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.charCount}>
|
||||
{content.length}/{MAX_CONTENT_LENGTH}
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.postButton,
|
||||
(!content.trim() || posting) && styles.postButtonDisabled
|
||||
]}
|
||||
onPress={handlePost}
|
||||
disabled={!content.trim() || posting}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{posting ? (
|
||||
<MaterialCommunityIcons name="loading" size={18} color={colors.primary.contrast} />
|
||||
) : (
|
||||
<Text variant="body" color={colors.primary.contrast} style={styles.postButtonText}>
|
||||
发布
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 渲染主内容
|
||||
const renderMainContent = () => (
|
||||
<>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{/* 内容输入区 */}
|
||||
{renderContentSection()}
|
||||
|
||||
{/* 投票编辑器 */}
|
||||
{renderVoteEditor()}
|
||||
|
||||
{/* 图片网格 */}
|
||||
{renderImageGrid()}
|
||||
|
||||
{/* 标签 */}
|
||||
{renderTags()}
|
||||
</ScrollView>
|
||||
|
||||
{/* 表情面板 - 位于底部工具栏上方 */}
|
||||
{renderEmojiPanel()}
|
||||
|
||||
{/* 底部工具栏 - 重新设计 */}
|
||||
{renderToolbar()}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.flex}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
|
||||
>
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={800}>
|
||||
{renderMainContent()}
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
renderMainContent()
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
flex: {
|
||||
flex: 1,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
// 内容输入区
|
||||
contentSection: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
},
|
||||
titleInput: {
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
paddingVertical: spacing.sm,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
titleInputWide: {
|
||||
fontSize: fontSizes.xl,
|
||||
},
|
||||
contentInput: {
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
minHeight: 120,
|
||||
lineHeight: 22,
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
contentInputWide: {
|
||||
fontSize: fontSizes.lg,
|
||||
lineHeight: 26,
|
||||
minHeight: 150,
|
||||
},
|
||||
// 图片网格
|
||||
imageGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.md,
|
||||
gap: 8,
|
||||
},
|
||||
imageGridItem: {
|
||||
borderRadius: borderRadius.md,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
gridImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
uploadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
removeImageButton: {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
zIndex: 10,
|
||||
},
|
||||
removeImageButtonInner: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
addImageGridButton: {
|
||||
borderRadius: borderRadius.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
borderStyle: 'dashed',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
// 标签
|
||||
tagsSection: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.lg,
|
||||
},
|
||||
tagsContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
tag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.primary.light + '15',
|
||||
borderRadius: borderRadius.full,
|
||||
paddingLeft: spacing.sm,
|
||||
paddingRight: spacing.xs,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
tagText: {
|
||||
marginLeft: spacing.xs,
|
||||
fontWeight: '500',
|
||||
},
|
||||
tagDeleteButton: {
|
||||
marginLeft: spacing.xs,
|
||||
padding: 2,
|
||||
},
|
||||
tagInputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.full,
|
||||
paddingLeft: spacing.sm,
|
||||
paddingRight: spacing.xs,
|
||||
paddingVertical: spacing.xs,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
tagInput: {
|
||||
fontSize: fontSizes.sm,
|
||||
color: colors.text.primary,
|
||||
minWidth: 80,
|
||||
marginLeft: spacing.xs,
|
||||
padding: 0,
|
||||
},
|
||||
tagCancelButton: {
|
||||
padding: spacing.xs,
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
tagConfirmButton: {
|
||||
padding: spacing.xs,
|
||||
},
|
||||
addTagButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.full,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
borderStyle: 'dashed',
|
||||
},
|
||||
addTagText: {
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
// 投票编辑器宽屏样式
|
||||
voteEditorWide: {
|
||||
maxWidth: 600,
|
||||
alignSelf: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
// 底部工具栏
|
||||
toolbar: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.divider,
|
||||
},
|
||||
toolbarLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
toolbarRight: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.md,
|
||||
},
|
||||
toolbarButton: {
|
||||
position: 'relative',
|
||||
},
|
||||
toolbarButtonInner: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: borderRadius.full,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
tagBadge: {
|
||||
position: 'absolute',
|
||||
top: 4,
|
||||
right: 4,
|
||||
minWidth: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: colors.primary.main,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
tagBadgeText: {
|
||||
color: colors.primary.contrast,
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '600',
|
||||
},
|
||||
charCount: {
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
postButton: {
|
||||
backgroundColor: colors.primary.main,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.full,
|
||||
minWidth: 64,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
postButtonDisabled: {
|
||||
backgroundColor: colors.text.disabled,
|
||||
},
|
||||
postButtonText: {
|
||||
fontWeight: '600',
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
// 表情面板
|
||||
emojiPanel: {
|
||||
height: EMOJI_PANEL_HEIGHT,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.divider,
|
||||
},
|
||||
emojiPanelWide: {
|
||||
height: 320,
|
||||
},
|
||||
emojiGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
padding: spacing.md,
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
emojiItem: {
|
||||
width: '12.5%',
|
||||
aspectRatio: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
emojiText: {
|
||||
fontSize: 24,
|
||||
},
|
||||
});
|
||||
|
||||
export default CreatePostScreen;
|
||||
5
src/screens/create/index.ts
Normal file
5
src/screens/create/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 发帖模块导出
|
||||
*/
|
||||
|
||||
export { CreatePostScreen } from './CreatePostScreen';
|
||||
767
src/screens/home/HomeScreen.tsx
Normal file
767
src/screens/home/HomeScreen.tsx
Normal file
@@ -0,0 +1,767 @@
|
||||
/**
|
||||
* 首页 HomeScreen
|
||||
* 胡萝卜BBS - 首页展示
|
||||
* 支持列表和多列网格模式(响应式布局)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
RefreshControl,
|
||||
StatusBar,
|
||||
TouchableOpacity,
|
||||
NativeScrollEvent,
|
||||
NativeSyntheticEvent,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
|
||||
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
||||
import { Post } from '../../types';
|
||||
import { useUserStore } from '../../stores';
|
||||
import { useCurrentUser } from '../../stores/authStore';
|
||||
import { postService } from '../../services';
|
||||
import { PostCard, TabBar, SearchBar } from '../../components/business';
|
||||
import { Loading, EmptyState, Text, ImageGallery, ImageGridItem, ResponsiveGrid } from '../../components/common';
|
||||
import { HomeStackParamList, RootStackParamList } from '../../navigation/types';
|
||||
import { useResponsive, useResponsiveSpacing } from '../../hooks/useResponsive';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'Home'> & NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
const TABS = ['推荐', '关注', '热门', '最新'];
|
||||
const TAB_ICONS = ['compass-outline', 'account-heart-outline', 'fire', 'clock-outline'];
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
const SCROLL_BOTTOM_THRESHOLD = 240;
|
||||
const LOAD_MORE_COOLDOWN_MS = 800;
|
||||
const SWIPE_TRANSLATION_THRESHOLD = 40;
|
||||
const SWIPE_COOLDOWN_MS = 300;
|
||||
const MOBILE_TAB_BAR_HEIGHT = 64;
|
||||
const MOBILE_TAB_FLOATING_MARGIN = 12;
|
||||
const MOBILE_FAB_GAP = 12;
|
||||
|
||||
type ViewMode = 'list' | 'grid';
|
||||
|
||||
export const HomeScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { fetchPosts, likePost, unlikePost, favoritePost, unfavoritePost, posts: storePosts } = useUserStore();
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
// 使用响应式 hook
|
||||
const {
|
||||
width,
|
||||
isMobile,
|
||||
isTablet,
|
||||
isDesktop,
|
||||
isWideScreen,
|
||||
breakpoint,
|
||||
orientation,
|
||||
isLandscape
|
||||
} = useResponsive();
|
||||
|
||||
// 响应式间距
|
||||
const responsiveGap = useResponsiveSpacing({ xs: 4, sm: 6, md: 8, lg: 12, xl: 16 });
|
||||
const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 });
|
||||
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('list');
|
||||
|
||||
// 图片查看器状态
|
||||
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||
const [postImages, setPostImages] = useState<ImageGridItem[]>([]);
|
||||
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
||||
|
||||
// 用于跟踪当前页面显示的帖子 ID,以便从 store 同步状态
|
||||
const postIdsRef = React.useRef<Set<string>>(new Set());
|
||||
const inFlightRequestKeysRef = React.useRef<Set<string>>(new Set());
|
||||
const lastLoadMoreTriggerAtRef = useRef(0);
|
||||
const lastSwipeAtRef = useRef(0);
|
||||
|
||||
// 用 ref 同步关键状态,避免 onWaterfallScroll 的陈旧闭包问题
|
||||
const pageRef = useRef(page);
|
||||
const loadingMoreRef = useRef(loadingMore);
|
||||
const hasMoreRef = useRef(hasMore);
|
||||
pageRef.current = page;
|
||||
loadingMoreRef.current = loadingMore;
|
||||
hasMoreRef.current = hasMore;
|
||||
|
||||
// 根据屏幕尺寸确定网格列数
|
||||
const gridColumns = useMemo(() => {
|
||||
if (isWideScreen || width >= 1440) return 4;
|
||||
if (isDesktop || width >= 1024) return 3;
|
||||
if (isTablet || width >= 768) return 2;
|
||||
return 2; // 移动端瀑布流保持2列
|
||||
}, [width, isTablet, isDesktop, isWideScreen]);
|
||||
|
||||
// 列表模式下始终使用单列,宽屏下居中显示
|
||||
const useMultiColumnList = useMemo(() => {
|
||||
return false; // 改为始终返回false,使用单列布局
|
||||
}, []);
|
||||
|
||||
// 宽屏下内容最大宽度
|
||||
const contentMaxWidth = useMemo(() => {
|
||||
if (isWideScreen) return 800;
|
||||
if (isDesktop) return 720;
|
||||
if (isTablet) return 640;
|
||||
return width; // 移动端使用全宽
|
||||
}, [width, isTablet, isDesktop, isWideScreen]);
|
||||
|
||||
// 列表模式横向内边距:移动端适当收窄,减少两侧空白
|
||||
const listHorizontalPadding = useMemo(() => {
|
||||
if (isMobile) {
|
||||
return Math.max(6, responsivePadding - responsiveGap);
|
||||
}
|
||||
return responsivePadding;
|
||||
}, [isMobile, responsivePadding, responsiveGap]);
|
||||
|
||||
// 列表模式卡片宽度:宽屏限宽并居中,移动端占用更多可用宽度
|
||||
const listItemWidth = useMemo(() => {
|
||||
const availableWidth = Math.max(0, width - listHorizontalPadding * 2);
|
||||
if (isDesktop || isWideScreen) {
|
||||
return Math.min(contentMaxWidth, availableWidth);
|
||||
}
|
||||
return availableWidth;
|
||||
}, [width, listHorizontalPadding, isDesktop, isWideScreen, contentMaxWidth]);
|
||||
|
||||
const floatingButtonBottom = useMemo(() => {
|
||||
if (!isMobile) {
|
||||
return undefined;
|
||||
}
|
||||
return insets.bottom + MOBILE_TAB_BAR_HEIGHT + MOBILE_TAB_FLOATING_MARGIN + MOBILE_FAB_GAP;
|
||||
}, [isMobile, insets.bottom]);
|
||||
|
||||
const appendUniquePosts = useCallback((prevPosts: Post[], incomingPosts: Post[]) => {
|
||||
if (incomingPosts.length === 0) return prevPosts;
|
||||
const seenIds = new Set(prevPosts.map(item => item.id));
|
||||
const dedupedIncoming = incomingPosts.filter(item => {
|
||||
if (seenIds.has(item.id)) return false;
|
||||
seenIds.add(item.id);
|
||||
return true;
|
||||
});
|
||||
return dedupedIncoming.length > 0 ? [...prevPosts, ...dedupedIncoming] : prevPosts;
|
||||
}, []);
|
||||
|
||||
const uniquePostsById = useCallback((items: Post[]) => {
|
||||
if (items.length <= 1) return items;
|
||||
const map = new Map<string, Post>();
|
||||
for (const item of items) {
|
||||
map.set(item.id, item);
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}, []);
|
||||
|
||||
const getPostType = (): 'recommend' | 'follow' | 'hot' | 'latest' => {
|
||||
switch (activeIndex) {
|
||||
case 0: return 'recommend';
|
||||
case 1: return 'follow';
|
||||
case 2: return 'hot';
|
||||
case 3: return 'latest';
|
||||
default: return 'recommend';
|
||||
}
|
||||
};
|
||||
|
||||
// 加载帖子列表
|
||||
const loadPosts = useCallback(async (pageNum: number = 1, isRefresh: boolean = false) => {
|
||||
const postType = getPostType();
|
||||
const requestKey = `${postType}:${pageNum}`;
|
||||
if (inFlightRequestKeysRef.current.has(requestKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
inFlightRequestKeysRef.current.add(requestKey);
|
||||
if (isRefresh) {
|
||||
setRefreshing(true);
|
||||
} else if (pageNum === 1) {
|
||||
setLoading(true);
|
||||
} else {
|
||||
setLoadingMore(true);
|
||||
}
|
||||
|
||||
console.log('[HomeScreen] loadPosts - activeIndex:', activeIndex, 'postType:', postType);
|
||||
const response = await fetchPosts(postType, pageNum);
|
||||
const newPosts = response.list || [];
|
||||
|
||||
if (isRefresh) {
|
||||
setPosts(uniquePostsById(newPosts));
|
||||
setPage(1);
|
||||
} else if (pageNum === 1) {
|
||||
setPosts(uniquePostsById(newPosts));
|
||||
setPage(1);
|
||||
} else {
|
||||
setPosts(prev => appendUniquePosts(prev, newPosts));
|
||||
setPage(pageNum);
|
||||
}
|
||||
|
||||
const hasMoreByPage = response.total_pages > 0 ? response.page < response.total_pages : false;
|
||||
const hasMoreBySize = newPosts.length >= (response.page_size || DEFAULT_PAGE_SIZE);
|
||||
setHasMore(hasMoreByPage || hasMoreBySize);
|
||||
} catch (error) {
|
||||
console.error('Failed to load posts:', error);
|
||||
} finally {
|
||||
inFlightRequestKeysRef.current.delete(requestKey);
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [fetchPosts, activeIndex, appendUniquePosts, uniquePostsById]);
|
||||
|
||||
// 切换Tab时重新加载
|
||||
useEffect(() => {
|
||||
loadPosts(1, true);
|
||||
}, [activeIndex]);
|
||||
|
||||
// 同步 store 中的帖子状态到本地(用于点赞、收藏等状态更新)
|
||||
useEffect(() => {
|
||||
if (posts.length === 0) return;
|
||||
|
||||
// 更新 postIdsRef
|
||||
const currentPostIds = new Set(posts.map(p => p.id));
|
||||
postIdsRef.current = currentPostIds;
|
||||
|
||||
// 从 store 中找到对应的帖子并同步状态
|
||||
let hasChanges = false;
|
||||
const updatedPosts = posts.map(localPost => {
|
||||
const storePost = storePosts.find(sp => sp.id === localPost.id);
|
||||
if (storePost && (
|
||||
storePost.is_liked !== localPost.is_liked ||
|
||||
storePost.is_favorited !== localPost.is_favorited ||
|
||||
storePost.likes_count !== localPost.likes_count ||
|
||||
storePost.favorites_count !== localPost.favorites_count
|
||||
)) {
|
||||
hasChanges = true;
|
||||
return {
|
||||
...localPost,
|
||||
is_liked: storePost.is_liked,
|
||||
is_favorited: storePost.is_favorited,
|
||||
likes_count: storePost.likes_count,
|
||||
favorites_count: storePost.favorites_count,
|
||||
};
|
||||
}
|
||||
return localPost;
|
||||
});
|
||||
|
||||
if (hasChanges) {
|
||||
setPosts(updatedPosts);
|
||||
}
|
||||
}, [storePosts]);
|
||||
|
||||
// 下拉刷新
|
||||
const onRefresh = useCallback(() => {
|
||||
loadPosts(1, true);
|
||||
}, [loadPosts]);
|
||||
|
||||
// 上拉加载更多
|
||||
const onEndReached = useCallback(() => {
|
||||
if (!loadingMoreRef.current && hasMoreRef.current) {
|
||||
loadPosts(pageRef.current + 1);
|
||||
}
|
||||
}, [loadPosts]);
|
||||
|
||||
const onWaterfallScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
||||
if (loadingMoreRef.current || !hasMoreRef.current) return;
|
||||
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;
|
||||
const distanceToBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height);
|
||||
const now = Date.now();
|
||||
if (distanceToBottom <= SCROLL_BOTTOM_THRESHOLD) {
|
||||
if (now - lastLoadMoreTriggerAtRef.current < LOAD_MORE_COOLDOWN_MS) {
|
||||
return;
|
||||
}
|
||||
lastLoadMoreTriggerAtRef.current = now;
|
||||
loadPosts(pageRef.current + 1);
|
||||
}
|
||||
}, [loadPosts]);
|
||||
|
||||
// 切换视图模式
|
||||
const toggleViewMode = () => {
|
||||
setViewMode(prev => prev === 'list' ? 'grid' : 'list');
|
||||
};
|
||||
|
||||
// 切换Tab(手势/点击共用)
|
||||
const changeTab = useCallback((nextIndex: number) => {
|
||||
if (nextIndex < 0 || nextIndex >= TABS.length || nextIndex === activeIndex) {
|
||||
return;
|
||||
}
|
||||
setActiveIndex(nextIndex);
|
||||
}, [activeIndex]);
|
||||
|
||||
const handleSwipeTabChange = useCallback((translationX: number) => {
|
||||
setActiveIndex(prev => (
|
||||
translationX < 0
|
||||
? Math.min(prev + 1, TABS.length - 1)
|
||||
: Math.max(prev - 1, 0)
|
||||
));
|
||||
}, []);
|
||||
|
||||
const swipeGesture = useMemo(() => (
|
||||
Gesture.Pan()
|
||||
.runOnJS(true)
|
||||
.activeOffsetX([-15, 15])
|
||||
.failOffsetY([-20, 20])
|
||||
.onEnd((event) => {
|
||||
const now = Date.now();
|
||||
if (now - lastSwipeAtRef.current < SWIPE_COOLDOWN_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Math.abs(event.translationX) < SWIPE_TRANSLATION_THRESHOLD) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastSwipeAtRef.current = now;
|
||||
handleSwipeTabChange(event.translationX);
|
||||
})
|
||||
), [handleSwipeTabChange]);
|
||||
|
||||
// 跳转到搜索页
|
||||
const handleSearchPress = () => {
|
||||
navigation.navigate('Search');
|
||||
};
|
||||
|
||||
// 跳转到帖子详情
|
||||
const handlePostPress = (postId: string, scrollToComments: boolean = false) => {
|
||||
navigation.getParent()?.navigate('PostDetail', { postId, scrollToComments });
|
||||
};
|
||||
|
||||
// 跳转到用户主页
|
||||
const handleUserPress = (userId: string) => {
|
||||
navigation.getParent()?.navigate('UserProfile', { userId });
|
||||
};
|
||||
|
||||
// 点赞帖子
|
||||
const handleLike = (post: Post) => {
|
||||
if (post.is_liked) {
|
||||
unlikePost(post.id);
|
||||
} else {
|
||||
likePost(post.id);
|
||||
}
|
||||
};
|
||||
|
||||
// 收藏帖子
|
||||
const handleBookmark = (post: Post) => {
|
||||
if (post.is_favorited) {
|
||||
unfavoritePost(post.id);
|
||||
} else {
|
||||
favoritePost(post.id);
|
||||
}
|
||||
};
|
||||
|
||||
// 分享帖子
|
||||
const handleShare = (post: Post) => {
|
||||
console.log('Share post:', post.id);
|
||||
};
|
||||
|
||||
// 删除帖子
|
||||
const handleDeletePost = async (postId: string) => {
|
||||
try {
|
||||
const success = await postService.deletePost(postId);
|
||||
if (success) {
|
||||
// 从列表中移除已删除的帖子
|
||||
setPosts(prev => prev.filter(p => p.id !== postId));
|
||||
} else {
|
||||
console.error('删除帖子失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除帖子失败:', error);
|
||||
throw error; // 重新抛出错误,让 PostCard 处理错误提示
|
||||
}
|
||||
};
|
||||
|
||||
// 处理图片点击 - 打开图片查看器
|
||||
const handleImagePress = (images: ImageGridItem[], index: number) => {
|
||||
setPostImages(images);
|
||||
setSelectedImageIndex(index);
|
||||
setShowImageViewer(true);
|
||||
};
|
||||
|
||||
// 跳转到发帖页面
|
||||
const handleCreatePost = () => {
|
||||
navigation.getParent()?.navigate('CreatePost');
|
||||
};
|
||||
|
||||
// 渲染帖子卡片(列表模式)
|
||||
const renderPostList = ({ item }: { item: Post }) => {
|
||||
const authorId = item.author?.id || '';
|
||||
const isPostAuthor = currentUser?.id === authorId;
|
||||
return (
|
||||
<View style={[
|
||||
styles.listItem,
|
||||
{
|
||||
marginBottom: responsiveGap,
|
||||
width: listItemWidth,
|
||||
alignSelf: 'center',
|
||||
borderRadius: isMobile ? borderRadius.lg : 0,
|
||||
overflow: isMobile ? 'hidden' : 'visible',
|
||||
}
|
||||
]}>
|
||||
<PostCard
|
||||
post={item}
|
||||
onPress={() => handlePostPress(item.id)}
|
||||
onUserPress={() => authorId && handleUserPress(authorId)}
|
||||
onLike={() => handleLike(item)}
|
||||
onComment={() => handlePostPress(item.id, true)}
|
||||
onBookmark={() => handleBookmark(item)}
|
||||
onShare={() => handleShare(item)}
|
||||
onImagePress={(images, index) => handleImagePress(images, index)}
|
||||
onDelete={() => handleDeletePost(item.id)}
|
||||
isPostAuthor={isPostAuthor}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 估算帖子在瀑布流中的高度(用于均匀分配)
|
||||
const estimatePostHeight = (post: Post, columnWidth: number): number => {
|
||||
const hasImage = post.images && post.images.length > 0;
|
||||
const hasTitle = !!post.title;
|
||||
const hasContent = !!post.content;
|
||||
|
||||
let height = 0;
|
||||
|
||||
// 图片区域高度(如果有图)
|
||||
if (hasImage) {
|
||||
// 使用帖子 ID 生成一致的宽高比
|
||||
const hash = post.id.split('').reduce((a, b) => a + b.charCodeAt(0), 0);
|
||||
const aspectRatios = [0.7, 0.75, 0.8, 0.85, 0.9, 1, 1.1, 1.2];
|
||||
const aspectRatio = aspectRatios[hash % aspectRatios.length];
|
||||
height += columnWidth / aspectRatio;
|
||||
} else {
|
||||
// 无图帖子显示正文预览区域
|
||||
if (hasContent) {
|
||||
// 根据内容长度估算高度(每行约20像素,最多6行)
|
||||
const contentLength = post.content?.length || 0;
|
||||
const estimatedLines = Math.min(6, Math.max(3, Math.ceil(contentLength / 20)));
|
||||
height += 16 + estimatedLines * 20; // padding + 文本高度
|
||||
}
|
||||
}
|
||||
|
||||
// 标题高度
|
||||
if (hasTitle) {
|
||||
const titleLines = hasImage ? 2 : 3;
|
||||
height += 8 + titleLines * 20; // paddingTop + 文本高度
|
||||
}
|
||||
|
||||
// 底部信息栏高度
|
||||
height += 40; // 用户信息 + 点赞数
|
||||
|
||||
// 间距
|
||||
height += 2; // marginBottom
|
||||
|
||||
return height;
|
||||
};
|
||||
|
||||
// 将帖子分成多列(瀑布流)- 使用贪心算法使各列高度尽量均匀
|
||||
const distributePostsToColumns = useMemo(() => {
|
||||
const columns: Post[][] = Array.from({ length: gridColumns }, () => []);
|
||||
const columnHeights: number[] = Array(gridColumns).fill(0);
|
||||
|
||||
// 计算单列宽度
|
||||
const totalGap = (gridColumns - 1) * responsiveGap;
|
||||
const columnWidth = (width - responsivePadding * 2 - totalGap) / gridColumns;
|
||||
|
||||
posts.forEach((post) => {
|
||||
const postHeight = estimatePostHeight(post, columnWidth);
|
||||
|
||||
// 找到当前高度最小的列
|
||||
const minHeightIndex = columnHeights.indexOf(Math.min(...columnHeights));
|
||||
columns[minHeightIndex].push(post);
|
||||
columnHeights[minHeightIndex] += postHeight;
|
||||
});
|
||||
|
||||
return columns;
|
||||
}, [posts, gridColumns, width, responsiveGap, responsivePadding]);
|
||||
|
||||
// 渲染单列帖子
|
||||
const renderWaterfallColumn = (column: Post[], columnIndex: number) => (
|
||||
<View key={`column-${columnIndex}`} style={[styles.waterfallColumn, { marginRight: columnIndex < gridColumns - 1 ? responsiveGap : 0 }]}>
|
||||
{column.map(post => {
|
||||
const authorId = post.author?.id || '';
|
||||
const isPostAuthor = currentUser?.id === authorId;
|
||||
return (
|
||||
<View key={post.id} style={[styles.waterfallItem, { marginBottom: responsiveGap }]}>
|
||||
<PostCard
|
||||
post={post}
|
||||
variant="grid"
|
||||
onPress={() => handlePostPress(post.id)}
|
||||
onUserPress={() => authorId && handleUserPress(authorId)}
|
||||
onLike={() => handleLike(post)}
|
||||
onComment={() => handlePostPress(post.id, true)}
|
||||
onBookmark={() => handleBookmark(post)}
|
||||
onShare={() => handleShare(post)}
|
||||
onImagePress={(images, index) => handleImagePress(images, index)}
|
||||
onDelete={() => handleDeletePost(post.id)}
|
||||
isPostAuthor={isPostAuthor}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
|
||||
// 渲染响应式网格布局(平板/桌面端使用多列)
|
||||
const renderResponsiveGrid = () => {
|
||||
if (isMobile && !isTablet) {
|
||||
// 移动端使用瀑布流布局(2列)
|
||||
return (
|
||||
<ScrollView
|
||||
style={styles.waterfallScroll}
|
||||
contentContainerStyle={[
|
||||
styles.waterfallContainer,
|
||||
{
|
||||
paddingHorizontal: responsivePadding,
|
||||
paddingBottom: 80 + responsivePadding,
|
||||
}
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScroll={onWaterfallScroll}
|
||||
scrollEventThrottle={100}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{distributePostsToColumns.map((column, index) => renderWaterfallColumn(column, index))}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
// 平板/桌面端使用 ResponsiveGrid 组件
|
||||
return (
|
||||
<ResponsiveGrid
|
||||
columns={{ xs: 1, sm: 2, md: 2, lg: 3, xl: 3, '2xl': 4, '3xl': 4, '4xl': 5 }}
|
||||
gap={{ xs: 8, sm: 12, md: 16, lg: 20, xl: 24 }}
|
||||
containerStyle={{ paddingHorizontal: responsivePadding, paddingBottom: 80 }}
|
||||
>
|
||||
{posts.map(post => {
|
||||
const authorId = post.author?.id || '';
|
||||
const isPostAuthor = currentUser?.id === authorId;
|
||||
return (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
variant={viewMode === 'grid' ? 'grid' : 'default'}
|
||||
onPress={() => handlePostPress(post.id)}
|
||||
onUserPress={() => authorId && handleUserPress(authorId)}
|
||||
onLike={() => handleLike(post)}
|
||||
onComment={() => handlePostPress(post.id, true)}
|
||||
onBookmark={() => handleBookmark(post)}
|
||||
onShare={() => handleShare(post)}
|
||||
onImagePress={(images, index) => handleImagePress(images, index)}
|
||||
onDelete={() => handleDeletePost(post.id)}
|
||||
isPostAuthor={isPostAuthor}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ResponsiveGrid>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmpty = () => {
|
||||
if (loading) return null;
|
||||
|
||||
return (
|
||||
<EmptyState
|
||||
title="暂无内容"
|
||||
description={activeIndex === 1 ? '关注一些用户来获取内容吧' : '暂无帖子,快去发布第一条内容吧'}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染列表内容
|
||||
const renderListContent = () => {
|
||||
if (useMultiColumnList) {
|
||||
// 平板/桌面端使用多列网格
|
||||
return renderResponsiveGrid();
|
||||
}
|
||||
|
||||
// 移动端和宽屏都使用单列 FlatList,宽屏下居中显示
|
||||
return (
|
||||
<FlatList
|
||||
data={posts}
|
||||
renderItem={renderPostList}
|
||||
keyExtractor={item => item.id}
|
||||
contentContainerStyle={[
|
||||
styles.listContent,
|
||||
{
|
||||
paddingHorizontal: listHorizontalPadding,
|
||||
paddingBottom: 80 + responsivePadding,
|
||||
alignItems: 'center', // 确保子项居中
|
||||
}
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
onEndReached={onEndReached}
|
||||
onEndReachedThreshold={0.3}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
ListFooterComponent={loadingMore ? <Loading size="sm" /> : null}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
<StatusBar barStyle="dark-content" backgroundColor={colors.background.paper} />
|
||||
|
||||
{/* 顶部Header */}
|
||||
<View style={styles.header}>
|
||||
{/* 搜索栏 */}
|
||||
<View style={[styles.searchWrapper, { paddingHorizontal: responsivePadding }]}>
|
||||
<SearchBar
|
||||
value=""
|
||||
onChangeText={() => {}}
|
||||
onSubmit={handleSearchPress}
|
||||
onFocus={handleSearchPress}
|
||||
placeholder="搜索帖子、用户"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Tab切换 */}
|
||||
<TabBar
|
||||
tabs={TABS}
|
||||
icons={TAB_ICONS}
|
||||
activeIndex={activeIndex}
|
||||
onTabChange={changeTab}
|
||||
variant="modern"
|
||||
rightContent={
|
||||
<TouchableOpacity onPress={toggleViewMode} style={styles.viewToggleBtn}>
|
||||
<MaterialCommunityIcons
|
||||
name={viewMode === 'list' ? 'view-grid-outline' : 'view-list'}
|
||||
size={22}
|
||||
color="#666"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 帖子列表 */}
|
||||
<GestureDetector gesture={swipeGesture}>
|
||||
<View style={styles.contentContainer}>
|
||||
{viewMode === 'list' ? (
|
||||
renderListContent()
|
||||
) : (
|
||||
// 网格模式:使用响应式网格
|
||||
renderResponsiveGrid()
|
||||
)}
|
||||
</View>
|
||||
</GestureDetector>
|
||||
|
||||
{/* 漂浮发帖按钮 */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.floatingButton,
|
||||
isDesktop && styles.floatingButtonDesktop,
|
||||
isWideScreen && styles.floatingButtonWide,
|
||||
floatingButtonBottom !== undefined && { bottom: floatingButtonBottom },
|
||||
]}
|
||||
onPress={handleCreatePost}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<MaterialCommunityIcons name="plus" size={28} color={colors.text.inverse} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 图片查看器 */}
|
||||
<ImageGallery
|
||||
visible={showImageViewer}
|
||||
images={postImages.map(img => ({
|
||||
id: img.id || img.url || String(Math.random()),
|
||||
url: img.url || img.uri || ''
|
||||
}))}
|
||||
initialIndex={selectedImageIndex}
|
||||
onClose={() => setShowImageViewer(false)}
|
||||
enableSave
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
header: {
|
||||
backgroundColor: colors.background.paper,
|
||||
},
|
||||
searchWrapper: {
|
||||
paddingBottom: spacing.sm,
|
||||
},
|
||||
viewToggleBtn: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
listItem: {
|
||||
// 动态设置 marginBottom
|
||||
},
|
||||
waterfallScroll: {
|
||||
flex: 1,
|
||||
},
|
||||
waterfallContainer: {
|
||||
flexDirection: 'row',
|
||||
flexGrow: 1,
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
waterfallColumn: {
|
||||
flex: 1,
|
||||
},
|
||||
waterfallItem: {
|
||||
// 动态设置 marginBottom
|
||||
},
|
||||
floatingButton: {
|
||||
position: 'absolute',
|
||||
right: 20,
|
||||
bottom: 20,
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
backgroundColor: colors.primary.main,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
...shadows.lg,
|
||||
},
|
||||
floatingButtonDesktop: {
|
||||
right: 40,
|
||||
bottom: 40,
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
},
|
||||
floatingButtonWide: {
|
||||
right: 60,
|
||||
bottom: 60,
|
||||
width: 72,
|
||||
height: 72,
|
||||
borderRadius: 36,
|
||||
},
|
||||
});
|
||||
1815
src/screens/home/PostDetailScreen.tsx
Normal file
1815
src/screens/home/PostDetailScreen.tsx
Normal file
File diff suppressed because it is too large
Load Diff
564
src/screens/home/SearchScreen.tsx
Normal file
564
src/screens/home/SearchScreen.tsx
Normal file
@@ -0,0 +1,564 @@
|
||||
/**
|
||||
* 搜索页 SearchScreen(响应式版本)
|
||||
* 胡萝卜BBS - 搜索帖子、用户
|
||||
* 支持响应式布局,宽屏下显示更大的搜索结果区域
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { Post, User } from '../../types';
|
||||
import { useUserStore } from '../../stores';
|
||||
import { postService, authService } from '../../services';
|
||||
import { PostCard, TabBar, SearchBar } from '../../components/business';
|
||||
import { Avatar, EmptyState, Text, ResponsiveGrid } from '../../components/common';
|
||||
import { HomeStackParamList } from '../../navigation/types';
|
||||
import { useResponsive, useResponsiveSpacing, useResponsiveValue } from '../../hooks/useResponsive';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'Search'>;
|
||||
|
||||
const TABS = ['帖子', '用户'];
|
||||
|
||||
type SearchType = 'posts' | 'users';
|
||||
|
||||
export const SearchScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const { searchHistory: history, addSearchHistory, clearSearchHistory } = useUserStore();
|
||||
|
||||
// 使用响应式 hook
|
||||
const {
|
||||
width,
|
||||
isMobile,
|
||||
isTablet,
|
||||
isDesktop,
|
||||
isWideScreen
|
||||
} = useResponsive();
|
||||
|
||||
// 响应式间距
|
||||
const responsivePadding = useResponsiveSpacing({
|
||||
xs: 12, sm: 14, md: 16, lg: 20, xl: 24, '2xl': 32, '3xl': 40, '4xl': 48
|
||||
});
|
||||
|
||||
const responsiveGap = useResponsiveSpacing({
|
||||
xs: 8, sm: 10, md: 12, lg: 16, xl: 20, '2xl': 24, '3xl': 28, '4xl': 32
|
||||
});
|
||||
|
||||
// 响应式搜索栏最大宽度(使用数字)
|
||||
const searchBarMaxWidth = useResponsiveValue({
|
||||
xs: 600,
|
||||
sm: 600,
|
||||
md: 600,
|
||||
lg: 700,
|
||||
xl: 800,
|
||||
'2xl': 900,
|
||||
'3xl': 1000,
|
||||
'4xl': 1200,
|
||||
});
|
||||
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [searchResults, setSearchResults] = useState<{
|
||||
posts: Post[];
|
||||
users: User[];
|
||||
}>({
|
||||
posts: [],
|
||||
users: [],
|
||||
});
|
||||
const [hasSearched, setHasSearched] = useState(false);
|
||||
// 保存当前搜索关键词,用于Tab切换时重新搜索
|
||||
const [currentKeyword, setCurrentKeyword] = useState('');
|
||||
|
||||
// 执行搜索 - 根据当前Tab执行对应类型的搜索
|
||||
const performSearch = useCallback(async (keyword: string) => {
|
||||
if (!keyword.trim()) return;
|
||||
|
||||
const trimmedKeyword = keyword.trim();
|
||||
|
||||
// 保存当前搜索关键词
|
||||
setCurrentKeyword(trimmedKeyword);
|
||||
|
||||
// 添加到搜索历史
|
||||
addSearchHistory(trimmedKeyword);
|
||||
|
||||
try {
|
||||
const searchType = getSearchType();
|
||||
|
||||
if (searchType === 'posts') {
|
||||
// 搜索帖子
|
||||
const postsResponse = await postService.searchPosts(trimmedKeyword, 1, 20);
|
||||
setSearchResults(prev => ({
|
||||
...prev,
|
||||
posts: postsResponse.list
|
||||
}));
|
||||
} else if (searchType === 'users') {
|
||||
// 搜索用户
|
||||
const usersResponse = await authService.searchUsers(trimmedKeyword, 1, 20);
|
||||
setSearchResults(prev => ({
|
||||
...prev,
|
||||
users: usersResponse.list
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索失败:', error);
|
||||
}
|
||||
|
||||
setHasSearched(true);
|
||||
}, [addSearchHistory, activeIndex]);
|
||||
|
||||
// 处理搜索提交
|
||||
const handleSearch = () => {
|
||||
performSearch(searchText);
|
||||
};
|
||||
|
||||
// 处理搜索历史点击
|
||||
const handleHistoryPress = (keyword: string) => {
|
||||
setSearchText(keyword);
|
||||
performSearch(keyword);
|
||||
};
|
||||
|
||||
// 清除搜索历史
|
||||
const handleClearHistory = () => {
|
||||
clearSearchHistory();
|
||||
};
|
||||
|
||||
// 跳转到帖子详情
|
||||
const handlePostPress = (postId: string, scrollToComments: boolean = false) => {
|
||||
navigation.navigate('PostDetail', { postId, scrollToComments });
|
||||
};
|
||||
|
||||
// 跳转到用户主页
|
||||
const handleUserPress = (userId: string) => {
|
||||
navigation.navigate('UserProfile', { userId });
|
||||
};
|
||||
|
||||
// 获取当前搜索类型
|
||||
const getSearchType = (): SearchType => {
|
||||
switch (activeIndex) {
|
||||
case 0: return 'posts';
|
||||
case 1: return 'users';
|
||||
default: return 'posts';
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染帖子搜索结果(使用响应式网格)
|
||||
const renderPostResults = () => {
|
||||
const posts = searchResults.posts;
|
||||
|
||||
if (posts.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="未找到相关帖子"
|
||||
description="试试其他关键词吧"
|
||||
icon="file-search-outline"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 平板/桌面端使用网格布局
|
||||
if (isTablet || isDesktop || isWideScreen) {
|
||||
return (
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: responsivePadding }}
|
||||
>
|
||||
<ResponsiveGrid
|
||||
columns={{ xs: 1, sm: 2, md: 2, lg: 3, xl: 3, '2xl': 4, '3xl': 4, '4xl': 5 }}
|
||||
gap={{ xs: 12, sm: 14, md: 16, lg: 20, xl: 24 }}
|
||||
containerStyle={{ paddingHorizontal: responsivePadding }}
|
||||
>
|
||||
{posts.map(post => (
|
||||
<PostCard
|
||||
key={post.id}
|
||||
post={post}
|
||||
onPress={() => handlePostPress(post.id)}
|
||||
onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}}
|
||||
onLike={() => {}}
|
||||
onComment={() => handlePostPress(post.id, true)}
|
||||
onBookmark={() => {}}
|
||||
onShare={() => {}}
|
||||
compact={isMobile}
|
||||
/>
|
||||
))}
|
||||
</ResponsiveGrid>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
// 移动端使用列表布局
|
||||
return (
|
||||
<FlatList
|
||||
data={posts}
|
||||
renderItem={({ item }) => (
|
||||
<PostCard
|
||||
post={item}
|
||||
onPress={() => handlePostPress(item.id)}
|
||||
onUserPress={() => item.author ? handleUserPress(item.author.id) : () => {}}
|
||||
onLike={() => {}}
|
||||
onComment={() => handlePostPress(item.id, true)}
|
||||
onBookmark={() => {}}
|
||||
onShare={() => {}}
|
||||
compact
|
||||
/>
|
||||
)}
|
||||
keyExtractor={item => item.id}
|
||||
contentContainerStyle={{ paddingBottom: responsivePadding }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染用户搜索结果
|
||||
const renderUserResults = () => {
|
||||
const users = searchResults.users;
|
||||
|
||||
if (users.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="未找到相关用户"
|
||||
description="试试其他关键词吧"
|
||||
icon="account-search-outline"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 平板/桌面端使用网格布局
|
||||
if (isTablet || isDesktop || isWideScreen) {
|
||||
return (
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: responsivePadding }}
|
||||
>
|
||||
<ResponsiveGrid
|
||||
columns={{ xs: 1, sm: 2, md: 2, lg: 3, xl: 3, '2xl': 4, '3xl': 4, '4xl': 5 }}
|
||||
gap={{ xs: 12, sm: 14, md: 16, lg: 20, xl: 24 }}
|
||||
containerStyle={{ paddingHorizontal: responsivePadding }}
|
||||
>
|
||||
{users.map(item => (
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
style={[
|
||||
styles.userCard,
|
||||
{ padding: responsiveGap }
|
||||
]}
|
||||
onPress={() => handleUserPress(item.id)}
|
||||
>
|
||||
<Avatar
|
||||
source={item.avatar}
|
||||
size={isDesktop ? 70 : isTablet ? 60 : 50}
|
||||
name={item.nickname}
|
||||
/>
|
||||
<View style={styles.userCardInfo}>
|
||||
<Text
|
||||
variant="body"
|
||||
style={[
|
||||
styles.userCardName,
|
||||
{ fontSize: isDesktop ? fontSizes.lg : fontSizes.md }
|
||||
]}
|
||||
>
|
||||
{item.nickname}
|
||||
</Text>
|
||||
<Text
|
||||
variant="caption"
|
||||
color={colors.text.secondary}
|
||||
style={{ fontSize: isDesktop ? fontSizes.md : fontSizes.sm }}
|
||||
>
|
||||
{item.bio || '@' + item.username}
|
||||
</Text>
|
||||
</View>
|
||||
{item.is_following && (
|
||||
<View style={styles.followingBadge}>
|
||||
<MaterialCommunityIcons name="check" size={14} color={colors.primary.main} />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</ResponsiveGrid>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
// 移动端使用列表布局
|
||||
return (
|
||||
<FlatList
|
||||
data={users}
|
||||
renderItem={({ item }) => (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.userItem,
|
||||
{
|
||||
marginHorizontal: responsivePadding,
|
||||
marginVertical: responsiveGap / 2,
|
||||
padding: responsiveGap
|
||||
}
|
||||
]}
|
||||
onPress={() => handleUserPress(item.id)}
|
||||
>
|
||||
<Avatar
|
||||
source={item.avatar}
|
||||
size={50}
|
||||
name={item.nickname}
|
||||
/>
|
||||
<View style={styles.userInfo}>
|
||||
<Text variant="body" style={styles.userName}>{item.nickname}</Text>
|
||||
<Text variant="caption" color={colors.text.secondary}>{item.bio || '@' + item.username}</Text>
|
||||
</View>
|
||||
{item.is_following && (
|
||||
<View style={styles.followingBadge}>
|
||||
<MaterialCommunityIcons name="check" size={14} color={colors.primary.main} />
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
keyExtractor={item => item.id}
|
||||
contentContainerStyle={{ paddingVertical: responsiveGap }}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染搜索结果
|
||||
const renderSearchResults = () => {
|
||||
const searchType = getSearchType();
|
||||
|
||||
if (searchType === 'posts') {
|
||||
return renderPostResults();
|
||||
}
|
||||
|
||||
if (searchType === 'users') {
|
||||
return renderUserResults();
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 渲染搜索历史和热门搜索
|
||||
const renderSearchSuggestions = () => {
|
||||
if (hasSearched) return null;
|
||||
|
||||
return (
|
||||
<View style={[styles.suggestionsContainer, { paddingHorizontal: responsivePadding }]}>
|
||||
{/* 搜索历史 */}
|
||||
{history.length > 0 && (
|
||||
<View style={[styles.section, { marginTop: responsiveGap }]}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text
|
||||
variant="body"
|
||||
style={[
|
||||
styles.sectionTitle,
|
||||
{ fontSize: isDesktop ? fontSizes.lg : fontSizes.md }
|
||||
]}
|
||||
>
|
||||
搜索历史
|
||||
</Text>
|
||||
<TouchableOpacity onPress={handleClearHistory}>
|
||||
<MaterialCommunityIcons name="delete-outline" size={isDesktop ? 22 : 18} color={colors.text.secondary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.tagContainer}>
|
||||
{history.map((keyword, index) => (
|
||||
<TouchableOpacity
|
||||
key={index}
|
||||
style={[
|
||||
styles.tag,
|
||||
{
|
||||
paddingHorizontal: responsiveGap,
|
||||
paddingVertical: responsiveGap / 2,
|
||||
marginRight: responsiveGap / 2,
|
||||
marginBottom: responsiveGap / 2
|
||||
}
|
||||
]}
|
||||
onPress={() => handleHistoryPress(keyword)}
|
||||
>
|
||||
<MaterialCommunityIcons name="history" size={isDesktop ? 16 : 14} color={colors.text.secondary} />
|
||||
<Text
|
||||
variant="caption"
|
||||
color={colors.text.secondary}
|
||||
style={[
|
||||
styles.tagText,
|
||||
{ fontSize: isDesktop ? fontSizes.md : fontSizes.sm }
|
||||
]}
|
||||
>
|
||||
{keyword}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['left', 'right']}>
|
||||
{/* 搜索输入框 */}
|
||||
<View
|
||||
style={[
|
||||
styles.searchHeader,
|
||||
{
|
||||
paddingTop: Math.max(spacing.sm, insets.top + spacing.xs),
|
||||
paddingHorizontal: responsivePadding,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[styles.searchShell, { maxWidth: searchBarMaxWidth }]}>
|
||||
<SearchBar
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
onSubmit={handleSearch}
|
||||
placeholder="搜索帖子、用户"
|
||||
autoFocus
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.cancelButton, { marginLeft: responsiveGap }]}
|
||||
onPress={() => navigation.goBack()}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
color={colors.primary.main}
|
||||
style={styles.cancelText}
|
||||
>
|
||||
取消
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Tab切换 */}
|
||||
<View style={styles.tabWrapper}>
|
||||
<TabBar
|
||||
tabs={TABS}
|
||||
activeIndex={activeIndex}
|
||||
onTabChange={(index) => {
|
||||
setActiveIndex(index);
|
||||
// 如果已经搜索过,切换Tab时重新搜索
|
||||
if (currentKeyword && hasSearched) {
|
||||
performSearch(currentKeyword);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 内容区域 */}
|
||||
{hasSearched ? renderSearchResults() : renderSearchSuggestions()}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
searchHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: `${colors.divider}70`,
|
||||
},
|
||||
searchShell: {
|
||||
flex: 1,
|
||||
borderRadius: borderRadius.xl,
|
||||
backgroundColor: `${colors.primary.main}08`,
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: `${colors.primary.main}14`,
|
||||
borderRadius: borderRadius.full,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
},
|
||||
cancelText: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
tabWrapper: {
|
||||
backgroundColor: colors.background.paper,
|
||||
paddingTop: spacing.xs,
|
||||
paddingBottom: spacing.xs,
|
||||
},
|
||||
suggestionsContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
section: {
|
||||
marginTop: spacing.lg,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
tagContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
tag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
tagText: {
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
// 移动端用户列表样式
|
||||
userItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
userInfo: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
userName: {
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
},
|
||||
followingBadge: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
backgroundColor: colors.primary.light + '30',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
// 桌面端用户卡片样式
|
||||
userCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: 180,
|
||||
},
|
||||
userCardInfo: {
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
userCardName: {
|
||||
fontWeight: '600',
|
||||
color: colors.text.primary,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
});
|
||||
7
src/screens/home/index.ts
Normal file
7
src/screens/home/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* 首页模块导出
|
||||
*/
|
||||
|
||||
export { HomeScreen } from './HomeScreen';
|
||||
export { PostDetailScreen } from './PostDetailScreen';
|
||||
export { SearchScreen } from './SearchScreen';
|
||||
376
src/screens/message/ChatScreen.tsx
Normal file
376
src/screens/message/ChatScreen.tsx
Normal file
@@ -0,0 +1,376 @@
|
||||
/**
|
||||
* 聊天页 ChatScreen
|
||||
* 胡萝卜BBS - 私信/群聊聊天界面
|
||||
* 高级现代化设计
|
||||
* 支持群聊功能:显示发送者头像和昵称、@提及功能
|
||||
* 支持响应式布局(桌面端宽屏优化)
|
||||
*
|
||||
* 重构说明:将原2264行的大文件拆分为多个模块化组件
|
||||
* - types.ts: 类型定义
|
||||
* - constants.ts: 常量定义
|
||||
* - styles.ts: 样式定义
|
||||
* - useChatScreen.ts: 自定义Hook,管理所有状态和逻辑
|
||||
* - EmojiPanel.tsx: 表情面板组件
|
||||
* - MorePanel.tsx: 更多功能面板组件
|
||||
* - MentionPanel.tsx: @成员选择面板组件
|
||||
* - LongPressMenu.tsx: 长按菜单组件
|
||||
* - ChatHeader.tsx: 聊天头部组件
|
||||
* - MessageBubble.tsx: 消息气泡组件
|
||||
* - ChatInput.tsx: 输入框组件
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
TouchableWithoutFeedback,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { Text, ImageGallery, ImageGridItem } from '../../components/common';
|
||||
import { colors } from '../../theme';
|
||||
import { messageManager } from '../../stores';
|
||||
import { useBreakpointGTE } from '../../hooks/useResponsive';
|
||||
import {
|
||||
useChatScreen,
|
||||
chatScreenStyles as baseStyles,
|
||||
EmojiPanel,
|
||||
MorePanel,
|
||||
MentionPanel,
|
||||
LongPressMenu,
|
||||
ChatHeader,
|
||||
MessageBubble,
|
||||
ChatInput,
|
||||
PANEL_HEIGHTS,
|
||||
} from './components/ChatScreen';
|
||||
|
||||
export const ChatScreen: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// 响应式布局
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
const styles = baseStyles;
|
||||
|
||||
// 输入框区域高度(用于定位浮动 mention 面板)
|
||||
const [inputWrapperHeight, setInputWrapperHeight] = useState(60);
|
||||
|
||||
const containerStyle = useMemo(() => ([
|
||||
styles.container,
|
||||
isWideScreen ? { maxWidth: 1200, alignSelf: 'center' as const, width: '100%' as const } : null,
|
||||
]), [isWideScreen, styles.container]);
|
||||
|
||||
const listContentStyle = useMemo(() => ([
|
||||
styles.listContent,
|
||||
isWideScreen
|
||||
? { paddingHorizontal: 24, maxWidth: 900, alignSelf: 'center' as const }
|
||||
: { paddingHorizontal: 16 },
|
||||
]), [isWideScreen, styles.listContent]);
|
||||
|
||||
const inputWrapperStyle = useMemo(() => ([
|
||||
styles.inputWrapper,
|
||||
isWideScreen ? { maxWidth: 900, alignSelf: 'center' as const, width: '100%' as const } : null,
|
||||
]), [isWideScreen, styles.inputWrapper]);
|
||||
|
||||
// 图片查看器状态
|
||||
const [showImageViewer, setShowImageViewer] = useState(false);
|
||||
const [chatImages, setChatImages] = useState<ImageGridItem[]>([]);
|
||||
const [selectedImageIndex, setSelectedImageIndex] = useState(0);
|
||||
|
||||
// 图片点击处理函数
|
||||
const handleImagePress = (images: ImageGridItem[], index: number) => {
|
||||
setChatImages(images);
|
||||
setSelectedImageIndex(index);
|
||||
setShowImageViewer(true);
|
||||
};
|
||||
|
||||
// 关闭图片查看器
|
||||
const handleCloseImageViewer = () => {
|
||||
setShowImageViewer(false);
|
||||
};
|
||||
|
||||
const {
|
||||
// 状态
|
||||
messages,
|
||||
inputText,
|
||||
otherUser,
|
||||
currentUser,
|
||||
currentUserId,
|
||||
keyboardHeight,
|
||||
loading,
|
||||
sending,
|
||||
activePanel,
|
||||
sendingImage,
|
||||
replyingTo,
|
||||
longPressMenuVisible,
|
||||
selectedMessage,
|
||||
selectedMessageId,
|
||||
menuPosition,
|
||||
isGroupChat,
|
||||
groupInfo,
|
||||
groupMembers,
|
||||
currentUserRole,
|
||||
mentionQuery,
|
||||
isMuted,
|
||||
muteAll,
|
||||
followRestrictionHint,
|
||||
canSendPrivateImage,
|
||||
routeGroupId,
|
||||
routeGroupName,
|
||||
otherUserLastReadSeq,
|
||||
messageMap,
|
||||
loadingMore,
|
||||
hasMoreHistory,
|
||||
|
||||
// Refs
|
||||
flatListRef,
|
||||
textInputRef,
|
||||
scrollPositionRef,
|
||||
|
||||
// 方法
|
||||
formatTime,
|
||||
shouldShowTime,
|
||||
handleInputChange,
|
||||
handleSend,
|
||||
handleMoreAction,
|
||||
handleInsertEmoji,
|
||||
handleSendSticker,
|
||||
toggleEmojiPanel,
|
||||
toggleMorePanel,
|
||||
closePanel,
|
||||
handleRecall,
|
||||
handleLongPressMessage,
|
||||
hideLongPressMenu,
|
||||
handleDeleteMessage,
|
||||
handleReplyMessage,
|
||||
handleCancelReply,
|
||||
handleAvatarPress,
|
||||
handleAvatarLongPress,
|
||||
handleSelectMention,
|
||||
handleMentionAll,
|
||||
getSenderInfo,
|
||||
getTypingHint,
|
||||
getInputBottom,
|
||||
handleDismiss,
|
||||
navigateToInfo,
|
||||
navigateToChatSettings,
|
||||
loadMoreHistory,
|
||||
handleMessageListContentSizeChange,
|
||||
} = useChatScreen();
|
||||
|
||||
// 监听返回事件,刷新会话列表
|
||||
useEffect(() => {
|
||||
const unsubscribe = navigation.addListener('beforeRemove', () => {
|
||||
// 刷新会话列表,确保已读状态正确显示
|
||||
messageManager.fetchConversations(true);
|
||||
});
|
||||
return unsubscribe;
|
||||
}, [navigation]);
|
||||
|
||||
// 渲染消息气泡
|
||||
const renderMessage = ({ item, index }: { item: any; index: number }) => (
|
||||
<MessageBubble
|
||||
message={item}
|
||||
index={index}
|
||||
currentUserId={currentUserId}
|
||||
currentUser={currentUser}
|
||||
otherUser={otherUser}
|
||||
isGroupChat={isGroupChat}
|
||||
groupMembers={groupMembers}
|
||||
otherUserLastReadSeq={otherUserLastReadSeq}
|
||||
selectedMessageId={selectedMessageId}
|
||||
messageMap={messageMap}
|
||||
onLongPress={handleLongPressMessage}
|
||||
onAvatarPress={handleAvatarPress}
|
||||
onAvatarLongPress={handleAvatarLongPress}
|
||||
formatTime={formatTime}
|
||||
shouldShowTime={shouldShowTime}
|
||||
onImagePress={handleImagePress}
|
||||
/>
|
||||
);
|
||||
|
||||
// 获取正在输入提示
|
||||
const typingHint = getTypingHint();
|
||||
|
||||
const handleMessageListScroll = useCallback((event: any) => {
|
||||
const { contentSize, contentOffset } = event.nativeEvent;
|
||||
scrollPositionRef.current = {
|
||||
contentHeight: contentSize.height,
|
||||
scrollY: contentOffset.y,
|
||||
};
|
||||
}, [scrollPositionRef]);
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={containerStyle}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
|
||||
>
|
||||
<StatusBar style="dark" backgroundColor="#FFFFFF" />
|
||||
|
||||
{/* 顶部栏 */}
|
||||
<ChatHeader
|
||||
isGroupChat={isGroupChat}
|
||||
groupInfo={groupInfo}
|
||||
otherUser={otherUser}
|
||||
routeGroupName={routeGroupName}
|
||||
typingHint={typingHint}
|
||||
onBack={() => navigation.goBack()}
|
||||
onTitlePress={navigateToInfo}
|
||||
onMorePress={navigateToChatSettings}
|
||||
/>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<TouchableWithoutFeedback onPress={handleDismiss}>
|
||||
<View style={styles.messageListContainer}>
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={messages}
|
||||
renderItem={renderMessage}
|
||||
keyExtractor={item => String(item.id)}
|
||||
contentContainerStyle={listContentStyle}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
refreshing={loadingMore}
|
||||
onRefresh={hasMoreHistory ? loadMoreHistory : undefined}
|
||||
progressViewOffset={0}
|
||||
onContentSizeChange={handleMessageListContentSizeChange}
|
||||
onScroll={handleMessageListScroll}
|
||||
scrollEventThrottle={16}
|
||||
// 优化渲染性能
|
||||
initialNumToRender={15}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={10}
|
||||
removeClippedSubviews={true}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
|
||||
{/* 底部区域:输入框 + 面板 */}
|
||||
<View style={{ marginBottom: keyboardHeight > 0 ? keyboardHeight : 0 }}>
|
||||
{/* 输入框区域 */}
|
||||
<View
|
||||
style={[inputWrapperStyle, insets.bottom > 0 && { paddingBottom: insets.bottom }]}
|
||||
onLayout={e => setInputWrapperHeight(e.nativeEvent.layout.height)}
|
||||
>
|
||||
<ChatInput
|
||||
inputText={inputText}
|
||||
onInputChange={handleInputChange}
|
||||
onSend={handleSend}
|
||||
onToggleEmoji={toggleEmojiPanel}
|
||||
onToggleMore={toggleMorePanel}
|
||||
activePanel={activePanel}
|
||||
sending={sending}
|
||||
isMuted={isMuted}
|
||||
isGroupChat={isGroupChat}
|
||||
muteAll={muteAll}
|
||||
restrictionHint={followRestrictionHint}
|
||||
replyingTo={replyingTo}
|
||||
onCancelReply={handleCancelReply}
|
||||
onFocus={() => {
|
||||
// 输入框获得焦点时,关闭其他面板(但不要关闭键盘)
|
||||
if (activePanel !== 'none' && activePanel !== 'mention') {
|
||||
closePanel();
|
||||
}
|
||||
}}
|
||||
currentUser={currentUser}
|
||||
otherUser={otherUser}
|
||||
getSenderInfo={getSenderInfo}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 表情面板 */}
|
||||
{activePanel === 'emoji' && keyboardHeight === 0 && (
|
||||
<View style={[styles.panelWrapper, styles.emojiPanelWrapper, { height: PANEL_HEIGHTS.emoji + insets.bottom, paddingBottom: insets.bottom }]}>
|
||||
<EmojiPanel
|
||||
onInsertEmoji={handleInsertEmoji}
|
||||
onInsertSticker={handleSendSticker}
|
||||
onClose={closePanel}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 更多功能面板 */}
|
||||
{activePanel === 'more' && keyboardHeight === 0 && (
|
||||
<View style={[styles.panelWrapper, { height: PANEL_HEIGHTS.more + insets.bottom, paddingBottom: insets.bottom }]}>
|
||||
<MorePanel
|
||||
onAction={handleMoreAction}
|
||||
disabledActionIds={!isGroupChat && !canSendPrivateImage ? ['image', 'camera'] : []}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 发送图片加载遮罩 */}
|
||||
{sendingImage && (
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.overlayContent}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
<Text style={styles.overlayText}>发送图片中...</Text>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* @成员选择浮层 - 绝对定位在输入框上方,覆盖消息列表 */}
|
||||
{activePanel === 'mention' && (
|
||||
<View
|
||||
style={[
|
||||
styles.mentionPanelFloat,
|
||||
{
|
||||
bottom: keyboardHeight > 0
|
||||
? keyboardHeight + inputWrapperHeight
|
||||
: inputWrapperHeight + (insets.bottom > 0 ? insets.bottom : 0),
|
||||
height: PANEL_HEIGHTS.mention,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MentionPanel
|
||||
members={groupMembers}
|
||||
currentUserId={currentUserId}
|
||||
mentionQuery={mentionQuery}
|
||||
currentUserRole={currentUserRole}
|
||||
onSelectMention={handleSelectMention}
|
||||
onMentionAll={handleMentionAll}
|
||||
onClose={closePanel}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 长按菜单 */}
|
||||
<LongPressMenu
|
||||
visible={longPressMenuVisible}
|
||||
message={selectedMessage}
|
||||
currentUserId={currentUserId}
|
||||
position={menuPosition}
|
||||
onClose={hideLongPressMenu}
|
||||
onReply={handleReplyMessage}
|
||||
onRecall={handleRecall}
|
||||
onDelete={handleDeleteMessage}
|
||||
/>
|
||||
|
||||
{/* 图片查看器 */}
|
||||
<ImageGallery
|
||||
visible={showImageViewer}
|
||||
images={chatImages.map(img => ({
|
||||
id: img.id || img.url || String(Math.random()),
|
||||
url: img.url || img.uri || ''
|
||||
}))}
|
||||
initialIndex={selectedImageIndex}
|
||||
onClose={handleCloseImageViewer}
|
||||
enableSave
|
||||
/>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatScreen;
|
||||
480
src/screens/message/CreateGroupScreen.tsx
Normal file
480
src/screens/message/CreateGroupScreen.tsx
Normal file
@@ -0,0 +1,480 @@
|
||||
/**
|
||||
* CreateGroupScreen 创建群聊界面
|
||||
* 允许用户创建新的群聊,设置群名称、描述,并选择初始成员
|
||||
* 支持响应式布局
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
TextInput,
|
||||
FlatList,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
|
||||
import { groupService } from '../../services/groupService';
|
||||
import { uploadService } from '../../services/uploadService';
|
||||
import { Avatar, Text, Button, Loading } from '../../components/common';
|
||||
import { User } from '../../types';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import MutualFollowSelectorModal from './components/MutualFollowSelectorModal';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
const CreateGroupScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
|
||||
// 表单状态
|
||||
const [groupName, setGroupName] = useState('');
|
||||
const [groupDescription, setGroupDescription] = useState('');
|
||||
const [groupAvatar, setGroupAvatar] = useState<string | null>(null);
|
||||
const [selectedMembers, setSelectedMembers] = useState<User[]>([]);
|
||||
const [selectedMemberIds, setSelectedMemberIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// 提交状态
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
|
||||
// 邀请成员模态框状态
|
||||
const [inviteModalVisible, setInviteModalVisible] = useState(false);
|
||||
|
||||
// 移除已选成员
|
||||
const removeSelectedMember = (userId: string) => {
|
||||
const newSelectedIds = new Set(selectedMemberIds);
|
||||
newSelectedIds.delete(userId);
|
||||
setSelectedMemberIds(newSelectedIds);
|
||||
setSelectedMembers(prev => prev.filter(m => m.id !== userId));
|
||||
};
|
||||
|
||||
// 选择群头像
|
||||
const handleSelectAvatar = async () => {
|
||||
try {
|
||||
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('提示', '需要访问相册权限才能选择头像');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: 'images',
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets && result.assets.length > 0) {
|
||||
const selectedAsset = result.assets[0];
|
||||
setUploadingAvatar(true);
|
||||
|
||||
try {
|
||||
const uploadResult = await uploadService.uploadGroupAvatar({
|
||||
uri: selectedAsset.uri,
|
||||
type: selectedAsset.mimeType || 'image/jpeg',
|
||||
});
|
||||
|
||||
if (uploadResult && uploadResult.url) {
|
||||
setGroupAvatar(uploadResult.url);
|
||||
} else {
|
||||
Alert.alert('上传失败', '头像上传失败,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传头像失败:', error);
|
||||
Alert.alert('上传失败', '头像上传失败,请重试');
|
||||
} finally {
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('选择头像失败:', error);
|
||||
Alert.alert('错误', '选择头像时发生错误');
|
||||
}
|
||||
};
|
||||
|
||||
// 创建群组
|
||||
const handleCreateGroup = async () => {
|
||||
// 验证群名称
|
||||
if (!groupName.trim()) {
|
||||
Alert.alert('提示', '请输入群名称');
|
||||
return;
|
||||
}
|
||||
|
||||
if (groupName.length > 50) {
|
||||
Alert.alert('提示', '群名称最多50个字符');
|
||||
return;
|
||||
}
|
||||
|
||||
if (groupDescription.length > 500) {
|
||||
Alert.alert('提示', '群描述最多500个字符');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const memberIds = selectedMembers.map(m => m.id);
|
||||
const response = await groupService.createGroup({
|
||||
name: groupName.trim(),
|
||||
description: groupDescription.trim() || undefined,
|
||||
member_ids: memberIds.length > 0 ? memberIds : undefined,
|
||||
});
|
||||
|
||||
// 如果设置了头像,更新群头像
|
||||
if (groupAvatar && response.id) {
|
||||
try {
|
||||
await groupService.setGroupAvatar(response.id, groupAvatar);
|
||||
} catch (avatarError) {
|
||||
console.error('设置群头像失败:', avatarError);
|
||||
}
|
||||
}
|
||||
|
||||
Alert.alert('成功', '群组创建成功', [
|
||||
{
|
||||
text: '确定',
|
||||
onPress: () => navigation.goBack(),
|
||||
},
|
||||
]);
|
||||
} catch (error: any) {
|
||||
console.error('创建群组失败:', error);
|
||||
Alert.alert('创建失败', error.message || '创建群组时发生错误,请重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染已选成员
|
||||
const renderSelectedMember = ({ item }: { item: User }) => (
|
||||
<View style={styles.selectedMemberItem}>
|
||||
<Avatar source={item.avatar} size={48} name={item.nickname} />
|
||||
<TouchableOpacity
|
||||
style={styles.removeMemberButton}
|
||||
onPress={() => removeSelectedMember(item.id)}
|
||||
>
|
||||
<View style={styles.removeIconContainer}>
|
||||
<MaterialCommunityIcons name="close" size={12} color={colors.background.paper} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
<Text variant="caption" numberOfLines={1} style={styles.selectedMemberName}>
|
||||
{item.nickname}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const handleConfirmMembers = (users: User[]) => {
|
||||
setSelectedMembers(users);
|
||||
setSelectedMemberIds(new Set(users.map(user => user.id)));
|
||||
setInviteModalVisible(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 群头像和名称区域 */}
|
||||
<View style={styles.headerSection}>
|
||||
<View style={styles.avatarContainer}>
|
||||
<TouchableOpacity style={styles.avatarWrapper} onPress={handleSelectAvatar} disabled={uploadingAvatar}>
|
||||
{uploadingAvatar ? (
|
||||
<View style={[styles.avatarPlaceholder, { width: 80, height: 80 }]}>
|
||||
<Loading />
|
||||
</View>
|
||||
) : groupAvatar ? (
|
||||
<Image source={{ uri: groupAvatar }} style={styles.avatarImage} />
|
||||
) : (
|
||||
<Avatar
|
||||
source={undefined}
|
||||
size={80}
|
||||
name={groupName || '群'}
|
||||
/>
|
||||
)}
|
||||
<View style={styles.avatarBadge}>
|
||||
<MaterialCommunityIcons name="camera" size={14} color={colors.background.paper} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.nameInputContainer}>
|
||||
<Text variant="label" style={styles.inputLabel}>
|
||||
群名称 <Text color={colors.error.main}>*</Text>
|
||||
</Text>
|
||||
<TextInput
|
||||
style={styles.nameInput}
|
||||
value={groupName}
|
||||
onChangeText={setGroupName}
|
||||
placeholder="请输入群名称"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
maxLength={50}
|
||||
/>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.charCount}>
|
||||
{groupName.length}/50
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 群描述输入 */}
|
||||
<View style={styles.section}>
|
||||
<Text variant="label" style={styles.sectionTitle}>
|
||||
群描述
|
||||
</Text>
|
||||
<View style={styles.textAreaContainer}>
|
||||
<TextInput
|
||||
style={styles.textArea}
|
||||
value={groupDescription}
|
||||
onChangeText={setGroupDescription}
|
||||
placeholder="介绍一下这个群聊吧..."
|
||||
placeholderTextColor={colors.text.hint}
|
||||
maxLength={500}
|
||||
multiline
|
||||
numberOfLines={4}
|
||||
textAlignVertical="top"
|
||||
/>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.textAreaCharCount}>
|
||||
{groupDescription.length}/500
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 已选成员 */}
|
||||
{selectedMembers.length > 0 && (
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text variant="label" style={styles.sectionTitle}>
|
||||
已选成员
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.primary.main}>
|
||||
{selectedMembers.length}人
|
||||
</Text>
|
||||
</View>
|
||||
<FlatList
|
||||
data={selectedMembers}
|
||||
renderItem={renderSelectedMember}
|
||||
keyExtractor={item => item.id}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.selectedMembersList}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 邀请成员按钮 */}
|
||||
<TouchableOpacity
|
||||
style={styles.inviteButton}
|
||||
onPress={() => setInviteModalVisible(true)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.inviteIconContainer}>
|
||||
<MaterialCommunityIcons name="account-plus" size={24} color={colors.primary.main} />
|
||||
</View>
|
||||
<View style={styles.inviteTextContainer}>
|
||||
<Text variant="body" style={styles.inviteTitle}>邀请成员</Text>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
仅可从互关好友中选择成员加入群聊
|
||||
</Text>
|
||||
</View>
|
||||
<MaterialCommunityIcons name="chevron-right" size={24} color={colors.text.hint} />
|
||||
</TouchableOpacity>
|
||||
</ScrollView>
|
||||
|
||||
{/* 创建按钮 */}
|
||||
<View style={styles.footer}>
|
||||
<Button
|
||||
title="创建群聊"
|
||||
onPress={handleCreateGroup}
|
||||
loading={submitting}
|
||||
disabled={!groupName.trim() || submitting}
|
||||
fullWidth
|
||||
size="lg"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<MutualFollowSelectorModal
|
||||
visible={inviteModalVisible}
|
||||
title="邀请成员"
|
||||
confirmText="完成"
|
||||
initialSelectedIds={Array.from(selectedMemberIds)}
|
||||
onClose={() => setInviteModalVisible(false)}
|
||||
onConfirm={handleConfirmMembers}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: spacing.lg,
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
// 头部区域样式
|
||||
headerSection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
avatarContainer: {
|
||||
marginRight: spacing.lg,
|
||||
},
|
||||
avatarWrapper: {
|
||||
position: 'relative',
|
||||
},
|
||||
avatarImage: {
|
||||
width: 80,
|
||||
height: 80,
|
||||
borderRadius: 40,
|
||||
},
|
||||
avatarPlaceholder: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: 40,
|
||||
},
|
||||
avatarBadge: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
backgroundColor: colors.primary.main,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background.paper,
|
||||
},
|
||||
nameInputContainer: {
|
||||
flex: 1,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
inputLabel: {
|
||||
marginBottom: spacing.sm,
|
||||
fontWeight: '600',
|
||||
},
|
||||
nameInput: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
fontSize: fontSizes.lg,
|
||||
color: colors.text.primary,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
charCount: {
|
||||
textAlign: 'right',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
// 区域样式
|
||||
section: {
|
||||
marginBottom: spacing.xl,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontWeight: '600',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
// 文本域样式
|
||||
textAreaContainer: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
padding: spacing.md,
|
||||
},
|
||||
textArea: {
|
||||
minHeight: 100,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
lineHeight: 22,
|
||||
},
|
||||
textAreaCharCount: {
|
||||
textAlign: 'right',
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
// 已选成员样式
|
||||
selectedMembersList: {
|
||||
paddingVertical: spacing.sm,
|
||||
},
|
||||
selectedMemberItem: {
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.lg,
|
||||
width: 64,
|
||||
},
|
||||
removeMemberButton: {
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
right: 4,
|
||||
},
|
||||
removeIconContainer: {
|
||||
backgroundColor: colors.text.secondary,
|
||||
borderRadius: 10,
|
||||
width: 20,
|
||||
height: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background.paper,
|
||||
},
|
||||
selectedMemberName: {
|
||||
marginTop: spacing.xs,
|
||||
textAlign: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
// 邀请按钮样式
|
||||
inviteButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.xl,
|
||||
...shadows.sm,
|
||||
},
|
||||
inviteIconContainer: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
inviteTextContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
inviteTitle: {
|
||||
fontWeight: '600',
|
||||
marginBottom: 2,
|
||||
},
|
||||
// 底部按钮样式
|
||||
footer: {
|
||||
padding: spacing.lg,
|
||||
paddingBottom: spacing.xl,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: colors.divider,
|
||||
},
|
||||
});
|
||||
|
||||
export default CreateGroupScreen;
|
||||
1563
src/screens/message/GroupInfoScreen.tsx
Normal file
1563
src/screens/message/GroupInfoScreen.tsx
Normal file
File diff suppressed because it is too large
Load Diff
231
src/screens/message/GroupInviteDetailScreen.tsx
Normal file
231
src/screens/message/GroupInviteDetailScreen.tsx
Normal file
@@ -0,0 +1,231 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { View, StyleSheet, ActivityIndicator, Alert, ScrollView } from 'react-native';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
||||
import { Avatar, Text } from '../../components/common';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { groupService } from '../../services/groupService';
|
||||
import { groupManager } from '../../stores/groupManager';
|
||||
import { GroupMemberResponse } from '../../types/dto';
|
||||
import { GroupInfoSummaryCard, DecisionFooter } from './components/GroupRequestShared';
|
||||
|
||||
type Route = RouteProp<RootStackParamList, 'GroupInviteDetail'>;
|
||||
type Navigation = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
const GroupInviteDetailScreen: React.FC = () => {
|
||||
const route = useRoute<Route>();
|
||||
const navigation = useNavigation<Navigation>();
|
||||
const { message } = route.params;
|
||||
const { extra_data } = message;
|
||||
|
||||
const [loadingMembers, setLoadingMembers] = useState(false);
|
||||
const [members, setMembers] = useState<GroupMemberResponse[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [memberCount, setMemberCount] = useState<number | null>(null);
|
||||
const [loadingGroup, setLoadingGroup] = useState(false);
|
||||
|
||||
const canAction = extra_data?.request_status === 'pending';
|
||||
|
||||
useEffect(() => {
|
||||
const loadGroup = async () => {
|
||||
if (!extra_data?.group_id) return;
|
||||
setLoadingGroup(true);
|
||||
try {
|
||||
const group = await groupManager.getGroup(extra_data.group_id);
|
||||
setMemberCount(group.member_count ?? null);
|
||||
} catch {
|
||||
setMemberCount(null);
|
||||
} finally {
|
||||
setLoadingGroup(false);
|
||||
}
|
||||
};
|
||||
loadGroup();
|
||||
}, [extra_data?.group_id]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadMembers = async () => {
|
||||
if (!extra_data?.group_id) return;
|
||||
setLoadingMembers(true);
|
||||
try {
|
||||
const res = await groupManager.getMembers(extra_data.group_id, 1, 100);
|
||||
const admins = (res.list || []).filter(m => m.role === 'owner' || m.role === 'admin');
|
||||
setMembers(admins);
|
||||
} catch {
|
||||
setMembers([]);
|
||||
} finally {
|
||||
setLoadingMembers(false);
|
||||
}
|
||||
};
|
||||
loadMembers();
|
||||
}, [extra_data?.group_id]);
|
||||
|
||||
const handleDecision = async (approve: boolean) => {
|
||||
const flag = extra_data?.flag;
|
||||
if (!flag) {
|
||||
Alert.alert('提示', '缺少邀请标识,无法处理');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await groupService.respondInvite({ flag, approve });
|
||||
Alert.alert('成功', approve ? '已同意加入群聊' : '已拒绝邀请', [
|
||||
{ text: '确定', onPress: () => navigation.goBack() },
|
||||
]);
|
||||
} catch {
|
||||
Alert.alert('操作失败', '请稍后重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const groupNo = useMemo(() => extra_data?.group_id || '-', [extra_data?.group_id]);
|
||||
const formatGroupNo = (id: string) => {
|
||||
if (id.length <= 12) return id;
|
||||
return `${id.slice(0, 6)}...${id.slice(-4)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
<GroupInfoSummaryCard
|
||||
groupName={extra_data?.group_name}
|
||||
groupAvatar={extra_data?.group_avatar}
|
||||
groupNo={formatGroupNo(groupNo)}
|
||||
groupDescription={extra_data?.group_description}
|
||||
memberCountText={loadingGroup ? '人数加载中...' : `${memberCount ?? '-'} 人`}
|
||||
/>
|
||||
|
||||
<View style={styles.card}>
|
||||
<View style={styles.cardHeader}>
|
||||
<View style={styles.cardIconContainer}>
|
||||
<MaterialCommunityIcons name="account-multiple" size={18} color={colors.info.main} />
|
||||
</View>
|
||||
<Text variant="label" style={styles.cardTitle}>群主与管理员</Text>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.memberCount}>
|
||||
{members.length}人
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{loadingMembers ? (
|
||||
<View style={styles.loadingWrap}>
|
||||
<ActivityIndicator color={colors.primary.main} />
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.memberPreview}>
|
||||
{members.slice(0, 12).map((member, index) => (
|
||||
<View
|
||||
key={member.id}
|
||||
style={[
|
||||
styles.memberAvatar,
|
||||
index === 0 && styles.memberAvatarFirst,
|
||||
{ zIndex: index + 1 },
|
||||
]}
|
||||
>
|
||||
<Avatar
|
||||
source={member.user?.avatar || ''}
|
||||
size={44}
|
||||
name={member.user?.nickname || member.nickname}
|
||||
/>
|
||||
{member.role === 'owner' && (
|
||||
<View style={styles.ownerBadge}>
|
||||
<Text variant="caption" color={colors.background.paper} style={styles.ownerBadgeText}>主</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
))}
|
||||
{members.length === 0 && (
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
暂无可展示的管理员信息
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<DecisionFooter
|
||||
canAction={canAction}
|
||||
submitting={submitting}
|
||||
onReject={() => handleDecision(false)}
|
||||
onApprove={() => handleDecision(true)}
|
||||
processedText="该邀请已处理"
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: spacing.lg,
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.lg,
|
||||
...shadows.sm,
|
||||
},
|
||||
cardHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
cardIconContainer: {
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 15,
|
||||
backgroundColor: colors.info.light + '30',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
cardTitle: {
|
||||
fontWeight: '600',
|
||||
flex: 1,
|
||||
},
|
||||
memberCount: {
|
||||
marginRight: spacing.xs,
|
||||
},
|
||||
loadingWrap: {
|
||||
paddingVertical: spacing.md,
|
||||
alignItems: 'center',
|
||||
},
|
||||
memberPreview: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
memberAvatar: {
|
||||
marginLeft: -8,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
memberAvatarFirst: {
|
||||
marginLeft: 0,
|
||||
},
|
||||
ownerBadge: {
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
right: -2,
|
||||
backgroundColor: colors.warning.main,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 4,
|
||||
paddingVertical: 1,
|
||||
},
|
||||
ownerBadgeText: {
|
||||
fontSize: 10,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
export default GroupInviteDetailScreen;
|
||||
722
src/screens/message/GroupMembersScreen.tsx
Normal file
722
src/screens/message/GroupMembersScreen.tsx
Normal file
@@ -0,0 +1,722 @@
|
||||
/**
|
||||
* GroupMembersScreen 群成员管理界面
|
||||
* 显示群成员列表,支持管理员管理成员
|
||||
* 支持响应式网格布局
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
FlatList,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
RefreshControl,
|
||||
ListRenderItem,
|
||||
Modal,
|
||||
TextInput,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { useAuthStore } from '../../stores';
|
||||
import { groupService } from '../../services/groupService';
|
||||
import { groupManager } from '../../stores/groupManager';
|
||||
import { Avatar, Text, Button, Loading, EmptyState, Divider, ResponsiveContainer } from '../../components/common';
|
||||
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
|
||||
import {
|
||||
GroupMemberResponse,
|
||||
GroupRole,
|
||||
} from '../../types/dto';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
|
||||
// 网格布局配置
|
||||
const GRID_CONFIG = {
|
||||
mobile: { columns: 1, itemWidth: '100%' },
|
||||
tablet: { columns: 2, itemWidth: '48%' },
|
||||
desktop: { columns: 3, itemWidth: '31%' },
|
||||
};
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
type GroupMembersRouteProp = RouteProp<RootStackParamList, 'GroupMembers'>;
|
||||
|
||||
// 成员分组
|
||||
interface MemberGroup {
|
||||
title: string;
|
||||
data: GroupMemberResponse[];
|
||||
}
|
||||
|
||||
const GroupMembersScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const route = useRoute<GroupMembersRouteProp>();
|
||||
const { groupId } = route.params;
|
||||
const { currentUser } = useAuthStore();
|
||||
|
||||
// 响应式布局
|
||||
const { isDesktop, isTablet, width } = useResponsive();
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
|
||||
// 计算网格列数
|
||||
const gridConfig = useMemo(() => {
|
||||
if (width >= 1024) return GRID_CONFIG.desktop;
|
||||
if (width >= 768) return GRID_CONFIG.tablet;
|
||||
return GRID_CONFIG.mobile;
|
||||
}, [width]);
|
||||
|
||||
// 成员列表状态
|
||||
const [members, setMembers] = useState<GroupMemberResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
// 当前用户的成员信息
|
||||
const [currentMember, setCurrentMember] = useState<GroupMemberResponse | null>(null);
|
||||
|
||||
// 操作模态框状态
|
||||
const [actionModalVisible, setActionModalVisible] = useState(false);
|
||||
const [selectedMember, setSelectedMember] = useState<GroupMemberResponse | null>(null);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
|
||||
// 设置群昵称模态框
|
||||
const [nicknameModalVisible, setNicknameModalVisible] = useState(false);
|
||||
const [newNickname, setNewNickname] = useState('');
|
||||
|
||||
// 计算当前用户的角色
|
||||
const isOwner = currentMember?.role === 'owner';
|
||||
const isAdmin = currentMember?.role === 'admin' || isOwner;
|
||||
|
||||
// 加载成员列表
|
||||
const loadMembers = useCallback(async (pageNum: number = 1, refresh: boolean = false) => {
|
||||
if (!hasMore && !refresh) return;
|
||||
|
||||
try {
|
||||
const response = await groupManager.getMembers(groupId, pageNum, 50);
|
||||
|
||||
if (refresh) {
|
||||
setMembers(response.list);
|
||||
setPage(1);
|
||||
} else {
|
||||
setMembers(prev => [...prev, ...response.list]);
|
||||
}
|
||||
|
||||
setHasMore(response.list.length === 50);
|
||||
|
||||
// 查找当前用户的成员信息
|
||||
const myMember = response.list.find(m => m.user_id === currentUser?.id);
|
||||
if (myMember) {
|
||||
setCurrentMember(myMember);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载成员列表失败:', error);
|
||||
}
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}, [groupId, currentUser, hasMore]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadMembers(1, true);
|
||||
}, [groupId]);
|
||||
|
||||
// 下拉刷新
|
||||
const onRefresh = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
setHasMore(true);
|
||||
loadMembers(1, true);
|
||||
}, [loadMembers]);
|
||||
|
||||
// 加载更多
|
||||
const loadMore = useCallback(() => {
|
||||
if (!loading && hasMore) {
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
loadMembers(nextPage);
|
||||
}
|
||||
}, [loading, hasMore, page, loadMembers]);
|
||||
|
||||
// 按角色分组
|
||||
const groupMembers = useCallback((): MemberGroup[] => {
|
||||
const owners = members.filter(m => m.role === 'owner');
|
||||
const admins = members.filter(m => m.role === 'admin');
|
||||
const normalMembers = members.filter(m => m.role === 'member');
|
||||
|
||||
const groups: MemberGroup[] = [];
|
||||
|
||||
if (owners.length > 0) {
|
||||
groups.push({ title: '群主', data: owners });
|
||||
}
|
||||
if (admins.length > 0) {
|
||||
groups.push({ title: '管理员', data: admins });
|
||||
}
|
||||
if (normalMembers.length > 0) {
|
||||
groups.push({ title: '成员', data: normalMembers });
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [members]);
|
||||
|
||||
// 打开操作菜单
|
||||
const openActionModal = (member: GroupMemberResponse) => {
|
||||
// 不能操作群主
|
||||
if (member.role === 'owner') return;
|
||||
|
||||
// 普通成员不能操作其他人
|
||||
if (!isAdmin) return;
|
||||
|
||||
// 管理员不能操作其他管理员(只有群主可以)
|
||||
if (!isOwner && member.role === 'admin') return;
|
||||
|
||||
setSelectedMember(member);
|
||||
setActionModalVisible(true);
|
||||
};
|
||||
|
||||
// 设置/取消管理员
|
||||
const handleToggleAdmin = async () => {
|
||||
if (!selectedMember) return;
|
||||
|
||||
const newRole: GroupRole = selectedMember.role === 'admin' ? 'member' : 'admin';
|
||||
const actionText = newRole === 'admin' ? '设为管理员' : '取消管理员';
|
||||
|
||||
Alert.alert(
|
||||
actionText,
|
||||
`确定要${actionText} "${selectedMember.user?.nickname || selectedMember.nickname}" 吗?`,
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '确定',
|
||||
onPress: async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await groupService.setMemberRole(groupId, selectedMember.user_id, {
|
||||
role: newRole as 'admin' | 'member',
|
||||
});
|
||||
|
||||
// 更新本地数据
|
||||
setMembers(prev => prev.map(m => {
|
||||
if (m.user_id === selectedMember.user_id) {
|
||||
return { ...m, role: newRole };
|
||||
}
|
||||
return m;
|
||||
}));
|
||||
|
||||
setActionModalVisible(false);
|
||||
Alert.alert('成功', `已${actionText}`);
|
||||
} catch (error: any) {
|
||||
console.error('设置角色失败:', error);
|
||||
Alert.alert('错误', error.message || '操作失败');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// 禁言/解禁成员
|
||||
const handleToggleMute = async () => {
|
||||
if (!selectedMember) return;
|
||||
|
||||
const newMuted = !selectedMember.muted;
|
||||
const actionText = newMuted ? '禁言' : '解禁';
|
||||
|
||||
Alert.alert(
|
||||
`${actionText}成员`,
|
||||
`确定要${actionText} "${selectedMember.user?.nickname || selectedMember.nickname}" 吗?`,
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '确定',
|
||||
onPress: async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
// duration: 0 表示解除禁言,-1 表示永久禁言
|
||||
await groupService.muteMember(groupId, selectedMember.user_id, newMuted ? -1 : 0);
|
||||
|
||||
// 更新本地数据
|
||||
setMembers(prev => prev.map(m => {
|
||||
if (m.user_id === selectedMember.user_id) {
|
||||
return { ...m, muted: newMuted };
|
||||
}
|
||||
return m;
|
||||
}));
|
||||
|
||||
setActionModalVisible(false);
|
||||
Alert.alert('成功', `已${actionText}`);
|
||||
} catch (error: any) {
|
||||
console.error('禁言操作失败:', error);
|
||||
Alert.alert('错误', error.message || '操作失败');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// 移除成员
|
||||
const handleRemoveMember = () => {
|
||||
if (!selectedMember) return;
|
||||
|
||||
Alert.alert(
|
||||
'移除成员',
|
||||
`确定要将 "${selectedMember.user?.nickname || selectedMember.nickname}" 移出群聊吗?`,
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '移除',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await groupService.removeMember(groupId, selectedMember.user_id);
|
||||
|
||||
// 更新本地数据
|
||||
setMembers(prev => prev.filter(m => m.user_id !== selectedMember.user_id));
|
||||
|
||||
setActionModalVisible(false);
|
||||
Alert.alert('成功', '已移除成员');
|
||||
} catch (error: any) {
|
||||
console.error('移除成员失败:', error);
|
||||
Alert.alert('错误', error.message || '操作失败');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// 打开设置群昵称模态框
|
||||
const openNicknameModal = (member: GroupMemberResponse) => {
|
||||
setSelectedMember(member);
|
||||
setNewNickname(member.nickname || member.user?.nickname || '');
|
||||
setNicknameModalVisible(true);
|
||||
};
|
||||
|
||||
// 设置群昵称(仅限自己)
|
||||
const handleSetNickname = async () => {
|
||||
if (!selectedMember || selectedMember.user_id !== currentUser?.id) return;
|
||||
|
||||
setActionLoading(true);
|
||||
try {
|
||||
await groupService.setNickname(groupId, {
|
||||
nickname: newNickname.trim() || selectedMember.user?.nickname || '',
|
||||
});
|
||||
|
||||
// 更新本地数据
|
||||
setMembers(prev => prev.map(m => {
|
||||
if (m.user_id === selectedMember.user_id) {
|
||||
return { ...m, nickname: newNickname.trim() };
|
||||
}
|
||||
return m;
|
||||
}));
|
||||
|
||||
setNicknameModalVisible(false);
|
||||
Alert.alert('成功', '群昵称已更新');
|
||||
} catch (error: any) {
|
||||
console.error('设置群昵称失败:', error);
|
||||
Alert.alert('错误', error.message || '操作失败');
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取角色标签颜色
|
||||
const getRoleBadgeColor = (role: GroupRole): string => {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return colors.warning.main;
|
||||
case 'admin':
|
||||
return colors.primary.main;
|
||||
default:
|
||||
return colors.text.hint;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取角色标签文本
|
||||
const getRoleBadgeText = (role: GroupRole): string => {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return '群主';
|
||||
case 'admin':
|
||||
return '管理员';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染成员项
|
||||
const renderMember: ListRenderItem<GroupMemberResponse> = ({ item }) => {
|
||||
const isSelf = item.user_id === currentUser?.id;
|
||||
const canManage = isAdmin && item.role !== 'owner' && (isOwner || item.role === 'member');
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.memberItem}
|
||||
onPress={() => canManage ? openActionModal(item) : null}
|
||||
onLongPress={() => isSelf ? openNicknameModal(item) : null}
|
||||
activeOpacity={canManage ? 0.7 : 1}
|
||||
>
|
||||
<Avatar
|
||||
source={item.user?.avatar}
|
||||
size={48}
|
||||
name={item.user?.nickname || item.nickname}
|
||||
/>
|
||||
<View style={styles.memberInfo}>
|
||||
<View style={styles.memberNameRow}>
|
||||
<Text variant="body" style={styles.memberName}>
|
||||
{item.nickname || item.user?.nickname || '未知用户'}
|
||||
</Text>
|
||||
{item.role !== 'member' && (
|
||||
<View style={[styles.roleBadge, { backgroundColor: getRoleBadgeColor(item.role) }]}>
|
||||
<Text variant="label" color={colors.background.paper}>
|
||||
{getRoleBadgeText(item.role)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
@{item.user?.username || 'unknown'}
|
||||
</Text>
|
||||
{item.muted && (
|
||||
<View style={styles.mutedBadge}>
|
||||
<MaterialCommunityIcons name="microphone-off" size={12} color={colors.error.main} />
|
||||
<Text variant="label" color={colors.error.main}> 禁言中</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
{canManage && (
|
||||
<MaterialCommunityIcons name="dots-vertical" size={20} color={colors.text.hint} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染分组头部
|
||||
const renderSectionHeader = (title: string, count: number) => (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text variant="label" color={colors.text.secondary}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.hint}>
|
||||
{count}人
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmpty = () => {
|
||||
if (loading) return <Loading />;
|
||||
|
||||
return (
|
||||
<EmptyState
|
||||
title="暂无成员"
|
||||
description="群组还没有成员"
|
||||
icon="account-group-outline"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染操作菜单
|
||||
const renderActionModal = () => {
|
||||
if (!selectedMember) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={actionModalVisible}
|
||||
animationType="slide"
|
||||
transparent
|
||||
onRequestClose={() => setActionModalVisible(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContent}>
|
||||
<View style={styles.modalHeader}>
|
||||
<Avatar
|
||||
source={selectedMember.user?.avatar}
|
||||
size={60}
|
||||
name={selectedMember.user?.nickname || selectedMember.nickname}
|
||||
/>
|
||||
<Text variant="h3" style={styles.modalTitle}>
|
||||
{selectedMember.user?.nickname || selectedMember.nickname}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
@{selectedMember.user?.username}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Divider />
|
||||
|
||||
{/* 设置/取消管理员(仅群主) */}
|
||||
{isOwner && selectedMember.role !== 'owner' && (
|
||||
<TouchableOpacity
|
||||
style={styles.actionItem}
|
||||
onPress={handleToggleAdmin}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={selectedMember.role === 'admin' ? 'account-remove' : 'account-plus'}
|
||||
size={22}
|
||||
color={colors.text.primary}
|
||||
/>
|
||||
<Text variant="body" style={styles.actionText}>
|
||||
{selectedMember.role === 'admin' ? '取消管理员' : '设为管理员'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* 禁言/解禁 */}
|
||||
<TouchableOpacity
|
||||
style={styles.actionItem}
|
||||
onPress={handleToggleMute}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={selectedMember.muted ? 'microphone' : 'microphone-off'}
|
||||
size={22}
|
||||
color={selectedMember.muted ? colors.success.main : colors.error.main}
|
||||
/>
|
||||
<Text
|
||||
variant="body"
|
||||
color={selectedMember.muted ? colors.success.main : colors.error.main}
|
||||
style={styles.actionText}
|
||||
>
|
||||
{selectedMember.muted ? '解除禁言' : '禁言'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 移除成员 */}
|
||||
<TouchableOpacity
|
||||
style={styles.actionItem}
|
||||
onPress={handleRemoveMember}
|
||||
disabled={actionLoading}
|
||||
>
|
||||
<MaterialCommunityIcons name="account-remove" size={22} color={colors.error.main} />
|
||||
<Text variant="body" color={colors.error.main} style={styles.actionText}>
|
||||
移除成员
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Button
|
||||
title="取消"
|
||||
variant="outline"
|
||||
onPress={() => setActionModalVisible(false)}
|
||||
fullWidth
|
||||
disabled={actionLoading}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染设置群昵称模态框
|
||||
const renderNicknameModal = () => {
|
||||
if (!selectedMember) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={nicknameModalVisible}
|
||||
animationType="slide"
|
||||
transparent
|
||||
onRequestClose={() => setNicknameModalVisible(false)}
|
||||
>
|
||||
<View style={styles.modalOverlay}>
|
||||
<View style={styles.modalContent}>
|
||||
<Text variant="h3" style={styles.modalTitle}>设置群昵称</Text>
|
||||
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.inputLabel}>
|
||||
在本群的昵称(选填)
|
||||
</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
value={newNickname}
|
||||
onChangeText={setNewNickname}
|
||||
placeholder={selectedMember.user?.nickname || '请输入群昵称'}
|
||||
placeholderTextColor={colors.text.hint}
|
||||
maxLength={20}
|
||||
/>
|
||||
|
||||
<View style={styles.modalButtons}>
|
||||
<Button
|
||||
title="取消"
|
||||
variant="outline"
|
||||
onPress={() => setNicknameModalVisible(false)}
|
||||
style={styles.modalButton}
|
||||
disabled={actionLoading}
|
||||
/>
|
||||
<Button
|
||||
title="保存"
|
||||
onPress={handleSetNickname}
|
||||
loading={actionLoading}
|
||||
style={styles.modalButton}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染分组列表
|
||||
const renderGroupedList = () => {
|
||||
const groups = groupMembers();
|
||||
|
||||
if (groups.length === 0) {
|
||||
return renderEmpty();
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={groups}
|
||||
keyExtractor={(item) => item.title}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.3}
|
||||
showsVerticalScrollIndicator={false}
|
||||
renderItem={({ item: group }) => (
|
||||
<View style={styles.section}>
|
||||
{renderSectionHeader(group.title, group.data.length)}
|
||||
{group.data.map((member, index) => (
|
||||
<View key={member.id}>
|
||||
{renderMember({ item: member, index } as any)}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
{loading ? <Loading /> : renderGroupedList()}
|
||||
{renderActionModal()}
|
||||
{renderNicknameModal()}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
section: {
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
memberItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
memberInfo: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
memberNameRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 2,
|
||||
},
|
||||
memberName: {
|
||||
fontWeight: '500',
|
||||
},
|
||||
roleBadge: {
|
||||
paddingHorizontal: spacing.xs,
|
||||
paddingVertical: 2,
|
||||
borderRadius: borderRadius.sm,
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
mutedBadge: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginTop: 2,
|
||||
},
|
||||
// 模态框样式
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderTopLeftRadius: borderRadius.lg,
|
||||
borderTopRightRadius: borderRadius.lg,
|
||||
padding: spacing.lg,
|
||||
maxHeight: '80%',
|
||||
},
|
||||
modalHeader: {
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
modalTitle: {
|
||||
fontWeight: '700',
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
actionItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
actionText: {
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
inputLabel: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
modalButtons: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
modalButton: {
|
||||
flex: 1,
|
||||
marginHorizontal: spacing.xs,
|
||||
},
|
||||
});
|
||||
|
||||
export default GroupMembersScreen;
|
||||
197
src/screens/message/GroupRequestDetailScreen.tsx
Normal file
197
src/screens/message/GroupRequestDetailScreen.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, Alert, ScrollView } from 'react-native';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
|
||||
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
||||
import { Avatar, Text } from '../../components/common';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { groupService } from '../../services/groupService';
|
||||
import { groupManager } from '../../stores/groupManager';
|
||||
import { userManager } from '../../stores/userManager';
|
||||
import { GroupInfoSummaryCard, DecisionFooter } from './components/GroupRequestShared';
|
||||
|
||||
type Route = RouteProp<RootStackParamList, 'GroupRequestDetail'>;
|
||||
type Navigation = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
const GroupRequestDetailScreen: React.FC = () => {
|
||||
const route = useRoute<Route>();
|
||||
const navigation = useNavigation<Navigation>();
|
||||
const { message } = route.params;
|
||||
const { extra_data } = message;
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [memberCount, setMemberCount] = useState<number | null>(null);
|
||||
const [loadingGroup, setLoadingGroup] = useState(false);
|
||||
|
||||
const requestType = extra_data?.request_type;
|
||||
const requestStatus = extra_data?.request_status;
|
||||
const reviewerName = extra_data?.actor_name || extra_data?.operator_name || '管理员';
|
||||
const canAction =
|
||||
requestStatus === 'pending' &&
|
||||
(message.system_type === 'group_join_apply' || message.system_type === 'group_invite');
|
||||
const processedText =
|
||||
requestStatus === 'accepted'
|
||||
? `${reviewerName}已同意`
|
||||
: requestStatus === 'rejected'
|
||||
? `${reviewerName}已拒绝`
|
||||
: '该请求已处理';
|
||||
|
||||
const applicantName = useMemo(() => {
|
||||
if (requestType === 'invite') {
|
||||
return extra_data?.target_user_name || '被邀请用户';
|
||||
}
|
||||
return extra_data?.actor_name || '申请用户';
|
||||
}, [requestType, extra_data]);
|
||||
|
||||
const applicantAvatar = useMemo(() => {
|
||||
if (requestType === 'invite') {
|
||||
return extra_data?.target_user_avatar || '';
|
||||
}
|
||||
return extra_data?.avatar_url || '';
|
||||
}, [requestType, extra_data]);
|
||||
|
||||
const applicantId = useMemo(() => {
|
||||
if (requestType === 'invite') {
|
||||
return extra_data?.target_user_id;
|
||||
}
|
||||
return extra_data?.actor_id_str;
|
||||
}, [requestType, extra_data]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadGroupInfo = async () => {
|
||||
if (!extra_data?.group_id) return;
|
||||
setLoadingGroup(true);
|
||||
try {
|
||||
const group = await groupManager.getGroup(extra_data.group_id);
|
||||
setMemberCount(group.member_count ?? null);
|
||||
} catch {
|
||||
setMemberCount(null);
|
||||
} finally {
|
||||
setLoadingGroup(false);
|
||||
}
|
||||
};
|
||||
loadGroupInfo();
|
||||
}, [extra_data?.group_id]);
|
||||
|
||||
const handleDecision = async (approve: boolean) => {
|
||||
const flag = extra_data?.flag;
|
||||
if (!flag) {
|
||||
Alert.alert('提示', '缺少请求标识,无法处理');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (message.system_type === 'group_invite') {
|
||||
await groupService.respondInvite({
|
||||
flag,
|
||||
approve,
|
||||
});
|
||||
} else {
|
||||
await groupService.reviewJoinRequest({
|
||||
flag,
|
||||
approve,
|
||||
});
|
||||
}
|
||||
Alert.alert('成功', approve ? '已同意申请' : '已拒绝申请', [
|
||||
{ text: '确定', onPress: () => navigation.goBack() },
|
||||
]);
|
||||
} catch (error) {
|
||||
Alert.alert('操作失败', '请稍后重试');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenUser = async () => {
|
||||
if (!applicantId) return;
|
||||
const user = await userManager.getUserById(applicantId);
|
||||
if (user?.id) {
|
||||
navigation.navigate('UserProfile', { userId: String(user.id) });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<ScrollView style={styles.scrollView} contentContainerStyle={styles.scrollContent} showsVerticalScrollIndicator={false}>
|
||||
<GroupInfoSummaryCard
|
||||
groupName={extra_data?.group_name}
|
||||
groupAvatar={extra_data?.group_avatar}
|
||||
groupNo={extra_data?.group_id}
|
||||
groupDescription={extra_data?.group_description}
|
||||
memberCountText={loadingGroup ? '人数加载中...' : `${memberCount ?? '-'} 人`}
|
||||
/>
|
||||
<View style={styles.card}>
|
||||
<Text variant="label" style={styles.sectionTitle}>用户信息</Text>
|
||||
<TouchableOpacity style={styles.row} activeOpacity={0.7} onPress={handleOpenUser} disabled={!applicantId}>
|
||||
<Avatar source={applicantAvatar} size={44} name={applicantName} />
|
||||
<View style={styles.meta}>
|
||||
<Text variant="body" style={styles.name}>{applicantName}</Text>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
{requestType === 'invite' ? '被邀请加入' : '申请加入'}
|
||||
</Text>
|
||||
</View>
|
||||
{applicantId ? (
|
||||
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
|
||||
) : null}
|
||||
</TouchableOpacity>
|
||||
{requestType === 'invite' && (
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.subDesc}>
|
||||
邀请人:{extra_data?.actor_name || '群成员'}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<DecisionFooter
|
||||
canAction={canAction}
|
||||
submitting={submitting}
|
||||
onReject={() => handleDecision(false)}
|
||||
onApprove={() => handleDecision(true)}
|
||||
processedText={processedText}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: spacing.lg,
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
...shadows.sm,
|
||||
},
|
||||
sectionTitle: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
meta: {
|
||||
marginLeft: spacing.md,
|
||||
flex: 1,
|
||||
},
|
||||
name: {
|
||||
marginBottom: 2,
|
||||
},
|
||||
subDesc: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
});
|
||||
|
||||
export default GroupRequestDetailScreen;
|
||||
307
src/screens/message/JoinGroupScreen.tsx
Normal file
307
src/screens/message/JoinGroupScreen.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, StyleSheet, TextInput, TouchableOpacity, Alert, ActivityIndicator, Clipboard } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { colors, spacing, borderRadius } from '../../theme';
|
||||
import Avatar from '../../components/common/Avatar';
|
||||
import Text from '../../components/common/Text';
|
||||
import { groupService } from '../../services/groupService';
|
||||
import { groupManager } from '../../stores/groupManager';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { GroupResponse, JoinType } from '../../types/dto';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
const JoinGroupScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [joining, setJoining] = useState(false);
|
||||
const [group, setGroup] = useState<GroupResponse | null>(null);
|
||||
const [searched, setSearched] = useState(false);
|
||||
|
||||
const getJoinTypeText = (joinType: JoinType) => {
|
||||
if (joinType === 0) return '允许加入';
|
||||
if (joinType === 1) return '需要审批';
|
||||
return '禁止加入';
|
||||
};
|
||||
|
||||
const handleSearch = async () => {
|
||||
const trimmed = keyword.trim();
|
||||
if (!trimmed) {
|
||||
Alert.alert('提示', '请输入群ID进行搜索');
|
||||
return;
|
||||
}
|
||||
|
||||
setSearching(true);
|
||||
setSearched(true);
|
||||
try {
|
||||
const result = await groupManager.getGroup(trimmed, true);
|
||||
setGroup(result);
|
||||
} catch (error: any) {
|
||||
setGroup(null);
|
||||
const message = error?.response?.data?.message || error?.message || '';
|
||||
if (String(message).includes('不存在') || error?.response?.status === 404) {
|
||||
Alert.alert('未找到', '未搜索到该群聊,请确认群ID是否正确');
|
||||
} else {
|
||||
Alert.alert('搜索失败', '请稍后重试');
|
||||
}
|
||||
} finally {
|
||||
setSearching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJoin = async () => {
|
||||
if (!group?.id) return;
|
||||
setJoining(true);
|
||||
try {
|
||||
await groupService.joinGroup(group.id);
|
||||
Alert.alert('成功', '操作已提交', [
|
||||
{
|
||||
text: '确定',
|
||||
onPress: () => navigation.goBack(),
|
||||
},
|
||||
]);
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
error?.response?.data?.message ||
|
||||
error?.message ||
|
||||
'操作失败,请稍后重试';
|
||||
Alert.alert('操作失败', String(message));
|
||||
} finally {
|
||||
setJoining(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopyGroupId = () => {
|
||||
if (!group?.id) return;
|
||||
Clipboard.setString(String(group.id));
|
||||
Alert.alert('已复制', '群号已复制到剪贴板');
|
||||
};
|
||||
|
||||
const formatGroupNo = (id: string | number) => {
|
||||
const raw = String(id);
|
||||
if (raw.length <= 12) return raw;
|
||||
return `${raw.slice(0, 6)}...${raw.slice(-4)}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.heroCard}>
|
||||
<View style={styles.heroIconWrap}>
|
||||
<MaterialCommunityIcons name="account-group-outline" size={28} color={colors.primary.main} />
|
||||
</View>
|
||||
<Text variant="h3" style={styles.heroTitle}>搜索群聊</Text>
|
||||
<Text variant="body" color={colors.text.secondary} style={styles.tip}>
|
||||
输入群 ID 搜索后,你可以先查看群资料,再决定是否申请加入。
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.formCard}>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.label}>搜索群聊(群ID)</Text>
|
||||
<View style={styles.searchRow}>
|
||||
<TextInput
|
||||
value={keyword}
|
||||
onChangeText={setKeyword}
|
||||
placeholder="例如:7391234567890"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
style={styles.input}
|
||||
editable={!searching && !joining}
|
||||
autoCapitalize="none"
|
||||
/>
|
||||
<TouchableOpacity
|
||||
style={[styles.searchBtn, (!keyword.trim() || searching || joining) && styles.submitBtnDisabled]}
|
||||
onPress={handleSearch}
|
||||
disabled={!keyword.trim() || searching || joining}
|
||||
>
|
||||
{searching ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<MaterialCommunityIcons name="magnify" size={20} color="#fff" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{group && (
|
||||
<View style={styles.groupCard}>
|
||||
<View style={styles.groupHeader}>
|
||||
<Avatar source={group.avatar} size={52} name={group.name} />
|
||||
<View style={styles.groupMeta}>
|
||||
<Text variant="body" style={styles.groupName} numberOfLines={1}>{group.name}</Text>
|
||||
</View>
|
||||
</View>
|
||||
{!!group.description && (
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.groupDesc}>
|
||||
{group.description}
|
||||
</Text>
|
||||
)}
|
||||
<View style={styles.groupInfoRow}>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
成员 {group.member_count}/{group.max_members}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
{getJoinTypeText(group.join_type)}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.groupNoRow}>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
群号:{formatGroupNo(group.id)}
|
||||
</Text>
|
||||
<TouchableOpacity style={styles.copyBtn} onPress={handleCopyGroupId}>
|
||||
<MaterialCommunityIcons name="content-copy" size={14} color={colors.primary.main} />
|
||||
<Text variant="caption" color={colors.primary.main} style={styles.copyBtnText}>复制</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.submitBtn, joining && styles.submitBtnDisabled]}
|
||||
onPress={handleJoin}
|
||||
disabled={joining}
|
||||
>
|
||||
{joining ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<>
|
||||
<MaterialCommunityIcons name="send-outline" size={18} color="#fff" />
|
||||
<Text variant="body" color="#fff" style={styles.submitText}>申请入群</Text>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{searched && !group && !searching && (
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.emptyText}>
|
||||
暂无搜索结果,请检查群ID后重试
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
padding: spacing.lg,
|
||||
},
|
||||
heroCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.lg,
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
heroIconWrap: {
|
||||
width: 48,
|
||||
height: 48,
|
||||
borderRadius: 24,
|
||||
backgroundColor: colors.primary.light + '26',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
heroTitle: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
tip: {
|
||||
lineHeight: 20,
|
||||
},
|
||||
formCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.lg,
|
||||
},
|
||||
label: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.md,
|
||||
color: colors.text.primary,
|
||||
},
|
||||
searchRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
searchBtn: {
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.primary.main,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
groupCard: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
borderRadius: borderRadius.md,
|
||||
padding: spacing.md,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
groupHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
groupMeta: {
|
||||
marginLeft: spacing.md,
|
||||
flex: 1,
|
||||
},
|
||||
groupName: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
groupDesc: {
|
||||
marginTop: spacing.sm,
|
||||
lineHeight: 18,
|
||||
},
|
||||
groupInfoRow: {
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.xs,
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
groupNoRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
copyBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
borderRadius: borderRadius.sm,
|
||||
backgroundColor: colors.primary.light + '22',
|
||||
},
|
||||
copyBtnText: {
|
||||
marginLeft: 4,
|
||||
},
|
||||
submitBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.primary.main,
|
||||
minHeight: 46,
|
||||
},
|
||||
submitBtnDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
submitText: {
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
emptyText: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
});
|
||||
|
||||
export default JoinGroupScreen;
|
||||
1324
src/screens/message/MessageListScreen.tsx
Normal file
1324
src/screens/message/MessageListScreen.tsx
Normal file
File diff suppressed because it is too large
Load Diff
540
src/screens/message/NotificationsScreen.tsx
Normal file
540
src/screens/message/NotificationsScreen.tsx
Normal file
@@ -0,0 +1,540 @@
|
||||
/**
|
||||
* 通知页 NotificationsScreen
|
||||
* 胡萝卜BBS - 系统消息列表
|
||||
* 使用新的系统消息API
|
||||
* 支持响应式布局
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
RefreshControl,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useIsFocused } from '@react-navigation/native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { colors, spacing } from '../../theme';
|
||||
import { SystemMessageResponse } from '../../types/dto';
|
||||
import { messageService } from '../../services/messageService';
|
||||
import { commentService } from '../../services/commentService';
|
||||
import { SystemMessageItem } from '../../components/business';
|
||||
import { Text, EmptyState, ResponsiveContainer } from '../../components/common';
|
||||
import { useResponsive, useBreakpointGTE } from '../../hooks/useResponsive';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
import { useMessageManagerSystemUnreadCount, useUserStore } from '../../stores';
|
||||
|
||||
const MESSAGE_TYPES = [
|
||||
{ key: 'all', title: '全部' },
|
||||
{ key: 'like_post', title: '点赞' },
|
||||
{ key: 'comment', title: '评论' },
|
||||
{ key: 'follow', title: '关注' },
|
||||
{ key: 'group', title: '群通知' },
|
||||
{ key: 'system', title: '系统' },
|
||||
{ key: 'announcement', title: '公告' },
|
||||
];
|
||||
|
||||
const LIKE_SYSTEM_TYPES = new Set(['like_post', 'like_comment', 'like_reply', 'favorite_post']);
|
||||
const GROUP_SYSTEM_TYPES = new Set(['group_invite', 'group_join_apply', 'group_join_approved', 'group_join_rejected']);
|
||||
|
||||
export const NotificationsScreen: React.FC = () => {
|
||||
const isFocused = useIsFocused();
|
||||
const fetchMessageUnreadCount = useUserStore(state => state.fetchMessageUnreadCount);
|
||||
const { setSystemUnreadCount, decrementSystemUnreadCount } = useMessageManagerSystemUnreadCount();
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
|
||||
// 响应式布局
|
||||
const { isDesktop, isTablet } = useResponsive();
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
|
||||
const [messages, setMessages] = useState<SystemMessageResponse[]>([]);
|
||||
const [activeType, setActiveType] = useState('all');
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
|
||||
// 同一 flag 只要有人审批过,就将待处理消息同步展示为已处理状态
|
||||
const displayMessages = useMemo(() => {
|
||||
const reviewedByFlag = new Map<string, SystemMessageResponse>();
|
||||
messages.forEach((msg) => {
|
||||
if (msg.system_type !== 'group_join_apply') return;
|
||||
const flag = msg.extra_data?.flag;
|
||||
const status = msg.extra_data?.request_status;
|
||||
if (!flag) return;
|
||||
if (status === 'accepted' || status === 'rejected') {
|
||||
reviewedByFlag.set(flag, msg);
|
||||
}
|
||||
});
|
||||
|
||||
if (reviewedByFlag.size === 0) {
|
||||
return messages;
|
||||
}
|
||||
|
||||
return messages.map((msg) => {
|
||||
if (msg.system_type !== 'group_join_apply') return msg;
|
||||
const flag = msg.extra_data?.flag;
|
||||
if (!flag) return msg;
|
||||
const reviewedMsg = reviewedByFlag.get(flag);
|
||||
if (!reviewedMsg) return msg;
|
||||
const reviewedStatus = reviewedMsg.extra_data?.request_status;
|
||||
if (reviewedStatus !== 'accepted' && reviewedStatus !== 'rejected') return msg;
|
||||
|
||||
return {
|
||||
...msg,
|
||||
extra_data: {
|
||||
...msg.extra_data,
|
||||
request_status: reviewedStatus,
|
||||
actor_name: reviewedMsg.extra_data?.actor_name || reviewedMsg.extra_data?.operator_name || msg.extra_data?.actor_name,
|
||||
avatar_url: reviewedMsg.extra_data?.avatar_url || reviewedMsg.extra_data?.operator_avatar || msg.extra_data?.avatar_url,
|
||||
},
|
||||
};
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
// 获取系统消息数据
|
||||
const fetchMessages = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await messageService.getSystemMessages(50, 1);
|
||||
// 添加防御性检查,确保 messages 数组存在
|
||||
setMessages(response.messages || []);
|
||||
setHasMore(response.has_more ?? false);
|
||||
} catch (error) {
|
||||
console.error('获取系统消息失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 获取未读数
|
||||
const fetchUnreadCount = useCallback(async () => {
|
||||
try {
|
||||
const response = await messageService.getSystemUnreadCount();
|
||||
setUnreadCount(response.unread_count);
|
||||
} catch (error) {
|
||||
console.error('获取未读数失败:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 一键已读
|
||||
const handleMarkAllRead = useCallback(async () => {
|
||||
try {
|
||||
await messageService.markAllSystemMessagesRead();
|
||||
setMessages(prev => prev.map(m => ({ ...m, is_read: true })));
|
||||
setUnreadCount(0);
|
||||
setSystemUnreadCount(0);
|
||||
// 同步更新全局 TabBar 红点
|
||||
fetchMessageUnreadCount();
|
||||
// 刷新消息列表
|
||||
fetchMessages();
|
||||
} catch (error) {
|
||||
console.error('一键已读失败:', error);
|
||||
}
|
||||
}, [fetchMessages, fetchMessageUnreadCount, setSystemUnreadCount]);
|
||||
|
||||
// 页面加载和获得焦点时刷新,并自动标记所有消息为已读
|
||||
useEffect(() => {
|
||||
if (isFocused) {
|
||||
fetchMessages();
|
||||
fetchUnreadCount();
|
||||
// 进入界面自动标记所有消息为已读
|
||||
handleMarkAllRead();
|
||||
}
|
||||
}, [isFocused, fetchMessages, fetchUnreadCount, handleMarkAllRead]);
|
||||
|
||||
// 屏幕失去焦点时返回消息列表
|
||||
useEffect(() => {
|
||||
if (!isFocused) {
|
||||
// 使用 setTimeout 确保在导航状态稳定后再执行
|
||||
const timer = setTimeout(() => {
|
||||
navigation.goBack();
|
||||
}, 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isFocused, navigation]);
|
||||
|
||||
// 筛选消息
|
||||
const filteredMessages = activeType === 'all'
|
||||
? displayMessages
|
||||
: displayMessages.filter((m) => {
|
||||
if (activeType === 'like_post') {
|
||||
return LIKE_SYSTEM_TYPES.has(m.system_type);
|
||||
}
|
||||
if (activeType === 'group') {
|
||||
return GROUP_SYSTEM_TYPES.has(m.system_type);
|
||||
}
|
||||
return m.system_type === activeType;
|
||||
});
|
||||
|
||||
// 下拉刷新
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
await Promise.all([fetchMessages(), fetchUnreadCount()]);
|
||||
setRefreshing(false);
|
||||
}, [fetchMessages, fetchUnreadCount]);
|
||||
|
||||
// 加载更多
|
||||
const loadMore = useCallback(async () => {
|
||||
if (loadingMore || !hasMore || messages.length === 0) return;
|
||||
|
||||
try {
|
||||
setLoadingMore(true);
|
||||
// 使用时间戳或seq作为游标分页(后端使用page分页)
|
||||
const nextPage = Math.floor((messages.length / 20)) + 1;
|
||||
const response = await messageService.getSystemMessages(20, nextPage);
|
||||
// 添加防御性检查
|
||||
const newMessages = response.messages || [];
|
||||
setMessages(prev => [...prev, ...newMessages]);
|
||||
setHasMore(response.has_more ?? false);
|
||||
} catch (error) {
|
||||
console.error('加载更多失败:', error);
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [loadingMore, hasMore, messages]);
|
||||
|
||||
// 标记单条消息已读并处理导航
|
||||
const extractPostIdFromActionUrl = (actionUrl?: string): string | null => {
|
||||
if (!actionUrl) return null;
|
||||
|
||||
const postPathMatch = actionUrl.match(/\/posts\/([^/?#]+)/);
|
||||
if (postPathMatch?.[1]) {
|
||||
return decodeURIComponent(postPathMatch[1]);
|
||||
}
|
||||
|
||||
const query = actionUrl.split('?')[1];
|
||||
if (!query) return null;
|
||||
const params = new URLSearchParams(query);
|
||||
return params.get('post') || params.get('post_id');
|
||||
};
|
||||
|
||||
const resolvePostId = async (message: SystemMessageResponse): Promise<string | null> => {
|
||||
const { extra_data, system_type } = message;
|
||||
|
||||
// 后端已统一将 target_id 设为帖子ID(like_post/comment/mention/favorite_post/like_reply/like_comment)
|
||||
if (
|
||||
extra_data?.target_id &&
|
||||
['like_post', 'like_comment', 'like_reply', 'comment', 'mention', 'favorite_post'].includes(system_type)
|
||||
) {
|
||||
return extra_data.target_id;
|
||||
}
|
||||
|
||||
// reply 通知:target_id 是 replyID,从 action_url 解析帖子ID
|
||||
const postIdFromUrl = extractPostIdFromActionUrl(extra_data?.action_url);
|
||||
if (postIdFromUrl) {
|
||||
return postIdFromUrl;
|
||||
}
|
||||
|
||||
// 兜底:通过评论详情反查(仅旧数据兜底)
|
||||
if (extra_data?.target_id && system_type === 'reply') {
|
||||
const comment = await commentService.getComment(extra_data.target_id);
|
||||
if (comment?.post_id) {
|
||||
return comment.post_id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const handleMessagePress = async (message: SystemMessageResponse) => {
|
||||
try {
|
||||
const messageId = String(message.id);
|
||||
const wasUnread = message.is_read !== true;
|
||||
await messageService.markSystemMessageRead(messageId);
|
||||
setMessages(prev =>
|
||||
prev.map(m => (String(m.id) === messageId ? { ...m, is_read: true } : m))
|
||||
);
|
||||
if (wasUnread) {
|
||||
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||
decrementSystemUnreadCount(1);
|
||||
}
|
||||
// 更新本地未读数以及全局 TabBar 红点
|
||||
fetchUnreadCount();
|
||||
fetchMessageUnreadCount();
|
||||
|
||||
// 根据消息类型处理导航
|
||||
const { system_type, extra_data } = message;
|
||||
|
||||
if (
|
||||
['like_post', 'comment', 'mention', 'reply', 'like_comment', 'like_reply', 'favorite_post'].includes(system_type)
|
||||
) {
|
||||
const postId = await resolvePostId(message);
|
||||
if (postId) {
|
||||
navigation.navigate('PostDetail', { postId });
|
||||
}
|
||||
} else if (system_type === 'follow') {
|
||||
// 关注 - 跳转到用户主页
|
||||
if (extra_data?.actor_id_str) {
|
||||
navigation.navigate('UserProfile', { userId: extra_data.actor_id_str });
|
||||
}
|
||||
} else if (system_type === 'group_join_apply') {
|
||||
navigation.navigate('GroupRequestDetail', { message });
|
||||
} else if (system_type === 'group_invite') {
|
||||
navigation.navigate('GroupInviteDetail', { message });
|
||||
}
|
||||
// 其他类型暂不处理跳转
|
||||
} catch (error) {
|
||||
console.error('标记已读失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理头像点击 - 跳转到用户主页
|
||||
const handleAvatarPress = (message: SystemMessageResponse) => {
|
||||
const { extra_data } = message;
|
||||
// 优先使用 actor_id_str (UUID格式),兼容 actor_id (数字格式)
|
||||
const actorId = extra_data?.actor_id_str || (extra_data?.actor_id ? String(extra_data.actor_id) : null);
|
||||
|
||||
if (actorId) {
|
||||
navigation.navigate('UserProfile', { userId: actorId });
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染消息项
|
||||
const renderMessage = ({ item }: { item: SystemMessageResponse }) => (
|
||||
<SystemMessageItem
|
||||
message={item}
|
||||
onPress={() => handleMessagePress(item)}
|
||||
onAvatarPress={() => handleAvatarPress(item)}
|
||||
/>
|
||||
);
|
||||
|
||||
// 渲染底部加载指示器
|
||||
const renderFooter = () => {
|
||||
if (!loadingMore) return null;
|
||||
return (
|
||||
<View style={styles.loadingMore}>
|
||||
<ActivityIndicator size="small" color={colors.primary.main} />
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.loadingMoreText}>
|
||||
加载中...
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmpty = () => (
|
||||
<EmptyState
|
||||
title="暂无通知"
|
||||
description="关注一些用户来获取通知吧"
|
||||
icon="bell-outline"
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={[]}>
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={900}>
|
||||
{/* 分类筛选 */}
|
||||
<View style={[styles.filterContainer, isWideScreen && styles.filterContainerWide]}>
|
||||
{MESSAGE_TYPES.map(type => {
|
||||
const count = type.key === 'all'
|
||||
? displayMessages.length
|
||||
: displayMessages.filter((m) => {
|
||||
if (type.key === 'like_post') {
|
||||
return LIKE_SYSTEM_TYPES.has(m.system_type);
|
||||
}
|
||||
if (type.key === 'group') {
|
||||
return GROUP_SYSTEM_TYPES.has(m.system_type);
|
||||
}
|
||||
return m.system_type === type.key;
|
||||
}).length;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={type.key}
|
||||
style={[
|
||||
styles.filterTag,
|
||||
activeType === type.key && styles.filterTagActive,
|
||||
isWideScreen && styles.filterTagWide,
|
||||
]}
|
||||
onPress={() => setActiveType(type.key)}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
color={activeType === type.key ? colors.primary.main : colors.text.secondary}
|
||||
>
|
||||
{type.title}
|
||||
</Text>
|
||||
{count > 0 && (
|
||||
<Text
|
||||
variant="caption"
|
||||
color={activeType === type.key ? colors.primary.main : colors.text.hint}
|
||||
style={styles.filterCount}
|
||||
>
|
||||
{count}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* 消息列表 */}
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={filteredMessages}
|
||||
renderItem={renderMessage}
|
||||
keyExtractor={item => item.id.toString()}
|
||||
contentContainerStyle={[styles.listContent, isWideScreen && styles.listContentWide]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
ListFooterComponent={renderFooter}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.3}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<>
|
||||
{/* 分类筛选 */}
|
||||
<View style={styles.filterContainer}>
|
||||
{MESSAGE_TYPES.map(type => {
|
||||
const count = type.key === 'all'
|
||||
? displayMessages.length
|
||||
: displayMessages.filter((m) => {
|
||||
if (type.key === 'like_post') {
|
||||
return LIKE_SYSTEM_TYPES.has(m.system_type);
|
||||
}
|
||||
if (type.key === 'group') {
|
||||
return GROUP_SYSTEM_TYPES.has(m.system_type);
|
||||
}
|
||||
return m.system_type === type.key;
|
||||
}).length;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={type.key}
|
||||
style={[
|
||||
styles.filterTag,
|
||||
activeType === type.key && styles.filterTagActive,
|
||||
]}
|
||||
onPress={() => setActiveType(type.key)}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
color={activeType === type.key ? colors.primary.main : colors.text.secondary}
|
||||
>
|
||||
{type.title}
|
||||
</Text>
|
||||
{count > 0 && (
|
||||
<Text
|
||||
variant="caption"
|
||||
color={activeType === type.key ? colors.primary.main : colors.text.hint}
|
||||
style={styles.filterCount}
|
||||
>
|
||||
{count}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* 消息列表 */}
|
||||
{loading ? (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={filteredMessages}
|
||||
renderItem={renderMessage}
|
||||
keyExtractor={item => item.id.toString()}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
ListFooterComponent={renderFooter}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.3}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
filterContainer: {
|
||||
flexDirection: 'row',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
filterContainerWide: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingVertical: spacing.md,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
filterTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
marginRight: spacing.sm,
|
||||
borderRadius: 16,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
filterTagWide: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
filterTagActive: {
|
||||
backgroundColor: colors.primary.light + '30',
|
||||
},
|
||||
filterCount: {
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
listContentWide: {
|
||||
paddingHorizontal: spacing.xl,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.xl * 2,
|
||||
},
|
||||
loadingMore: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.lg,
|
||||
},
|
||||
loadingMoreText: {
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
});
|
||||
490
src/screens/message/PrivateChatInfoScreen.tsx
Normal file
490
src/screens/message/PrivateChatInfoScreen.tsx
Normal file
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* PrivateChatInfoScreen 私聊聊天管理页面
|
||||
* 胡萝卜BBS - 私聊设置界面
|
||||
* 参考QQ和微信的实现,提供聊天管理功能
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
Switch,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
|
||||
import { useAuthStore } from '../../stores';
|
||||
import { authService } from '../../services/authService';
|
||||
import { messageService } from '../../services/messageService';
|
||||
import { ApiError } from '../../services/api';
|
||||
import {
|
||||
clearConversationMessages,
|
||||
getConversationCache,
|
||||
} from '../../services/database';
|
||||
import { messageManager } from '../../stores/messageManager';
|
||||
import { userManager } from '../../stores/userManager';
|
||||
import { Avatar, Text, Button, Loading, Divider } from '../../components/common';
|
||||
import { User } from '../../types';
|
||||
import { RootStackParamList, MessageStackParamList } from '../../navigation/types';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<MessageStackParamList>;
|
||||
type PrivateChatInfoRouteProp = RouteProp<MessageStackParamList, 'PrivateChatInfo'>;
|
||||
|
||||
const PrivateChatInfoScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const route = useRoute<PrivateChatInfoRouteProp>();
|
||||
const { conversationId, userId, userName, userAvatar } = route.params;
|
||||
const { currentUser } = useAuthStore();
|
||||
|
||||
// 用户信息状态
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isBlocked, setIsBlocked] = useState(false);
|
||||
|
||||
// 聊天设置状态
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
const [isPinned, setIsPinned] = useState(false);
|
||||
|
||||
// 加载用户信息
|
||||
const loadUserInfo = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const userData = await userManager.getUserById(userId);
|
||||
setUser(userData || null);
|
||||
const blockStatus = await authService.getBlockStatus(userId);
|
||||
setIsBlocked(blockStatus);
|
||||
} catch (error) {
|
||||
console.error('加载用户信息失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
// 加载聊天设置
|
||||
const loadChatSettings = useCallback(async () => {
|
||||
try {
|
||||
const currentConv = messageManager.getConversation(conversationId);
|
||||
if (currentConv) {
|
||||
setIsPinned(Boolean(currentConv.is_pinned));
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = await getConversationCache(conversationId);
|
||||
if (cached) {
|
||||
setIsPinned(Boolean((cached as any).is_pinned));
|
||||
return;
|
||||
}
|
||||
|
||||
const detail = await messageService.getConversationById(conversationId);
|
||||
setIsPinned(Boolean(detail.is_pinned));
|
||||
} catch (error) {
|
||||
console.error('加载聊天设置失败:', error);
|
||||
}
|
||||
}, [conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadUserInfo();
|
||||
loadChatSettings();
|
||||
}, [loadUserInfo, loadChatSettings]);
|
||||
|
||||
// 切换免打扰(仅本地状态,实际需要API支持)
|
||||
const toggleMute = async () => {
|
||||
try {
|
||||
const newValue = !isMuted;
|
||||
setIsMuted(newValue);
|
||||
// TODO: 调用API更新免打扰状态
|
||||
// await conversationService.updateConversationMute(conversationId, newValue);
|
||||
} catch (error) {
|
||||
setIsMuted(!isMuted);
|
||||
Alert.alert('错误', '设置免打扰失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 切换置顶
|
||||
const togglePin = async () => {
|
||||
const nextValue = !isPinned;
|
||||
setIsPinned(nextValue);
|
||||
try {
|
||||
await messageService.setConversationPinned(conversationId, nextValue);
|
||||
messageManager.updateConversation(conversationId, { is_pinned: nextValue });
|
||||
} catch (error) {
|
||||
setIsPinned(!nextValue);
|
||||
Alert.alert('错误', '设置置顶失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 清空聊天记录
|
||||
const handleClearHistory = () => {
|
||||
Alert.alert(
|
||||
'清空聊天记录',
|
||||
'确定要清空与该用户的所有聊天记录吗?此操作不可恢复。',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '清空',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await clearConversationMessages(conversationId);
|
||||
Alert.alert('成功', '聊天记录已清空');
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '清空聊天记录失败');
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// 查找聊天记录
|
||||
const handleSearchMessages = () => {
|
||||
// TODO: 实现聊天记录搜索功能
|
||||
Alert.alert('提示', '聊天记录搜索功能开发中');
|
||||
};
|
||||
|
||||
// 查看用户资料
|
||||
const handleViewProfile = () => {
|
||||
// 使用根导航跳转到用户资料页面
|
||||
const rootNavigation = navigation.getParent();
|
||||
rootNavigation?.navigate('UserProfile' as any, { userId });
|
||||
};
|
||||
|
||||
// 举报/投诉
|
||||
const handleReport = () => {
|
||||
Alert.alert(
|
||||
'举报用户',
|
||||
'请选择举报原因',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{ text: '骚扰信息', onPress: () => submitReport('harassment') },
|
||||
{ text: '欺诈行为', onPress: () => submitReport('fraud') },
|
||||
{ text: '不当内容', onPress: () => submitReport('inappropriate') },
|
||||
{ text: '其他原因', onPress: () => submitReport('other') },
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const submitReport = async (reason: string) => {
|
||||
try {
|
||||
// TODO: 实现举报API
|
||||
Alert.alert('举报已提交', '我们会尽快处理您的举报,感谢您的反馈');
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '举报提交失败,请稍后重试');
|
||||
}
|
||||
};
|
||||
|
||||
const handleBlockUser = () => {
|
||||
if (isBlocked) {
|
||||
Alert.alert(
|
||||
'取消拉黑',
|
||||
'确定取消拉黑该用户吗?',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '确定',
|
||||
onPress: async () => {
|
||||
const ok = await authService.unblockUser(userId);
|
||||
if (!ok) {
|
||||
Alert.alert('错误', '取消拉黑失败,请稍后重试');
|
||||
return;
|
||||
}
|
||||
setIsBlocked(false);
|
||||
Alert.alert('成功', '已取消拉黑');
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
'拉黑用户',
|
||||
'拉黑后,对方将无法给你发送私聊消息,且你们会互相移除关注关系。',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '确认拉黑',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
const ok = await authService.blockUser(userId);
|
||||
if (!ok) {
|
||||
Alert.alert('错误', '拉黑失败,请稍后重试');
|
||||
return;
|
||||
}
|
||||
setIsBlocked(true);
|
||||
messageManager.removeConversation(conversationId);
|
||||
navigation.navigate('MessageList' as any);
|
||||
Alert.alert('已拉黑', '你已成功拉黑该用户');
|
||||
} catch (error) {
|
||||
Alert.alert('错误', '拉黑失败,请稍后重试');
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// 删除并退出聊天
|
||||
const handleDeleteAndExit = () => {
|
||||
Alert.alert(
|
||||
'删除聊天',
|
||||
'确定要删除与该用户的聊天吗?聊天记录将被清空。',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '删除',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await messageService.deleteConversationForSelf(conversationId);
|
||||
messageManager.removeConversation(conversationId);
|
||||
// 返回消息列表
|
||||
navigation.navigate('MessageList' as any);
|
||||
} catch (error) {
|
||||
const msg = error instanceof ApiError ? error.message : '删除聊天失败';
|
||||
Alert.alert('错误', msg);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染设置项(带开关)
|
||||
const renderSwitchItem = (
|
||||
icon: string,
|
||||
title: string,
|
||||
value: boolean,
|
||||
onValueChange: () => void
|
||||
) => (
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.settingIconContainer}>
|
||||
<MaterialCommunityIcons name={icon as any} size={20} color={colors.primary.main} />
|
||||
</View>
|
||||
<Text variant="body" style={styles.settingTitle}>
|
||||
{title}
|
||||
</Text>
|
||||
<Switch
|
||||
value={value}
|
||||
onValueChange={onValueChange}
|
||||
trackColor={{ false: '#E0E0E0', true: colors.primary.light }}
|
||||
thumbColor={value ? colors.primary.main : '#F5F5F5'}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 渲染设置项(带箭头)
|
||||
const renderActionItem = (
|
||||
icon: string,
|
||||
title: string,
|
||||
onPress: () => void,
|
||||
danger: boolean = false
|
||||
) => (
|
||||
<TouchableOpacity
|
||||
style={styles.settingItem}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={[styles.settingIconContainer, danger && styles.settingIconDanger]}>
|
||||
<MaterialCommunityIcons
|
||||
name={icon as any}
|
||||
size={20}
|
||||
color={danger ? colors.error.main : colors.primary.main}
|
||||
/>
|
||||
</View>
|
||||
<Text variant="body" style={danger ? [styles.settingTitle, styles.dangerText] : styles.settingTitle}>
|
||||
{title}
|
||||
</Text>
|
||||
<MaterialCommunityIcons name="chevron-right" size={22} color={colors.text.hint} />
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<Loading />
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const displayUser = user || {
|
||||
id: userId,
|
||||
nickname: userName,
|
||||
avatar: userAvatar,
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* 用户基本信息卡片 */}
|
||||
<View style={styles.headerCard}>
|
||||
<TouchableOpacity
|
||||
style={styles.userHeader}
|
||||
onPress={handleViewProfile}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Avatar
|
||||
source={displayUser.avatar}
|
||||
size={90}
|
||||
name={displayUser.nickname || ''}
|
||||
/>
|
||||
<View style={styles.userInfo}>
|
||||
<Text variant="h3" style={styles.userName} numberOfLines={1}>
|
||||
{displayUser.nickname || userName || '用户'}
|
||||
</Text>
|
||||
{(displayUser as any).username && (
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
@{(displayUser as any).username}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<MaterialCommunityIcons name="chevron-right" size={24} color={colors.text.hint} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 聊天设置 */}
|
||||
<View style={styles.section}>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||||
聊天设置
|
||||
</Text>
|
||||
<View style={styles.card}>
|
||||
{renderSwitchItem('bell-off-outline', '消息免打扰', isMuted, toggleMute)}
|
||||
<Divider style={styles.divider} />
|
||||
{renderSwitchItem('pin-outline', '置顶聊天', isPinned, togglePin)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 聊天记录管理 */}
|
||||
<View style={styles.section}>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||||
聊天记录
|
||||
</Text>
|
||||
<View style={styles.card}>
|
||||
{renderActionItem('magnify', '查找聊天记录', handleSearchMessages)}
|
||||
<Divider style={styles.divider} />
|
||||
{renderActionItem('delete-sweep-outline', '清空聊天记录', handleClearHistory, true)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 其他操作 */}
|
||||
<View style={styles.section}>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||||
其他
|
||||
</Text>
|
||||
<View style={styles.card}>
|
||||
{renderActionItem('shield-alert-outline', '投诉', handleReport, true)}
|
||||
<Divider style={styles.divider} />
|
||||
{renderActionItem(
|
||||
isBlocked ? 'account-check-outline' : 'account-cancel-outline',
|
||||
isBlocked ? '取消拉黑' : '拉黑用户',
|
||||
handleBlockUser,
|
||||
true
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 删除聊天按钮 */}
|
||||
<View style={styles.section}>
|
||||
<Button
|
||||
title="删除并退出聊天"
|
||||
onPress={handleDeleteAndExit}
|
||||
variant="danger"
|
||||
style={styles.deleteButton}
|
||||
/>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
headerCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
marginHorizontal: spacing.md,
|
||||
marginTop: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.lg,
|
||||
...shadows.sm,
|
||||
},
|
||||
userHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
userInfo: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.md,
|
||||
},
|
||||
userName: {
|
||||
fontWeight: '600',
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
section: {
|
||||
marginTop: spacing.lg,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
sectionTitle: {
|
||||
marginBottom: spacing.sm,
|
||||
marginLeft: spacing.sm,
|
||||
fontWeight: '500',
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
...shadows.sm,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
settingItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
settingIconContainer: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
settingIconDanger: {
|
||||
backgroundColor: colors.error.light + '20',
|
||||
},
|
||||
settingTitle: {
|
||||
flex: 1,
|
||||
},
|
||||
dangerText: {
|
||||
color: colors.error.main,
|
||||
},
|
||||
divider: {
|
||||
marginLeft: spacing.xl + 36,
|
||||
width: 'auto',
|
||||
marginVertical: spacing.xs,
|
||||
},
|
||||
deleteButton: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
});
|
||||
|
||||
export default PrivateChatInfoScreen;
|
||||
129
src/screens/message/components/ChatScreen/ChatHeader.tsx
Normal file
129
src/screens/message/components/ChatScreen/ChatHeader.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 聊天头部组件
|
||||
* 支持响应式布局(宽屏下显示更多信息)
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Avatar, Text } from '../../../../components/common';
|
||||
import { colors, spacing } from '../../../../theme';
|
||||
import { chatScreenStyles as baseStyles } from './styles';
|
||||
import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive';
|
||||
import { ChatHeaderProps } from './types';
|
||||
|
||||
export const ChatHeader: React.FC<ChatHeaderProps> = ({
|
||||
isGroupChat,
|
||||
groupInfo,
|
||||
otherUser,
|
||||
routeGroupName,
|
||||
typingHint,
|
||||
onBack,
|
||||
onTitlePress,
|
||||
onMorePress,
|
||||
}) => {
|
||||
// 响应式布局
|
||||
const { width } = useResponsive();
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
|
||||
// 合并样式
|
||||
const styles = useMemo(() => {
|
||||
return StyleSheet.create({
|
||||
...baseStyles,
|
||||
headerContainer: {
|
||||
...baseStyles.headerContainer,
|
||||
maxWidth: isWideScreen ? 1200 : undefined,
|
||||
alignSelf: isWideScreen ? 'center' : undefined,
|
||||
width: isWideScreen ? '100%' : undefined,
|
||||
},
|
||||
header: {
|
||||
...baseStyles.header,
|
||||
paddingHorizontal: isWideScreen ? spacing.xl : spacing.md,
|
||||
},
|
||||
headerName: {
|
||||
...baseStyles.headerName,
|
||||
fontSize: isWideScreen ? 19 : 17,
|
||||
},
|
||||
memberCount: {
|
||||
...baseStyles.memberCount,
|
||||
fontSize: isWideScreen ? 14 : 13,
|
||||
},
|
||||
typingHint: {
|
||||
...baseStyles.typingHint,
|
||||
fontSize: isWideScreen ? 13 : 12,
|
||||
},
|
||||
});
|
||||
}, [isWideScreen]);
|
||||
|
||||
// 获取显示标题
|
||||
const getDisplayTitle = () => {
|
||||
if (isGroupChat) {
|
||||
return routeGroupName || groupInfo?.name || '群聊';
|
||||
}
|
||||
return otherUser?.nickname || '用户';
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView edges={['top']} style={styles.headerContainer}>
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
style={styles.backButton}
|
||||
onPress={onBack}
|
||||
>
|
||||
<MaterialCommunityIcons name="arrow-left" size={22} color={colors.primary.dark} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.headerCenter}>
|
||||
<TouchableOpacity
|
||||
style={styles.titleRow}
|
||||
onPress={onTitlePress}
|
||||
activeOpacity={0.75}
|
||||
>
|
||||
{isGroupChat ? (
|
||||
<>
|
||||
<View style={styles.titleLeading}>
|
||||
<MaterialCommunityIcons name="account-group" size={20} color={colors.primary.main} />
|
||||
</View>
|
||||
<View style={styles.titleContent}>
|
||||
<Text style={styles.headerName} numberOfLines={1}>{getDisplayTitle()}</Text>
|
||||
<View style={styles.subtitleRow}>
|
||||
<Text style={styles.memberCount}>{groupInfo?.member_count || 0}位成员</Text>
|
||||
{typingHint && (
|
||||
<Text style={styles.typingHint} numberOfLines={1}>{typingHint}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Avatar
|
||||
source={otherUser?.avatar || null}
|
||||
size={32}
|
||||
name={otherUser?.nickname || ''}
|
||||
/>
|
||||
<View style={styles.titleContent}>
|
||||
<Text style={styles.headerName} numberOfLines={1}>{getDisplayTitle()}</Text>
|
||||
{typingHint && (
|
||||
<View style={styles.subtitleRow}>
|
||||
<Text style={styles.typingHint} numberOfLines={1}>{typingHint}</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={styles.moreButton}
|
||||
onPress={onMorePress}
|
||||
>
|
||||
<MaterialCommunityIcons name="dots-horizontal" size={22} color="#667085" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatHeader;
|
||||
173
src/screens/message/components/ChatScreen/ChatInput.tsx
Normal file
173
src/screens/message/components/ChatScreen/ChatInput.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 聊天输入框组件
|
||||
* 支持响应式布局(宽屏下居中显示)
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, TouchableOpacity, TextInput, ActivityIndicator } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Text } from '../../../../components/common';
|
||||
import { colors } from '../../../../theme';
|
||||
import { chatScreenStyles as baseStyles } from './styles';
|
||||
import { useBreakpointGTE } from '../../../../hooks/useResponsive';
|
||||
import { ChatInputProps, GroupMessage, SenderInfo } from './types';
|
||||
import { extractTextFromSegments } from '../../../../types/dto';
|
||||
|
||||
export const ChatInput: React.FC<ChatInputProps & {
|
||||
currentUser: { id?: string; nickname?: string; avatar?: string | null } | null;
|
||||
otherUser: { id?: string; nickname?: string; avatar?: string | null } | null;
|
||||
getSenderInfo: (senderId: string) => SenderInfo;
|
||||
}> = ({
|
||||
inputText,
|
||||
onInputChange,
|
||||
onSend,
|
||||
onToggleEmoji,
|
||||
onToggleMore,
|
||||
activePanel,
|
||||
sending,
|
||||
isMuted,
|
||||
isGroupChat,
|
||||
muteAll,
|
||||
replyingTo,
|
||||
onCancelReply,
|
||||
onFocus,
|
||||
restrictionHint,
|
||||
disableMore = false,
|
||||
currentUser,
|
||||
otherUser,
|
||||
getSenderInfo,
|
||||
}) => {
|
||||
const isDisabled = isGroupChat && isMuted;
|
||||
|
||||
// 获取当前用户ID
|
||||
const currentUserId = currentUser?.id || '';
|
||||
|
||||
// 响应式布局
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
const styles = baseStyles;
|
||||
|
||||
const inputContainerStyle = useMemo(() => ([
|
||||
styles.inputContainer,
|
||||
isWideScreen ? { maxWidth: 900, alignSelf: 'center' as const, width: '100%' as const } : null,
|
||||
]), [isWideScreen, styles.inputContainer]);
|
||||
|
||||
// 回复预览组件 - 定义在内部以访问 styles
|
||||
const ReplyPreview: React.FC<{
|
||||
replyingTo: GroupMessage;
|
||||
onCancel: () => void;
|
||||
}> = ({ replyingTo, onCancel }) => {
|
||||
const senderInfo = replyingTo.sender_id === currentUserId
|
||||
? { nickname: '我', avatar: currentUser?.avatar }
|
||||
: isGroupChat
|
||||
? getSenderInfo(replyingTo.sender_id)
|
||||
: { nickname: otherUser?.nickname || '对方', avatar: otherUser?.avatar };
|
||||
|
||||
return (
|
||||
<View style={styles.replyPreviewContainer}>
|
||||
<View style={styles.replyPreviewContent}>
|
||||
<MaterialCommunityIcons name="reply" size={16} color={colors.primary.main} />
|
||||
<View style={styles.replyPreviewText}>
|
||||
<Text style={styles.replyPreviewName}>回复 {senderInfo.nickname}</Text>
|
||||
<Text style={styles.replyPreviewMessage} numberOfLines={1}>
|
||||
{replyingTo.segments?.some(seg => seg.type === 'image') ? '[图片]' : extractTextFromSegments(replyingTo.segments)}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TouchableOpacity onPress={onCancel} style={styles.replyPreviewClose}>
|
||||
<MaterialCommunityIcons name="close" size={18} color="#999" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={inputContainerStyle}>
|
||||
{/* 回复预览 */}
|
||||
{replyingTo && (
|
||||
<ReplyPreview
|
||||
replyingTo={replyingTo}
|
||||
onCancel={onCancelReply}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 禁言提示 */}
|
||||
{isGroupChat && isMuted && (
|
||||
<View style={styles.mutedBanner}>
|
||||
<MaterialCommunityIcons name="minus-circle" size={16} color="#FF3B30" />
|
||||
<Text style={styles.mutedBannerText}>
|
||||
{muteAll ? '全员禁言中,暂无法发送消息' : '你已被禁言,暂无法发送消息'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!!restrictionHint && (
|
||||
<View style={styles.mutedBanner}>
|
||||
<MaterialCommunityIcons name="information-outline" size={16} color={colors.warning.main} />
|
||||
<Text style={styles.mutedBannerText}>{restrictionHint}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[styles.inputInner, isDisabled && styles.inputInnerMuted]}>
|
||||
<TouchableOpacity
|
||||
style={[styles.iconButton, activePanel === 'emoji' && styles.iconButtonActive]}
|
||||
onPress={onToggleEmoji}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={activePanel === 'emoji' ? "keyboard" : "emoticon-happy-outline"}
|
||||
size={26}
|
||||
color={isDisabled ? '#CCC' : (activePanel === 'emoji' ? colors.primary.main : '#666')}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.inputBox}>
|
||||
<TextInput
|
||||
style={[styles.input, isDisabled && styles.inputMuted]}
|
||||
placeholder={isGroupChat ? (isMuted ? (muteAll ? "全员禁言中..." : "你已被禁言...") : "发送消息,@提及成员...") : "发送消息..."}
|
||||
placeholderTextColor={isDisabled ? '#CCC' : '#999'}
|
||||
value={inputText}
|
||||
onChangeText={onInputChange}
|
||||
multiline
|
||||
maxLength={500}
|
||||
returnKeyType="send"
|
||||
onSubmitEditing={onSend}
|
||||
blurOnSubmit={false}
|
||||
editable={!isDisabled}
|
||||
onFocus={onFocus}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{inputText.trim() && !sending && !isDisabled ? (
|
||||
<TouchableOpacity
|
||||
style={styles.sendButtonActive}
|
||||
onPress={onSend}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<MaterialCommunityIcons name="send" size={20} color="#FFF" />
|
||||
</TouchableOpacity>
|
||||
) : sending ? (
|
||||
<TouchableOpacity
|
||||
style={[styles.sendButtonActive, styles.sendButtonDisabled]}
|
||||
disabled
|
||||
>
|
||||
<ActivityIndicator size="small" color="#FFF" />
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
style={[styles.iconButton, activePanel === 'more' && styles.iconButtonActive]}
|
||||
onPress={onToggleMore}
|
||||
disabled={isDisabled || disableMore}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={activePanel === 'more' ? "close-circle" : "plus-circle-outline"}
|
||||
size={26}
|
||||
color={isDisabled || disableMore ? '#CCC' : (activePanel === 'more' ? colors.primary.main : '#666')}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatInput;
|
||||
556
src/screens/message/components/ChatScreen/EmojiPanel.tsx
Normal file
556
src/screens/message/components/ChatScreen/EmojiPanel.tsx
Normal file
@@ -0,0 +1,556 @@
|
||||
/**
|
||||
* 表情面板组件
|
||||
* 支持 Emoji 和自定义表情 Tab 切换
|
||||
* 支持响应式布局(平板/桌面端显示更大的表情)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { View, TouchableOpacity, ActivityIndicator, Modal, Alert, Dimensions, StyleSheet, FlatList, ListRenderItem } from 'react-native';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { Text } from '../../../../components/common';
|
||||
import { chatScreenStyles as baseStyles } from './styles';
|
||||
import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive';
|
||||
import { EMOJIS } from './constants';
|
||||
import { CustomSticker, getCustomStickers, batchDeleteStickers, addStickerFromUrl } from '../../../../services/stickerService';
|
||||
import { colors, spacing } from '../../../../theme';
|
||||
|
||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
// 表情尺寸配置
|
||||
const EMOJI_SIZES = {
|
||||
mobile: { size: 28, itemHeight: 52 },
|
||||
tablet: { size: 32, itemHeight: 60 },
|
||||
desktop: { size: 36, itemHeight: 68 },
|
||||
};
|
||||
|
||||
// Tab 类型
|
||||
type TabType = 'emoji' | 'sticker';
|
||||
type StickerListItem = { type: 'manage' } | { type: 'sticker'; sticker: CustomSticker };
|
||||
type ManageListItem = { type: 'add' } | { type: 'sticker'; sticker: CustomSticker };
|
||||
|
||||
interface EmojiPanelProps {
|
||||
onInsertEmoji: (emoji: string) => void;
|
||||
onInsertSticker: (stickerUrl: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const EmojiPanel: React.FC<EmojiPanelProps> = ({
|
||||
onInsertEmoji,
|
||||
onInsertSticker,
|
||||
onClose,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState<TabType>('emoji');
|
||||
const [stickers, setStickers] = useState<CustomSticker[]>([]);
|
||||
const [loadingStickers, setLoadingStickers] = useState(false);
|
||||
const [showManageModal, setShowManageModal] = useState(false);
|
||||
const [selectedStickers, setSelectedStickers] = useState<Set<string>>(new Set());
|
||||
const [manageMode, setManageMode] = useState(false);
|
||||
const [addingSticker, setAddingSticker] = useState(false);
|
||||
|
||||
// 响应式布局
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
const isTablet = useBreakpointGTE('md');
|
||||
|
||||
// 获取表情尺寸
|
||||
const emojiSize = useMemo(() => {
|
||||
if (isWideScreen) return EMOJI_SIZES.desktop;
|
||||
if (isTablet) return EMOJI_SIZES.tablet;
|
||||
return EMOJI_SIZES.mobile;
|
||||
}, [isWideScreen, isTablet]);
|
||||
|
||||
// 表情网格列数
|
||||
const emojiColumns = useMemo(() => {
|
||||
if (isWideScreen) return 10;
|
||||
if (isTablet) return 9;
|
||||
return 8;
|
||||
}, [isWideScreen, isTablet]);
|
||||
|
||||
// 合并样式
|
||||
const styles = useMemo(() => {
|
||||
return StyleSheet.create({
|
||||
...baseStyles,
|
||||
emojiItem: {
|
||||
...baseStyles.emojiItem,
|
||||
width: `${100 / emojiColumns}%`,
|
||||
height: emojiSize.itemHeight,
|
||||
},
|
||||
emojiText: {
|
||||
...baseStyles.emojiText,
|
||||
fontSize: emojiSize.size,
|
||||
lineHeight: emojiSize.size + 6,
|
||||
},
|
||||
});
|
||||
}, [emojiSize, emojiColumns]);
|
||||
|
||||
// 加载自定义表情
|
||||
const loadStickers = useCallback(async () => {
|
||||
setLoadingStickers(true);
|
||||
try {
|
||||
const data = await getCustomStickers();
|
||||
setStickers(data);
|
||||
} catch (error) {
|
||||
console.error('加载自定义表情失败:', error);
|
||||
} finally {
|
||||
setLoadingStickers(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 切换到自定义表情 Tab 时加载数据
|
||||
useEffect(() => {
|
||||
if (activeTab === 'sticker') {
|
||||
loadStickers();
|
||||
}
|
||||
}, [activeTab, loadStickers]);
|
||||
|
||||
const renderEmojiItem: ListRenderItem<string> = useCallback(({ item }) => (
|
||||
<TouchableOpacity
|
||||
style={styles.emojiItem}
|
||||
onPress={() => onInsertEmoji(item)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.emojiText}>{item}</Text>
|
||||
</TouchableOpacity>
|
||||
), [styles.emojiItem, styles.emojiText, onInsertEmoji]);
|
||||
|
||||
// 渲染 Emoji 面板(使用 FlatList 虚拟化,降低首次打开卡顿)
|
||||
const renderEmojiPanel = () => (
|
||||
<FlatList
|
||||
data={EMOJIS}
|
||||
key={emojiColumns}
|
||||
numColumns={emojiColumns}
|
||||
keyExtractor={(item, index) => `${item}-${index}`}
|
||||
renderItem={renderEmojiItem}
|
||||
contentContainerStyle={styles.emojiGrid}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
initialNumToRender={emojiColumns * 4}
|
||||
maxToRenderPerBatch={emojiColumns * 4}
|
||||
windowSize={7}
|
||||
removeClippedSubviews={true}
|
||||
getItemLayout={(_, index) => ({
|
||||
length: emojiSize.itemHeight,
|
||||
offset: emojiSize.itemHeight * Math.floor(index / emojiColumns),
|
||||
index,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
||||
// 添加表情(从相册选择)
|
||||
const handleAddSticker = async () => {
|
||||
try {
|
||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!permissionResult.granted) {
|
||||
Alert.alert('提示', '需要相册权限才能添加表情');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
quality: 0.8,
|
||||
allowsEditing: false,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
setAddingSticker(true);
|
||||
const asset = result.assets[0];
|
||||
|
||||
try {
|
||||
const sticker = await addStickerFromUrl(
|
||||
asset.uri,
|
||||
asset.width,
|
||||
asset.height
|
||||
);
|
||||
|
||||
if (sticker) {
|
||||
await loadStickers();
|
||||
Alert.alert('成功', '表情添加成功');
|
||||
} else {
|
||||
Alert.alert('提示', '该表情已存在');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('添加表情失败:', error);
|
||||
Alert.alert('错误', '添加表情失败,请重试');
|
||||
} finally {
|
||||
setAddingSticker(false);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('选择图片失败:', error);
|
||||
Alert.alert('错误', '选择图片失败');
|
||||
}
|
||||
};
|
||||
|
||||
// 打开管理界面
|
||||
const handleOpenManage = () => {
|
||||
setShowManageModal(true);
|
||||
setManageMode(false);
|
||||
setSelectedStickers(new Set());
|
||||
};
|
||||
|
||||
// 关闭管理界面
|
||||
const handleCloseManage = () => {
|
||||
setShowManageModal(false);
|
||||
setManageMode(false);
|
||||
setSelectedStickers(new Set());
|
||||
};
|
||||
|
||||
// 切换选择表情
|
||||
const toggleStickerSelection = (stickerId: string) => {
|
||||
const newSelected = new Set(selectedStickers);
|
||||
if (newSelected.has(stickerId)) {
|
||||
newSelected.delete(stickerId);
|
||||
} else {
|
||||
newSelected.add(stickerId);
|
||||
}
|
||||
setSelectedStickers(newSelected);
|
||||
};
|
||||
|
||||
// 删除选中的表情
|
||||
const handleDeleteSelected = async () => {
|
||||
if (selectedStickers.size === 0) {
|
||||
Alert.alert('提示', '请先选择要删除的表情');
|
||||
return;
|
||||
}
|
||||
|
||||
Alert.alert(
|
||||
'确认删除',
|
||||
`确定要删除选中的 ${selectedStickers.size} 个表情吗?`,
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '删除',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
const stickerIds = Array.from(selectedStickers);
|
||||
const result = await batchDeleteStickers(stickerIds);
|
||||
|
||||
// 重新加载表情列表
|
||||
await loadStickers();
|
||||
setSelectedStickers(new Set());
|
||||
setManageMode(false);
|
||||
|
||||
if (result.failed > 0) {
|
||||
Alert.alert('完成', `成功删除 ${result.success} 个表情,失败 ${result.failed} 个`);
|
||||
} else {
|
||||
Alert.alert('完成', `成功删除 ${result.success} 个表情`);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染管理按钮(表情面板第一位)
|
||||
const renderManageButton = () => (
|
||||
<TouchableOpacity
|
||||
key="manage-button"
|
||||
style={[styles.stickerItem, styles.stickerManageButton]}
|
||||
onPress={handleOpenManage}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="cog-outline"
|
||||
size={40}
|
||||
color="#666"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
const stickerListData = useMemo<StickerListItem[]>(() => {
|
||||
return [{ type: 'manage' }, ...stickers.map(sticker => ({ type: 'sticker', sticker } as const))];
|
||||
}, [stickers]);
|
||||
|
||||
const manageListData = useMemo<ManageListItem[]>(() => {
|
||||
const base = stickers.map(sticker => ({ type: 'sticker', sticker } as const));
|
||||
return manageMode ? base : ([{ type: 'add' } as const, ...base]);
|
||||
}, [stickers, manageMode]);
|
||||
|
||||
const renderStickerListItem: ListRenderItem<StickerListItem> = useCallback(({ item }) => {
|
||||
if (item.type === 'manage') {
|
||||
return renderManageButton();
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.stickerItem}
|
||||
onPress={() => onInsertSticker(item.sticker.url)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<ExpoImage
|
||||
source={{ uri: item.sticker.url }}
|
||||
style={styles.stickerImage}
|
||||
contentFit="cover"
|
||||
cachePolicy="memory"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}, [styles.stickerItem, styles.stickerImage, onInsertSticker]);
|
||||
|
||||
// 渲染自定义表情面板
|
||||
const renderStickerPanel = () => {
|
||||
if (loadingStickers) {
|
||||
return (
|
||||
<View style={styles.stickerLoadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={stickerListData}
|
||||
numColumns={4}
|
||||
keyExtractor={(item) => item.type === 'manage' ? 'manage-button' : item.sticker.id}
|
||||
renderItem={renderStickerListItem}
|
||||
contentContainerStyle={styles.stickerGrid}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
initialNumToRender={16}
|
||||
maxToRenderPerBatch={16}
|
||||
windowSize={7}
|
||||
removeClippedSubviews={true}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染管理界面中的添加按钮(第一位)
|
||||
const renderManageAddButton = () => (
|
||||
<TouchableOpacity
|
||||
key="manage-add-button"
|
||||
style={[styles.stickerItem, styles.stickerAddButton]}
|
||||
onPress={handleAddSticker}
|
||||
disabled={addingSticker}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{addingSticker ? (
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
) : (
|
||||
<View style={styles.stickerAddContent}>
|
||||
<MaterialCommunityIcons
|
||||
name="plus"
|
||||
size={36}
|
||||
color={colors.primary.main}
|
||||
/>
|
||||
<Text style={[styles.stickerAddText, { color: colors.primary.main }]}>添加</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
// 渲染管理界面中的表情项
|
||||
const renderManageStickerItem = (sticker: CustomSticker) => {
|
||||
const isSelected = selectedStickers.has(sticker.id);
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={sticker.id}
|
||||
style={[
|
||||
styles.stickerItem,
|
||||
manageMode && isSelected && styles.stickerItemSelected,
|
||||
]}
|
||||
onPress={() => {
|
||||
if (manageMode) {
|
||||
toggleStickerSelection(sticker.id);
|
||||
}
|
||||
}}
|
||||
onLongPress={() => {
|
||||
if (!manageMode) {
|
||||
setManageMode(true);
|
||||
toggleStickerSelection(sticker.id);
|
||||
}
|
||||
}}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<ExpoImage
|
||||
source={{ uri: sticker.url }}
|
||||
style={styles.stickerImage}
|
||||
contentFit="cover"
|
||||
cachePolicy="memory"
|
||||
/>
|
||||
{manageMode && (
|
||||
<View style={styles.stickerCheckOverlay}>
|
||||
<View style={[
|
||||
styles.stickerCheckBox,
|
||||
isSelected && styles.stickerCheckBoxSelected,
|
||||
]}>
|
||||
{isSelected && (
|
||||
<MaterialCommunityIcons name="check" size={16} color="#FFF" />
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const renderManageListItem: ListRenderItem<ManageListItem> = useCallback(({ item }) => {
|
||||
if (item.type === 'add') {
|
||||
return renderManageAddButton();
|
||||
}
|
||||
return renderManageStickerItem(item.sticker);
|
||||
}, [addingSticker, manageMode, selectedStickers, styles.stickerItem, styles.stickerImage]);
|
||||
|
||||
// 渲染管理模态框(2/3 高度)
|
||||
const renderManageModal = () => (
|
||||
<Modal
|
||||
visible={showManageModal}
|
||||
animationType="slide"
|
||||
transparent={true}
|
||||
onRequestClose={handleCloseManage}
|
||||
>
|
||||
<View style={styles.manageModalOverlay}>
|
||||
<TouchableOpacity
|
||||
style={styles.manageModalBackdrop}
|
||||
activeOpacity={1}
|
||||
onPress={handleCloseManage}
|
||||
/>
|
||||
<View style={[styles.manageModalContainer, { height: SCREEN_HEIGHT * 2 / 3 }]}>
|
||||
{/* 拖动指示器 */}
|
||||
<View style={styles.manageModalHandle} />
|
||||
|
||||
{/* 头部 */}
|
||||
<View style={styles.manageModalHeader}>
|
||||
<TouchableOpacity onPress={handleCloseManage} style={styles.manageHeaderButton}>
|
||||
<MaterialCommunityIcons name="chevron-left" size={28} color={colors.text.secondary} />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.manageModalTitle}>表情管理</Text>
|
||||
{manageMode ? (
|
||||
<TouchableOpacity
|
||||
onPress={() => {
|
||||
setManageMode(false);
|
||||
setSelectedStickers(new Set());
|
||||
}}
|
||||
style={styles.manageHeaderButton}
|
||||
>
|
||||
<Text style={[styles.manageDoneText, { color: colors.primary.main }]}>完成</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<TouchableOpacity
|
||||
onPress={() => setManageMode(true)}
|
||||
style={styles.manageHeaderButton}
|
||||
>
|
||||
<Text style={[styles.manageSelectText, { color: colors.primary.main }]}>编辑</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<View style={styles.manageHeaderDivider} />
|
||||
|
||||
{/* 表情网格 */}
|
||||
{loadingStickers ? (
|
||||
<View style={styles.stickerLoadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
data={manageListData}
|
||||
numColumns={4}
|
||||
keyExtractor={(item) => item.type === 'add' ? 'manage-add-button' : item.sticker.id}
|
||||
renderItem={renderManageListItem}
|
||||
contentContainerStyle={styles.manageStickerGrid}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
initialNumToRender={16}
|
||||
maxToRenderPerBatch={16}
|
||||
windowSize={7}
|
||||
removeClippedSubviews={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 底部操作栏(管理模式下显示) */}
|
||||
{manageMode && (
|
||||
<View style={styles.manageBottomBar}>
|
||||
<TouchableOpacity
|
||||
style={styles.manageSelectAllButton}
|
||||
onPress={() => {
|
||||
if (selectedStickers.size === stickers.length) {
|
||||
setSelectedStickers(new Set());
|
||||
} else {
|
||||
setSelectedStickers(new Set(stickers.map(s => s.id)));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.manageSelectAllText, { color: colors.primary.main }]}>
|
||||
{selectedStickers.size === stickers.length ? '取消全选' : '全选'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.manageDeleteButton,
|
||||
selectedStickers.size === 0 && styles.manageDeleteButtonDisabled,
|
||||
]}
|
||||
onPress={handleDeleteSelected}
|
||||
disabled={selectedStickers.size === 0}
|
||||
>
|
||||
<MaterialCommunityIcons name="delete-outline" size={20} color="#FFF" />
|
||||
<Text style={styles.manageDeleteText}>
|
||||
删除{selectedStickers.size > 0 && ` (${selectedStickers.size})`}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 使用提示 */}
|
||||
{!manageMode && stickers.length > 0 && (
|
||||
<View style={styles.manageTipBar}>
|
||||
<MaterialCommunityIcons name="information-outline" size={16} color={colors.text.hint} />
|
||||
<Text style={[styles.manageTipText, { color: colors.text.hint }]}>长按表情可删除</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 空状态 */}
|
||||
{!loadingStickers && stickers.length === 0 && !manageMode && (
|
||||
<View style={styles.manageEmptyContainer}>
|
||||
<View style={[styles.manageEmptyIconBg, { backgroundColor: colors.primary.light + '20' }]}>
|
||||
<MaterialCommunityIcons name="emoticon-happy-outline" size={48} color={colors.primary.main} />
|
||||
</View>
|
||||
<Text style={[styles.manageEmptyTitle, { color: colors.text.primary }]}>暂无表情</Text>
|
||||
<Text style={[styles.manageEmptyDesc, { color: colors.text.secondary }]}>点击上方的 + 添加您喜欢的表情</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.panelContainer}>
|
||||
{/* 内容区域 */}
|
||||
<View style={styles.panelContent}>
|
||||
{activeTab === 'emoji' ? renderEmojiPanel() : renderStickerPanel()}
|
||||
</View>
|
||||
|
||||
{/* 底部 Tab 栏 */}
|
||||
<View style={styles.panelTabBar}>
|
||||
<TouchableOpacity
|
||||
style={[styles.panelTab, activeTab === 'emoji' && styles.panelTabActive]}
|
||||
onPress={() => setActiveTab('emoji')}
|
||||
>
|
||||
<Text style={styles.panelTabEmoji}>😊</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.panelTab, activeTab === 'sticker' && styles.panelTabActive]}
|
||||
onPress={() => setActiveTab('sticker')}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="emoticon-happy-outline"
|
||||
size={24}
|
||||
color={activeTab === 'sticker' ? colors.primary.main : '#666'}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<View style={styles.panelTabDivider} />
|
||||
<TouchableOpacity style={styles.panelCloseButton} onPress={onClose}>
|
||||
<MaterialCommunityIcons name="keyboard" size={24} color="#666" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 表情管理模态框 */}
|
||||
{renderManageModal()}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default EmojiPanel;
|
||||
296
src/screens/message/components/ChatScreen/LongPressMenu.tsx
Normal file
296
src/screens/message/components/ChatScreen/LongPressMenu.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* 长按菜单组件 - 气泡旁边弹出形式(类似手机QQ)
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Modal,
|
||||
TouchableOpacity,
|
||||
TouchableWithoutFeedback,
|
||||
Animated,
|
||||
Alert,
|
||||
Clipboard,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { chatScreenStyles as styles } from './styles';
|
||||
import { LongPressMenuProps } from './types';
|
||||
import { RECALL_TIME_LIMIT } from './constants';
|
||||
import { extractTextFromSegments, ImageSegmentData } from '../../../../types/dto';
|
||||
import { isStickerExists, addStickerFromUrl } from '../../../../services/stickerService';
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
export const LongPressMenu: React.FC<LongPressMenuProps> = ({
|
||||
visible,
|
||||
message,
|
||||
currentUserId,
|
||||
position,
|
||||
onClose,
|
||||
onReply,
|
||||
onRecall,
|
||||
onDelete,
|
||||
onAddSticker,
|
||||
}) => {
|
||||
const scaleAnimation = useRef(new Animated.Value(0)).current;
|
||||
const opacityAnimation = useRef(new Animated.Value(0)).current;
|
||||
|
||||
// 显示动画 - 缩放弹出
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.spring(scaleAnimation, {
|
||||
toValue: 1,
|
||||
useNativeDriver: true,
|
||||
friction: 8,
|
||||
tension: 100,
|
||||
}),
|
||||
Animated.timing(opacityAnimation, {
|
||||
toValue: 1,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(scaleAnimation, {
|
||||
toValue: 0,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(opacityAnimation, {
|
||||
toValue: 0,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [visible, scaleAnimation, opacityAnimation]);
|
||||
|
||||
if (!message) return null;
|
||||
|
||||
const isMyMessage = message.sender_id === currentUserId;
|
||||
const messageTime = new Date(message.created_at).getTime();
|
||||
const now = Date.now();
|
||||
const canRecall = isMyMessage &&
|
||||
message.status !== 'recalled' &&
|
||||
(now - messageTime) < RECALL_TIME_LIMIT;
|
||||
|
||||
// 检查是否是图片消息
|
||||
const segments = Array.isArray(message.segments) ? message.segments : [];
|
||||
const imageSegment = segments.find(s => s.type === 'image');
|
||||
const isImageMessage = !!imageSegment;
|
||||
const imageUrl = imageSegment ? (imageSegment.data as ImageSegmentData)?.url : null;
|
||||
|
||||
// 复制文本到剪贴板
|
||||
const handleCopy = () => {
|
||||
const text = extractTextFromSegments(message.segments);
|
||||
if (text) {
|
||||
Clipboard.setString(text);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
// 多选功能(这里简化为复制全部)
|
||||
const handleMultiSelect = () => {
|
||||
const text = extractTextFromSegments(message.segments);
|
||||
if (text) {
|
||||
Clipboard.setString(text);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
Alert.alert(
|
||||
'确认删除',
|
||||
'删除后,该消息将从你的设备上移除。确定要删除吗?',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '删除',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
onDelete(message.id);
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// 添加到表情
|
||||
const handleAddSticker = async () => {
|
||||
if (!imageUrl) return;
|
||||
|
||||
onClose();
|
||||
|
||||
// 检查是否已存在
|
||||
const exists = await isStickerExists(imageUrl);
|
||||
if (exists) {
|
||||
Alert.alert('提示', '该表情已存在');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const sticker = await addStickerFromUrl(imageUrl);
|
||||
if (sticker) {
|
||||
Alert.alert('成功', '已添加到表情');
|
||||
onAddSticker?.(imageUrl);
|
||||
} else {
|
||||
Alert.alert('提示', '该表情已存在');
|
||||
}
|
||||
} catch (error) {
|
||||
Alert.alert('失败', '添加表情失败,请重试');
|
||||
}
|
||||
};
|
||||
|
||||
// 菜单项配置 - 类似QQ的横向排列
|
||||
const menuItems = [
|
||||
{
|
||||
id: 'reply',
|
||||
label: '回复',
|
||||
icon: 'reply',
|
||||
onPress: () => {
|
||||
onReply(message);
|
||||
onClose();
|
||||
},
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
id: 'copy',
|
||||
label: '复制',
|
||||
icon: 'content-copy',
|
||||
onPress: handleCopy,
|
||||
show: !message.is_system_notice && message.status !== 'recalled',
|
||||
},
|
||||
{
|
||||
id: 'multiSelect',
|
||||
label: '多选',
|
||||
icon: 'checkbox-multiple-marked-outline',
|
||||
onPress: handleMultiSelect,
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
id: 'addSticker',
|
||||
label: '添加表情',
|
||||
icon: 'emoticon-happy-outline',
|
||||
onPress: handleAddSticker,
|
||||
show: isImageMessage && message.status !== 'recalled',
|
||||
},
|
||||
{
|
||||
id: 'recall',
|
||||
label: '撤回',
|
||||
icon: 'undo',
|
||||
onPress: () => {
|
||||
onRecall(message.id);
|
||||
onClose();
|
||||
},
|
||||
show: canRecall,
|
||||
},
|
||||
{
|
||||
id: 'delete',
|
||||
label: '删除',
|
||||
icon: 'delete-outline',
|
||||
onPress: handleDelete,
|
||||
show: true,
|
||||
},
|
||||
].filter(item => item.show);
|
||||
|
||||
// 计算菜单位置 - 紧贴手指位置显示在气泡上方
|
||||
const calculatePosition = () => {
|
||||
if (!position) {
|
||||
// 默认居中显示
|
||||
return {
|
||||
top: SCREEN_HEIGHT / 2 - 50,
|
||||
left: SCREEN_WIDTH / 2 - 120,
|
||||
};
|
||||
}
|
||||
|
||||
const menuWidth = Math.max(menuItems.length * 56, 180);
|
||||
const menuHeight = 54;
|
||||
|
||||
// 使用按压点位置(手指位置)
|
||||
const pressX = position.pressX ?? position.x + position.width / 2;
|
||||
const pressY = position.pressY ?? position.y;
|
||||
|
||||
// 在手指位置上方显示菜单,留出 8px 间距
|
||||
let top = pressY - menuHeight - 8;
|
||||
|
||||
// 水平居中于手指位置
|
||||
let left = pressX - menuWidth / 2;
|
||||
|
||||
// 如果上方空间不足,显示在手指位置下方
|
||||
if (top < 50) {
|
||||
top = pressY + 8;
|
||||
}
|
||||
|
||||
// 确保不超出屏幕左右边界
|
||||
const minLeft = 8;
|
||||
const maxLeft = SCREEN_WIDTH - menuWidth - 8;
|
||||
|
||||
if (left < minLeft) left = minLeft;
|
||||
if (left > maxLeft) left = maxLeft;
|
||||
|
||||
return { top, left };
|
||||
};
|
||||
|
||||
const { top, left } = calculatePosition();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<TouchableWithoutFeedback onPress={onClose}>
|
||||
<View style={styles.qqMenuOverlay}>
|
||||
<TouchableWithoutFeedback>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.qqMenuContainer,
|
||||
{
|
||||
top,
|
||||
left,
|
||||
opacity: opacityAnimation,
|
||||
transform: [
|
||||
{ scale: scaleAnimation },
|
||||
],
|
||||
},
|
||||
]}
|
||||
>
|
||||
{/* 操作按钮横向排列 */}
|
||||
<View style={styles.qqMenuItemsRow}>
|
||||
{menuItems.map((item, index) => (
|
||||
<TouchableOpacity
|
||||
key={item.id}
|
||||
style={[
|
||||
styles.qqMenuItem,
|
||||
index < menuItems.length - 1 && styles.qqMenuItemWithBorder,
|
||||
]}
|
||||
onPress={item.onPress}
|
||||
activeOpacity={0.6}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={item.icon as any}
|
||||
size={20}
|
||||
color="#FFFFFF"
|
||||
/>
|
||||
<View style={styles.qqMenuItemLabelContainer}>
|
||||
<Animated.Text style={styles.qqMenuItemLabel}>
|
||||
{item.label}
|
||||
</Animated.Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</Animated.View>
|
||||
</TouchableWithoutFeedback>
|
||||
</View>
|
||||
</TouchableWithoutFeedback>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default LongPressMenu;
|
||||
102
src/screens/message/components/ChatScreen/MentionPanel.tsx
Normal file
102
src/screens/message/components/ChatScreen/MentionPanel.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @成员选择面板组件
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, ScrollView, TouchableOpacity } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Avatar, Text } from '../../../../components/common';
|
||||
import { chatScreenStyles as styles } from './styles';
|
||||
import { MentionPanelProps } from './types';
|
||||
|
||||
export const MentionPanel: React.FC<MentionPanelProps> = ({
|
||||
members,
|
||||
currentUserId,
|
||||
mentionQuery,
|
||||
currentUserRole,
|
||||
onSelectMention,
|
||||
onMentionAll,
|
||||
onClose,
|
||||
}) => {
|
||||
// 过滤成员列表
|
||||
const filteredMembers = members.filter(member => {
|
||||
if (member.user_id === currentUserId) return false; // 排除自己
|
||||
const displayName = member.nickname || member.user?.nickname || '';
|
||||
return displayName.toLowerCase().includes(mentionQuery);
|
||||
});
|
||||
|
||||
// 检查是否可以@所有人(仅群主和管理员)
|
||||
const canMentionAll = currentUserRole === 'owner' || currentUserRole === 'admin';
|
||||
|
||||
return (
|
||||
<View style={styles.mentionPanelContainer}>
|
||||
{/* 拖动把手 */}
|
||||
<View style={styles.mentionPanelDragHandle}>
|
||||
<View style={styles.mentionPanelDragBar} />
|
||||
</View>
|
||||
|
||||
{/* 标题栏 */}
|
||||
<View style={styles.mentionPanelHeader}>
|
||||
<Text style={styles.mentionPanelTitle}>@ 成员</Text>
|
||||
<TouchableOpacity style={styles.mentionPanelCloseBtn} onPress={onClose}>
|
||||
<MaterialCommunityIcons name="close" size={14} color="#999" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.mentionList}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
showsVerticalScrollIndicator={true}
|
||||
nestedScrollEnabled={true}
|
||||
>
|
||||
{/* @所有人选项 */}
|
||||
{canMentionAll && (
|
||||
<TouchableOpacity style={styles.mentionItemAll} onPress={onMentionAll}>
|
||||
<View style={styles.mentionAllIcon}>
|
||||
<MaterialCommunityIcons name="account-group" size={22} color="#FFF" />
|
||||
</View>
|
||||
<View style={styles.mentionItemInfo}>
|
||||
<Text style={styles.mentionItemNameAll}>所有人</Text>
|
||||
<Text style={styles.mentionItemSub}>通知群内所有成员</Text>
|
||||
</View>
|
||||
<MaterialCommunityIcons name="chevron-right" size={18} color="rgba(255,107,53,0.4)" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* 成员列表 */}
|
||||
{filteredMembers.map(member => (
|
||||
<TouchableOpacity
|
||||
key={member.id}
|
||||
style={styles.mentionItem}
|
||||
onPress={() => onSelectMention(member)}
|
||||
>
|
||||
<Avatar
|
||||
source={member.user?.avatar || null}
|
||||
size={38}
|
||||
name={member.nickname || member.user?.nickname || ''}
|
||||
/>
|
||||
<View style={styles.mentionItemInfo}>
|
||||
<Text style={styles.mentionItemName}>
|
||||
{member.nickname || member.user?.nickname || '用户'}
|
||||
</Text>
|
||||
{member.role === 'owner' && (
|
||||
<Text style={styles.mentionItemRole}>群主</Text>
|
||||
)}
|
||||
{member.role === 'admin' && (
|
||||
<Text style={styles.mentionItemRole}>管理员</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
|
||||
{filteredMembers.length === 0 && (
|
||||
<View style={styles.mentionEmpty}>
|
||||
<Text style={styles.mentionEmptyText}>没有找到匹配的成员</Text>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default MentionPanel;
|
||||
471
src/screens/message/components/ChatScreen/MessageBubble.tsx
Normal file
471
src/screens/message/components/ChatScreen/MessageBubble.tsx
Normal file
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* 消息气泡组件
|
||||
* 支持消息链(Segment数组)渲染
|
||||
* 支持响应式布局(宽屏下优化显示)
|
||||
*/
|
||||
|
||||
import React, { useRef, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
Image,
|
||||
findNodeHandle,
|
||||
UIManager,
|
||||
GestureResponderEvent,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Avatar, Text, ImageGridItem } from '../../../../components/common';
|
||||
import { chatScreenStyles as baseStyles } from './styles';
|
||||
import { useResponsive, useBreakpointGTE } from '../../../../hooks/useResponsive';
|
||||
import { MessageBubbleProps, SenderInfo, MenuPosition, GroupMessage } from './types';
|
||||
import { MessageSegmentsRenderer } from './SegmentRenderer';
|
||||
import { MessageSegment, ImageSegmentData, extractTextFromSegments } from '../../../../types/dto';
|
||||
|
||||
// 获取屏幕宽度
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
|
||||
// 消息内容最大宽度比例
|
||||
const MAX_WIDTH_RATIO = {
|
||||
mobile: 0.75,
|
||||
tablet: 0.65,
|
||||
desktop: 0.55,
|
||||
};
|
||||
|
||||
export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
||||
message,
|
||||
index,
|
||||
currentUserId,
|
||||
currentUser,
|
||||
otherUser,
|
||||
isGroupChat,
|
||||
groupMembers,
|
||||
otherUserLastReadSeq,
|
||||
selectedMessageId,
|
||||
messageMap,
|
||||
onLongPress,
|
||||
onAvatarPress,
|
||||
onAvatarLongPress,
|
||||
formatTime,
|
||||
shouldShowTime,
|
||||
onImagePress,
|
||||
}) => {
|
||||
const bubbleRef = useRef<View>(null);
|
||||
const pressPositionRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
// 响应式布局
|
||||
const { width } = useResponsive();
|
||||
const isWideScreen = useBreakpointGTE('lg');
|
||||
const isTablet = useBreakpointGTE('md');
|
||||
|
||||
// 计算消息内容最大宽度
|
||||
const maxContentWidth = useMemo(() => {
|
||||
const ratio = isWideScreen ? MAX_WIDTH_RATIO.desktop :
|
||||
isTablet ? MAX_WIDTH_RATIO.tablet :
|
||||
MAX_WIDTH_RATIO.mobile;
|
||||
return Math.min(SCREEN_WIDTH * ratio, 600);
|
||||
}, [isWideScreen, isTablet]);
|
||||
|
||||
// 合并样式
|
||||
const styles = useMemo(() => {
|
||||
return StyleSheet.create({
|
||||
...baseStyles,
|
||||
messageContent: {
|
||||
...baseStyles.messageContent,
|
||||
maxWidth: maxContentWidth,
|
||||
},
|
||||
messageImage: {
|
||||
...baseStyles.messageImage,
|
||||
width: isWideScreen ? 280 : 220,
|
||||
height: isWideScreen ? 280 : 220,
|
||||
},
|
||||
});
|
||||
}, [maxContentWidth, isWideScreen]);
|
||||
|
||||
// 系统通知消息特殊处理
|
||||
// 支持两种判断方式:
|
||||
// 1. is_system_notice 字段(WebSocket 推送时设置)
|
||||
// 2. category === 'notification'(从数据库加载时使用)
|
||||
// 3. sender_id === '10000'(系统发送者兜底)
|
||||
const isSystemNotice = message.is_system_notice || message.category === 'notification' || message.sender_id === '10000';
|
||||
|
||||
const isMe = message.sender_id === currentUserId;
|
||||
const showTime = shouldShowTime(index);
|
||||
const isRecalled = message.status === 'recalled';
|
||||
const isRead = !isGroupChat && isMe && message.seq <= otherUserLastReadSeq;
|
||||
const isLastReadMessage = !isGroupChat && isMe && message.seq === otherUserLastReadSeq;
|
||||
|
||||
// 检查是否有消息链(必须使用 segments 格式)
|
||||
// 安全检查:确保 segments 是数组
|
||||
const segments = Array.isArray(message.segments) ? message.segments : [];
|
||||
const hasSegments = segments.length > 0;
|
||||
// 检查是否是图片消息
|
||||
const isImage = Array.isArray(segments) && segments.some(s => s.type === 'image');
|
||||
// 检查是否是纯图片消息(只有图片segment,没有文本等其他内容)
|
||||
const isPureImageMessage = isImage && segments.every(s => s.type === 'image' || !s.type);
|
||||
|
||||
// 提取所有图片 segments
|
||||
const imageSegments = segments
|
||||
.filter(s => s.type === 'image')
|
||||
.map((s, idx) => {
|
||||
const data = s.data as ImageSegmentData;
|
||||
return {
|
||||
id: `img-${message.id}-${idx}`,
|
||||
url: data.url,
|
||||
thumbnail_url: data.thumbnail_url,
|
||||
width: data.width,
|
||||
height: data.height,
|
||||
};
|
||||
});
|
||||
|
||||
// 检查当前消息是否被选中
|
||||
const isSelected = selectedMessageId === String(message.id);
|
||||
|
||||
// 获取发送者信息(群聊)- 供子组件使用
|
||||
const getSenderInfo = React.useCallback((senderId: string): SenderInfo => {
|
||||
if (senderId === currentUserId) {
|
||||
return {
|
||||
nickname: currentUser?.nickname || '我',
|
||||
avatar: currentUser?.avatar || null,
|
||||
userId: currentUserId,
|
||||
};
|
||||
}
|
||||
|
||||
// 兜底:从群成员列表查找
|
||||
const member = groupMembers.find(m => m.user_id === senderId);
|
||||
if (member) {
|
||||
return {
|
||||
nickname: member.nickname || member.user?.nickname || '用户',
|
||||
avatar: member.user?.avatar || null,
|
||||
userId: member.user_id,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
nickname: '用户',
|
||||
avatar: null,
|
||||
userId: senderId,
|
||||
};
|
||||
}, [currentUserId, currentUser?.nickname, currentUser?.avatar, groupMembers]);
|
||||
|
||||
// 使用useMemo确保senderInfo在message.sender变化时重新计算
|
||||
const senderInfo = React.useMemo((): SenderInfo | null => {
|
||||
if (!isGroupChat) return null;
|
||||
|
||||
const senderId = message.sender_id;
|
||||
|
||||
if (senderId === currentUserId) {
|
||||
return {
|
||||
nickname: currentUser?.nickname || '我',
|
||||
avatar: currentUser?.avatar || null,
|
||||
userId: currentUserId,
|
||||
};
|
||||
}
|
||||
|
||||
// 优先使用消息对象中携带的sender信息(MessageManager 已填充)
|
||||
if (message.sender && (message.sender.avatar || message.sender.nickname)) {
|
||||
return {
|
||||
nickname: message.sender.nickname || message.sender.username || '用户',
|
||||
avatar: message.sender.avatar || null,
|
||||
userId: senderId,
|
||||
};
|
||||
}
|
||||
|
||||
// 兜底:从群成员列表查找
|
||||
const member = groupMembers.find(m => m.user_id === senderId);
|
||||
if (member) {
|
||||
return {
|
||||
nickname: member.nickname || member.user?.nickname || '用户',
|
||||
avatar: member.user?.avatar || null,
|
||||
userId: member.user_id,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
nickname: '用户',
|
||||
avatar: null,
|
||||
userId: senderId,
|
||||
};
|
||||
}, [isGroupChat, message.sender_id, message.sender, currentUserId, currentUser?.nickname, currentUser?.avatar, groupMembers]);
|
||||
|
||||
// 创建成员映射(用于@高亮)
|
||||
const memberMap = useMemo(() => {
|
||||
const map = new Map<string, { nickname: string; user_id: string }>();
|
||||
groupMembers.forEach(m => {
|
||||
map.set(m.user_id, {
|
||||
nickname: m.nickname || m.user?.nickname || '用户',
|
||||
user_id: m.user_id,
|
||||
});
|
||||
});
|
||||
return map;
|
||||
}, [groupMembers]);
|
||||
|
||||
// 记录按压位置
|
||||
const handlePressIn = (event: GestureResponderEvent) => {
|
||||
const { pageX, pageY } = event.nativeEvent;
|
||||
pressPositionRef.current = { x: pageX, y: pageY };
|
||||
};
|
||||
|
||||
// 处理长按并获取位置
|
||||
const handleLongPress = () => {
|
||||
if (bubbleRef.current) {
|
||||
const node = findNodeHandle(bubbleRef.current);
|
||||
if (node) {
|
||||
UIManager.measureInWindow(node, (x, y, width, height) => {
|
||||
const position: MenuPosition = {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
pressX: pressPositionRef.current.x,
|
||||
pressY: pressPositionRef.current.y,
|
||||
};
|
||||
onLongPress(message, position);
|
||||
});
|
||||
} else {
|
||||
onLongPress(message);
|
||||
}
|
||||
} else {
|
||||
onLongPress(message);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染消息内容
|
||||
const renderMessageContent = () => {
|
||||
// 撤回消息
|
||||
if (isRecalled) {
|
||||
return (
|
||||
<View style={[
|
||||
styles.messageBubble,
|
||||
isMe ? styles.myBubble : styles.theirBubble,
|
||||
styles.recalledBubble,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
isMe ? styles.myBubbleText : styles.theirBubbleText,
|
||||
styles.recalledText,
|
||||
]}
|
||||
selectable={false}
|
||||
>
|
||||
消息已撤回
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 使用消息链渲染(必须使用 segments 格式)
|
||||
// 从 segments 中获取被回复消息的 ID 和 seq,然后从 messageMap 中查找
|
||||
const getReplyMessage = (): GroupMessage | undefined => {
|
||||
const replySegment = segments.find(s => s.type === 'reply');
|
||||
if (replySegment && messageMap) {
|
||||
const replyData = replySegment.data as { id: string; seq?: number };
|
||||
const replyId = String(replyData.id);
|
||||
|
||||
// 首先尝试通过 ID 查找
|
||||
let found = messageMap.get(replyId);
|
||||
|
||||
// 如果通过 ID 找不到,尝试通过 seq 查找
|
||||
if (!found && replyData.seq !== undefined) {
|
||||
found = Array.from(messageMap.values()).find(msg => msg.seq === replyData.seq);
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// 如果没有 segments,显示空内容或错误提示
|
||||
if (!hasSegments) {
|
||||
return (
|
||||
<View style={[
|
||||
styles.messageBubble,
|
||||
isMe ? styles.myBubble : styles.theirBubble,
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
isMe ? styles.myBubbleText : styles.theirBubbleText,
|
||||
{ opacity: 0.5 },
|
||||
]}
|
||||
selectable={false}
|
||||
>
|
||||
[消息格式错误]
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 检查是否有回复,用于调整气泡样式
|
||||
const hasReply = segments.some(s => s.type === 'reply');
|
||||
|
||||
return (
|
||||
<View style={[
|
||||
styles.messageBubble,
|
||||
isMe ? styles.myBubble : styles.theirBubble,
|
||||
hasReply && segmentStyles.replyBubble,
|
||||
isPureImageMessage && segmentStyles.pureImageBubble,
|
||||
]}>
|
||||
<MessageSegmentsRenderer
|
||||
segments={segments}
|
||||
isMe={isMe}
|
||||
currentUserId={currentUserId}
|
||||
memberMap={memberMap}
|
||||
replyMessage={getReplyMessage()}
|
||||
getSenderInfo={getSenderInfo}
|
||||
onAtPress={(userId) => {
|
||||
// TODO: 跳转到用户资料页
|
||||
console.log('At pressed:', userId);
|
||||
}}
|
||||
onReplyPress={(messageId) => {
|
||||
// TODO: 滚动到被回复的消息
|
||||
console.log('Reply pressed:', messageId);
|
||||
}}
|
||||
onImagePress={(url) => {
|
||||
// 查找点击的图片索引
|
||||
const clickIndex = imageSegments.findIndex(img => img.url === url);
|
||||
if (clickIndex !== -1 && onImagePress) {
|
||||
onImagePress(imageSegments, clickIndex);
|
||||
}
|
||||
}}
|
||||
onImageLongPress={(position?: { x: number; y: number }) => {
|
||||
// 图片长按触发和消息气泡一样的菜单
|
||||
// 如果有位置信息,直接使用;否则使用气泡位置
|
||||
if (position) {
|
||||
onLongPress(message, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
pressX: position.x,
|
||||
pressY: position.y,
|
||||
});
|
||||
} else {
|
||||
handleLongPress();
|
||||
}
|
||||
}}
|
||||
onLinkPress={(url) => {
|
||||
console.log('Link pressed:', url);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 系统通知消息渲染
|
||||
if (isSystemNotice) {
|
||||
return (
|
||||
<View>
|
||||
{showTime && (
|
||||
<View style={styles.timeContainer}>
|
||||
<Text style={styles.timeText}>
|
||||
{formatTime(message.created_at)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.systemNoticeContainer}>
|
||||
<Text style={styles.systemNoticeText}>{message.notice_content || extractTextFromSegments(message.segments)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
onPressIn={handlePressIn}
|
||||
onLongPress={handleLongPress}
|
||||
activeOpacity={1}
|
||||
>
|
||||
<View ref={bubbleRef}>
|
||||
{showTime && (
|
||||
<View style={styles.timeContainer}>
|
||||
<Text style={styles.timeText}>
|
||||
{formatTime(message.created_at)}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[styles.messageRow, isMe ? styles.myMessageRow : styles.theirMessageRow]}>
|
||||
{/* 群聊模式:显示发送者头像和昵称 */}
|
||||
{isGroupChat && !isMe && senderInfo && (
|
||||
<TouchableOpacity
|
||||
style={styles.groupAvatarWrapper}
|
||||
onPress={() => senderInfo?.userId && onAvatarPress(senderInfo.userId)}
|
||||
onLongPress={() => onAvatarLongPress(message.sender_id, senderInfo.nickname)}
|
||||
delayLongPress={500}
|
||||
>
|
||||
<Avatar
|
||||
source={senderInfo.avatar}
|
||||
size={36}
|
||||
name={senderInfo.nickname}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* 私聊模式:显示对方头像 */}
|
||||
{!isGroupChat && !isMe && (
|
||||
<TouchableOpacity
|
||||
style={styles.avatarWrapper}
|
||||
onPress={() => otherUser?.id && onAvatarPress(String(otherUser.id))}
|
||||
>
|
||||
<Avatar
|
||||
source={otherUser?.avatar || null}
|
||||
size={36}
|
||||
name={otherUser?.nickname || ''}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
<View style={styles.messageContent}>
|
||||
{/* 群聊模式:显示发送者昵称 */}
|
||||
{isGroupChat && !isMe && senderInfo && (
|
||||
<Text style={styles.senderName}>{senderInfo.nickname}</Text>
|
||||
)}
|
||||
|
||||
{renderMessageContent()}
|
||||
|
||||
{/* 私聊模式:显示已读标记 */}
|
||||
{isLastReadMessage && (
|
||||
<View style={styles.readStatusContainer}>
|
||||
<MaterialCommunityIcons
|
||||
name="check-all"
|
||||
size={14}
|
||||
color="#34C759"
|
||||
/>
|
||||
<Text style={[styles.readStatusText, styles.readStatusTextRead]}>
|
||||
已读
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 自己的头像 */}
|
||||
{isMe && (
|
||||
<View style={styles.avatarWrapper}>
|
||||
<Avatar
|
||||
source={currentUser?.avatar || null}
|
||||
size={36}
|
||||
name={currentUser?.nickname || ''}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
// Segment 消息的特殊样式 - 自适应宽度
|
||||
const segmentStyles = StyleSheet.create({
|
||||
replyBubble: {
|
||||
// 有回复消息时,使用自适应宽度
|
||||
minWidth: 120,
|
||||
},
|
||||
pureImageBubble: {
|
||||
// 纯图片消息:去除内边距,让图片紧贴气泡边缘
|
||||
paddingHorizontal: 0,
|
||||
paddingVertical: 0,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
|
||||
export default MessageBubble;
|
||||
42
src/screens/message/components/ChatScreen/MorePanel.tsx
Normal file
42
src/screens/message/components/ChatScreen/MorePanel.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 更多功能面板组件
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { Text } from '../../../../components/common';
|
||||
import { chatScreenStyles as styles } from './styles';
|
||||
import { MORE_ACTIONS } from './constants';
|
||||
import { MorePanelProps } from './types';
|
||||
|
||||
export const MorePanel: React.FC<MorePanelProps> = ({
|
||||
onAction,
|
||||
disabledActionIds = [],
|
||||
}) => {
|
||||
const disabledSet = new Set(disabledActionIds);
|
||||
return (
|
||||
<View style={styles.panelContainer}>
|
||||
<View style={styles.moreGrid}>
|
||||
{MORE_ACTIONS.map((action) => {
|
||||
const disabled = disabledSet.has(action.id);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={action.id}
|
||||
style={[styles.moreItem, disabled ? { opacity: 0.45 } : null]}
|
||||
onPress={() => onAction(action.id)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<View style={styles.moreIconContainer}>
|
||||
<MaterialCommunityIcons name={action.icon as any} size={28} color={action.color} />
|
||||
</View>
|
||||
<Text style={styles.moreItemText}>{action.name}</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export default MorePanel;
|
||||
938
src/screens/message/components/ChatScreen/SegmentRenderer.tsx
Normal file
938
src/screens/message/components/ChatScreen/SegmentRenderer.tsx
Normal file
@@ -0,0 +1,938 @@
|
||||
/**
|
||||
* Segment 渲染组件
|
||||
* 用于渲染消息链中的各种 Segment 类型
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
Linking,
|
||||
Image,
|
||||
GestureResponderEvent,
|
||||
} from 'react-native';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { VideoPlayerModal } from '../../../../components/common/VideoPlayerModal';
|
||||
import {
|
||||
MessageSegment,
|
||||
AtSegmentData,
|
||||
ReplySegmentData,
|
||||
ImageSegmentData,
|
||||
VoiceSegmentData,
|
||||
VideoSegmentData,
|
||||
FileSegmentData,
|
||||
FaceSegmentData,
|
||||
LinkSegmentData,
|
||||
TextSegmentData,
|
||||
MessageResponse,
|
||||
UserDTO,
|
||||
extractTextFromSegments,
|
||||
} from '../../../../types/dto';
|
||||
import { colors, spacing } from '../../../../theme';
|
||||
import { SenderInfo } from './types';
|
||||
|
||||
const IMAGE_MAX_WIDTH = 200;
|
||||
const IMAGE_MAX_HEIGHT = 260;
|
||||
const IMAGE_FALLBACK_SIZE = { width: 200, height: 150 };
|
||||
|
||||
// Segment 渲染器 Props
|
||||
export interface SegmentRendererProps {
|
||||
segment: MessageSegment;
|
||||
// 当前用户ID,用于判断@是否高亮
|
||||
currentUserId?: string;
|
||||
// 群成员列表,用于获取@用户的昵称
|
||||
memberMap?: Map<string, { nickname: string; user_id: string }>;
|
||||
// 是否是自己发送的消息
|
||||
isMe: boolean;
|
||||
// @点击回调
|
||||
onAtPress?: (userId: string) => void;
|
||||
// 回复点击回调
|
||||
onReplyPress?: (messageId: string) => void;
|
||||
// 图片点击回调
|
||||
onImagePress?: (url: string) => void;
|
||||
// 图片长按回调(触发消息气泡菜单)
|
||||
onImageLongPress?: (position?: { x: number; y: number }) => void;
|
||||
// 链接点击回调
|
||||
onLinkPress?: (url: string) => void;
|
||||
}
|
||||
|
||||
// 回复预览 Props
|
||||
export interface ReplyPreviewSegmentProps {
|
||||
replyData: ReplySegmentData;
|
||||
replyMessage?: MessageResponse;
|
||||
isMe: boolean;
|
||||
onPress?: () => void;
|
||||
getSenderInfo?: (senderId: string) => SenderInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染单个 Segment
|
||||
*/
|
||||
export const renderSegment = (
|
||||
segment: MessageSegment,
|
||||
props: Omit<SegmentRendererProps, 'segment'>
|
||||
): React.ReactNode => {
|
||||
const { isMe } = props;
|
||||
|
||||
switch (segment.type) {
|
||||
case 'text':
|
||||
return renderTextSegment(segment.data as TextSegmentData, isMe);
|
||||
case 'image':
|
||||
return renderImageSegment(segment.data as ImageSegmentData, props);
|
||||
case 'at':
|
||||
return renderAtSegment(segment.data as AtSegmentData, props);
|
||||
case 'reply':
|
||||
return null; // reply segment 单独处理
|
||||
case 'face':
|
||||
return renderFaceSegment(segment.data as FaceSegmentData, isMe);
|
||||
case 'voice':
|
||||
return renderVoiceSegment(segment.data as VoiceSegmentData, isMe);
|
||||
case 'video':
|
||||
return renderVideoSegment(segment.data as VideoSegmentData, isMe);
|
||||
case 'file':
|
||||
return renderFileSegment(segment.data as FileSegmentData, isMe);
|
||||
case 'link':
|
||||
return renderLinkSegment(segment.data as LinkSegmentData, props);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染文本 Segment
|
||||
*/
|
||||
const renderTextSegment = (data: TextSegmentData, isMe: boolean): React.ReactNode => {
|
||||
// 兼容 content 和 text 两种字段
|
||||
const text = data.content ?? data.text ?? '';
|
||||
return (
|
||||
<Text
|
||||
key={`text-${text.substring(0, 10)}`}
|
||||
style={[styles.textContent, isMe ? styles.textMe : styles.textOther]}
|
||||
selectable={false}
|
||||
>
|
||||
{text}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染图片 Segment
|
||||
*/
|
||||
// 图片Segment组件 - 使用Hook获取实际尺寸
|
||||
const ImageSegment: React.FC<{
|
||||
data: ImageSegmentData;
|
||||
isMe: boolean;
|
||||
onImagePress?: (url: string) => void;
|
||||
onImageLongPress?: (position?: { x: number; y: number }) => void;
|
||||
}> = ({ data, isMe, onImagePress, onImageLongPress }) => {
|
||||
const pressPositionRef = useRef({ x: 0, y: 0 });
|
||||
const [dimensions, setDimensions] = useState<{ width: number; height: number } | null>(null);
|
||||
const imageUrl = data.thumbnail_url || data.url;
|
||||
|
||||
useEffect(() => {
|
||||
// 如果已经有宽高信息,直接使用
|
||||
if (data.width && data.height) {
|
||||
const aspectRatio = data.width / data.height;
|
||||
|
||||
let width = data.width;
|
||||
let height = data.height;
|
||||
|
||||
if (width > IMAGE_MAX_WIDTH) {
|
||||
width = IMAGE_MAX_WIDTH;
|
||||
height = width / aspectRatio;
|
||||
}
|
||||
|
||||
if (height > IMAGE_MAX_HEIGHT) {
|
||||
height = IMAGE_MAX_HEIGHT;
|
||||
width = height * aspectRatio;
|
||||
}
|
||||
|
||||
setDimensions({ width: Math.round(width), height: Math.round(height) });
|
||||
return;
|
||||
}
|
||||
|
||||
// 添加超时处理,防止高分辨率图片加载卡住
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!dimensions) {
|
||||
setDimensions(IMAGE_FALLBACK_SIZE);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
// 否则从图片获取实际尺寸
|
||||
Image.getSize(
|
||||
imageUrl,
|
||||
(width, height) => {
|
||||
clearTimeout(timeoutId);
|
||||
const aspectRatio = width / height;
|
||||
|
||||
let newWidth = width;
|
||||
let newHeight = height;
|
||||
|
||||
if (newWidth > IMAGE_MAX_WIDTH) {
|
||||
newWidth = IMAGE_MAX_WIDTH;
|
||||
newHeight = newWidth / aspectRatio;
|
||||
}
|
||||
|
||||
if (newHeight > IMAGE_MAX_HEIGHT) {
|
||||
newHeight = IMAGE_MAX_HEIGHT;
|
||||
newWidth = newHeight * aspectRatio;
|
||||
}
|
||||
|
||||
setDimensions({ width: Math.round(newWidth), height: Math.round(newHeight) });
|
||||
},
|
||||
(error) => {
|
||||
clearTimeout(timeoutId);
|
||||
// 获取失败时使用默认 4:3 比例
|
||||
setDimensions(IMAGE_FALLBACK_SIZE);
|
||||
}
|
||||
);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [imageUrl, data.width, data.height]);
|
||||
|
||||
// 记录按压位置
|
||||
const handlePressIn = (event: GestureResponderEvent) => {
|
||||
const { pageX, pageY } = event.nativeEvent;
|
||||
pressPositionRef.current = { x: pageX, y: pageY };
|
||||
};
|
||||
|
||||
// 处理长按
|
||||
const handleLongPress = () => {
|
||||
onImageLongPress?.(pressPositionRef.current);
|
||||
};
|
||||
|
||||
// 初始加载时显示占位
|
||||
if (!dimensions) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={`image-${data.url}`}
|
||||
style={[styles.imageContainer, isMe ? styles.imageMe : styles.imageOther]}
|
||||
onPressIn={handlePressIn}
|
||||
onPress={() => onImagePress?.(data.url)}
|
||||
onLongPress={handleLongPress}
|
||||
delayLongPress={500}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: IMAGE_FALLBACK_SIZE.width,
|
||||
height: IMAGE_FALLBACK_SIZE.height,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(0,0,0,0.1)',
|
||||
}}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={`image-${data.url}`}
|
||||
style={[styles.imageContainer, isMe ? styles.imageMe : styles.imageOther]}
|
||||
onPressIn={handlePressIn}
|
||||
onPress={() => onImagePress?.(data.url)}
|
||||
onLongPress={handleLongPress}
|
||||
delayLongPress={500}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<ExpoImage
|
||||
source={{ uri: imageUrl }}
|
||||
style={{
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
borderRadius: 8,
|
||||
}}
|
||||
contentFit="cover"
|
||||
cachePolicy="disk"
|
||||
priority="normal"
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const renderImageSegment = (
|
||||
data: ImageSegmentData,
|
||||
props: Omit<SegmentRendererProps, 'segment'>
|
||||
): React.ReactNode => {
|
||||
const { onImagePress, onImageLongPress, isMe } = props;
|
||||
return <ImageSegment data={data} isMe={isMe} onImagePress={onImagePress} onImageLongPress={onImageLongPress} />;
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染 @ Segment
|
||||
*/
|
||||
const renderAtSegment = (
|
||||
data: AtSegmentData,
|
||||
props: Omit<SegmentRendererProps, 'segment'>
|
||||
): React.ReactNode => {
|
||||
const { currentUserId, memberMap, onAtPress, isMe } = props;
|
||||
|
||||
// 判断是否@当前用户
|
||||
const isAtMe = data.user_id === currentUserId || data.user_id === 'all';
|
||||
|
||||
// 优先从 memberMap 中查找昵称,兜底使用 data.nickname(兼容旧消息),再兜底显示"用户"
|
||||
let displayName: string;
|
||||
if (data.user_id === 'all') {
|
||||
displayName = '所有人';
|
||||
} else {
|
||||
const member = memberMap?.get(data.user_id);
|
||||
displayName = member?.nickname || data.nickname || '用户';
|
||||
}
|
||||
|
||||
return (
|
||||
<Text
|
||||
key={`at-${data.user_id}`}
|
||||
style={[
|
||||
styles.atText,
|
||||
isMe ? styles.atTextMe : styles.atTextOther,
|
||||
isAtMe && styles.atHighlight,
|
||||
]}
|
||||
onPress={() => data.user_id !== 'all' && onAtPress?.(data.user_id)}
|
||||
>
|
||||
@{displayName}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染表情 Segment
|
||||
*/
|
||||
const renderFaceSegment = (data: FaceSegmentData, isMe: boolean): React.ReactNode => {
|
||||
// 如果有自定义表情URL,显示图片
|
||||
if (data.url) {
|
||||
return (
|
||||
<ExpoImage
|
||||
key={`face-${data.id}`}
|
||||
source={{ uri: data.url }}
|
||||
style={styles.faceImage}
|
||||
contentFit="cover"
|
||||
cachePolicy="memory"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 否则显示表情名称(后续可以映射到本地表情)
|
||||
return (
|
||||
<Text key={`face-${data.id}`} style={styles.faceText}>
|
||||
[{data.name || `表情${data.id}`}]
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染语音 Segment
|
||||
*/
|
||||
const renderVoiceSegment = (data: VoiceSegmentData, isMe: boolean): React.ReactNode => {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={`voice-${data.url}`}
|
||||
style={[styles.voiceContainer, isMe ? styles.voiceMe : styles.voiceOther]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name="microphone"
|
||||
size={20}
|
||||
color={isMe ? '#FFFFFF' : '#666666'}
|
||||
/>
|
||||
<Text style={[styles.voiceDuration, isMe ? styles.textMe : styles.textOther]}>
|
||||
{data.duration ? `${data.duration}"` : '语音'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 视频 Segment 组件 - 支持点击播放
|
||||
*/
|
||||
const VideoSegment: React.FC<{ data: VideoSegmentData; isMe: boolean }> = ({ data, isMe }) => {
|
||||
const [playerVisible, setPlayerVisible] = useState(false);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (data.url) {
|
||||
setPlayerVisible(true);
|
||||
}
|
||||
}, [data.url]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<TouchableOpacity
|
||||
style={[styles.videoContainer, isMe ? styles.videoMe : styles.videoOther]}
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{data.thumbnail_url ? (
|
||||
<ExpoImage
|
||||
source={{ uri: data.thumbnail_url }}
|
||||
style={styles.videoThumbnail}
|
||||
contentFit="cover"
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.videoPlaceholder}>
|
||||
<MaterialCommunityIcons name="video" size={32} color="#FFFFFF" />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.videoPlayIcon}>
|
||||
<MaterialCommunityIcons name="play-circle" size={40} color="#FFFFFF" />
|
||||
</View>
|
||||
{data.duration && (
|
||||
<Text style={styles.videoDuration}>{formatDuration(data.duration)}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
<VideoPlayerModal
|
||||
visible={playerVisible}
|
||||
url={data.url}
|
||||
onClose={() => setPlayerVisible(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderVideoSegment = (data: VideoSegmentData, isMe: boolean): React.ReactNode => {
|
||||
return <VideoSegment key={`video-${data.url}`} data={data} isMe={isMe} />;
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染文件 Segment
|
||||
*/
|
||||
const renderFileSegment = (data: FileSegmentData, isMe: boolean): React.ReactNode => {
|
||||
const fileSize = data.size ? formatFileSize(data.size) : '';
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={`file-${data.url}`}
|
||||
style={[styles.fileContainer, isMe ? styles.fileMe : styles.fileOther]}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.fileIcon}>
|
||||
<MaterialCommunityIcons
|
||||
name="file-document"
|
||||
size={28}
|
||||
color={isMe ? '#FFFFFF' : colors.primary.main}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.fileInfo}>
|
||||
<Text
|
||||
style={[styles.fileName, isMe ? styles.textMe : styles.textOther]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{data.name}
|
||||
</Text>
|
||||
{fileSize && (
|
||||
<Text style={styles.fileSize}>{fileSize}</Text>
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染链接 Segment
|
||||
*/
|
||||
const renderLinkSegment = (
|
||||
data: LinkSegmentData,
|
||||
props: Omit<SegmentRendererProps, 'segment'>
|
||||
): React.ReactNode => {
|
||||
const { onLinkPress, isMe } = props;
|
||||
|
||||
const handlePress = () => {
|
||||
if (onLinkPress) {
|
||||
onLinkPress(data.url);
|
||||
} else {
|
||||
Linking.openURL(data.url).catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={`link-${data.url}`}
|
||||
style={[styles.linkContainer, isMe ? styles.linkMe : styles.linkOther]}
|
||||
onPress={handlePress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
{data.image && (
|
||||
<ExpoImage source={{ uri: data.image }} style={styles.linkImage} contentFit="cover" cachePolicy="memory" />
|
||||
)}
|
||||
<View style={styles.linkContent}>
|
||||
<Text style={styles.linkTitle} numberOfLines={2}>
|
||||
{data.title || data.url}
|
||||
</Text>
|
||||
{data.description && (
|
||||
<Text style={styles.linkDescription} numberOfLines={2}>
|
||||
{data.description}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={styles.linkUrl} numberOfLines={1}>
|
||||
{new URL(data.url).hostname}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 回复预览组件(用于消息气泡内显示回复引用)
|
||||
*/
|
||||
export const ReplyPreviewSegment: React.FC<ReplyPreviewSegmentProps> = ({
|
||||
replyData,
|
||||
replyMessage,
|
||||
isMe,
|
||||
onPress,
|
||||
getSenderInfo,
|
||||
}) => {
|
||||
if (!replyMessage) {
|
||||
// 如果没有引用消息详情,只显示引用ID
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.replyPreview, isMe ? styles.replyMe : styles.replyOther]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.replyLine} />
|
||||
<Text style={styles.replyText} numberOfLines={2}>
|
||||
引用消息
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
// 获取发送者信息 - 优先使用 replyMessage.sender
|
||||
let senderName = '';
|
||||
if (replyMessage.sender?.nickname) {
|
||||
senderName = replyMessage.sender.nickname;
|
||||
} else if (replyMessage.sender?.username) {
|
||||
senderName = replyMessage.sender.username;
|
||||
} else if (getSenderInfo && replyMessage.sender_id) {
|
||||
const info = getSenderInfo(replyMessage.sender_id);
|
||||
senderName = info.nickname;
|
||||
}
|
||||
|
||||
// 如果还是没有昵称,显示"对方"
|
||||
if (!senderName || senderName === '用户') {
|
||||
senderName = '对方';
|
||||
}
|
||||
|
||||
// 获取预览内容 - 使用 extractTextFromSegments 从 segments 提取
|
||||
let previewContent = '';
|
||||
if (replyMessage.segments?.length) {
|
||||
// 检查是否有图片/语音/视频/文件
|
||||
const hasImage = replyMessage.segments.some(s => s.type === 'image');
|
||||
const hasVoice = replyMessage.segments.some(s => s.type === 'voice');
|
||||
const hasVideo = replyMessage.segments.some(s => s.type === 'video');
|
||||
const hasFile = replyMessage.segments.some(s => s.type === 'file');
|
||||
|
||||
if (hasImage) {
|
||||
previewContent = '[图片]';
|
||||
} else if (hasVoice) {
|
||||
previewContent = '[语音]';
|
||||
} else if (hasVideo) {
|
||||
previewContent = '[视频]';
|
||||
} else if (hasFile) {
|
||||
previewContent = '[文件]';
|
||||
} else {
|
||||
// 从文本 segments 提取
|
||||
previewContent = extractTextFromSegments(replyMessage.segments);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.replyPreview, isMe ? styles.replyMe : styles.replyOther]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.replyContent}>
|
||||
<Text style={styles.replySender} numberOfLines={1}>
|
||||
{senderName}:
|
||||
</Text>
|
||||
<Text
|
||||
style={styles.replyText}
|
||||
numberOfLines={1}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{previewContent}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染完整的消息链(多个 Segment 组合)
|
||||
*/
|
||||
export const MessageSegmentsRenderer: React.FC<{
|
||||
segments: MessageSegment[];
|
||||
isMe: boolean;
|
||||
currentUserId?: string;
|
||||
memberMap?: Map<string, { nickname: string; user_id: string }>;
|
||||
replyMessage?: MessageResponse;
|
||||
onAtPress?: (userId: string) => void;
|
||||
onReplyPress?: (messageId: string) => void;
|
||||
onImagePress?: (url: string) => void;
|
||||
onImageLongPress?: () => void;
|
||||
onLinkPress?: (url: string) => void;
|
||||
getSenderInfo?: (senderId: string) => SenderInfo;
|
||||
}> = ({
|
||||
segments,
|
||||
isMe,
|
||||
currentUserId,
|
||||
memberMap,
|
||||
replyMessage,
|
||||
onAtPress,
|
||||
onReplyPress,
|
||||
onImagePress,
|
||||
onImageLongPress,
|
||||
onLinkPress,
|
||||
getSenderInfo,
|
||||
}) => {
|
||||
// 找出 reply segment(如果有)
|
||||
const replySegment = segments.find(s => s.type === 'reply');
|
||||
const otherSegments = segments.filter(s => s.type !== 'reply');
|
||||
|
||||
const renderProps = {
|
||||
isMe,
|
||||
currentUserId,
|
||||
memberMap,
|
||||
onAtPress,
|
||||
onReplyPress,
|
||||
onImagePress,
|
||||
onImageLongPress,
|
||||
onLinkPress,
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.segmentsContainer}>
|
||||
{/* 回复预览 */}
|
||||
{replySegment && (
|
||||
<ReplyPreviewSegment
|
||||
replyData={(replySegment.data as ReplySegmentData)}
|
||||
replyMessage={replyMessage}
|
||||
isMe={isMe}
|
||||
onPress={() => onReplyPress?.((replySegment.data as ReplySegmentData).id)}
|
||||
getSenderInfo={getSenderInfo}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 其他 Segment 内容 */}
|
||||
<View style={styles.segmentsContent}>
|
||||
{otherSegments.map((segment, index) => (
|
||||
<React.Fragment key={`segment-${index}-${segment.type}`}>
|
||||
{renderSegment(segment, renderProps)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 辅助函数:格式化文件大小
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
};
|
||||
|
||||
// 辅助函数:格式化时长
|
||||
const formatDuration = (seconds: number): string => {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return mins > 0 ? `${mins}:${secs.toString().padStart(2, '0')}` : `0:${secs.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// 容器
|
||||
segmentsContainer: {
|
||||
flexDirection: 'column',
|
||||
width: '100%',
|
||||
},
|
||||
segmentsContent: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
|
||||
// 文本 - Telegram风格:统一深色字体
|
||||
textContent: {
|
||||
fontSize: 16,
|
||||
lineHeight: 23,
|
||||
letterSpacing: 0.2,
|
||||
fontWeight: '400',
|
||||
},
|
||||
textMe: {
|
||||
color: '#1A1A1A', // 统一使用深色字体
|
||||
},
|
||||
textOther: {
|
||||
color: '#1A1A1A', // 统一使用深色字体
|
||||
},
|
||||
|
||||
// @提及 - Telegram风格:蓝色高亮
|
||||
atText: {
|
||||
fontSize: 16,
|
||||
lineHeight: 23,
|
||||
fontWeight: '600',
|
||||
paddingHorizontal: 2,
|
||||
},
|
||||
atTextMe: {
|
||||
color: '#1976D2', // 蓝色
|
||||
backgroundColor: 'rgba(25, 118, 210, 0.12)',
|
||||
borderRadius: 4,
|
||||
},
|
||||
atTextOther: {
|
||||
color: '#1976D2', // 蓝色
|
||||
backgroundColor: 'rgba(25, 118, 210, 0.12)',
|
||||
borderRadius: 4,
|
||||
},
|
||||
atHighlight: {
|
||||
backgroundColor: 'rgba(25, 118, 210, 0.2)',
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 4,
|
||||
},
|
||||
|
||||
// 图片 - QQ风格:保持原始宽高比
|
||||
imageContainer: {
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
marginTop: 4,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
},
|
||||
imageMe: {
|
||||
borderBottomRightRadius: 4,
|
||||
},
|
||||
imageOther: {
|
||||
borderBottomLeftRadius: 4,
|
||||
},
|
||||
// 移除固定的 image 样式,改为在组件中动态计算
|
||||
|
||||
// 表情 - QQ风格:更大的表情
|
||||
faceImage: {
|
||||
width: 28,
|
||||
height: 28,
|
||||
},
|
||||
faceText: {
|
||||
fontSize: 16,
|
||||
color: '#666',
|
||||
},
|
||||
|
||||
// 语音 - QQ风格:更明显的背景
|
||||
voiceContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm + 2,
|
||||
borderRadius: 20,
|
||||
minWidth: 100,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
voiceMe: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.25)',
|
||||
},
|
||||
voiceOther: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.06)',
|
||||
},
|
||||
voiceDuration: {
|
||||
marginLeft: spacing.sm,
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
// 视频 - QQ风格:更大的缩略图
|
||||
videoContainer: {
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
width: 220,
|
||||
height: 165,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 4,
|
||||
elevation: 4,
|
||||
},
|
||||
videoMe: {
|
||||
borderBottomRightRadius: 6,
|
||||
},
|
||||
videoOther: {
|
||||
borderBottomLeftRadius: 6,
|
||||
},
|
||||
videoThumbnail: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
videoPlaceholder: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'linear-gradient(135deg, #333 0%, #555 100%)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
videoPlayIcon: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
marginTop: -24,
|
||||
marginLeft: -24,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
borderRadius: 24,
|
||||
padding: 4,
|
||||
},
|
||||
videoDuration: {
|
||||
position: 'absolute',
|
||||
bottom: 10,
|
||||
right: 10,
|
||||
color: '#FFFFFF',
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.6)',
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 3,
|
||||
borderRadius: 6,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
|
||||
// 文件 - QQ风格:卡片式设计
|
||||
fileContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: spacing.md,
|
||||
borderRadius: 16,
|
||||
minWidth: 220,
|
||||
maxWidth: 300,
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 1 },
|
||||
shadowOpacity: 0.08,
|
||||
shadowRadius: 2,
|
||||
elevation: 2,
|
||||
},
|
||||
fileMe: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.25)',
|
||||
},
|
||||
fileOther: {
|
||||
backgroundColor: '#F8F9FA',
|
||||
},
|
||||
fileIcon: {
|
||||
marginRight: spacing.md,
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(255, 107, 53, 0.15)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
fileInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
fileName: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#1A1A1A',
|
||||
},
|
||||
fileSize: {
|
||||
fontSize: 13,
|
||||
color: '#8E8E93',
|
||||
marginTop: 4,
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
// 链接 - QQ风格:卡片式设计
|
||||
linkContainer: {
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
maxWidth: 280,
|
||||
backgroundColor: '#FFFFFF',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
linkMe: {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
},
|
||||
linkOther: {
|
||||
backgroundColor: '#FFFFFF',
|
||||
},
|
||||
linkImage: {
|
||||
width: '100%',
|
||||
height: 140,
|
||||
},
|
||||
linkContent: {
|
||||
padding: spacing.md,
|
||||
},
|
||||
linkTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
color: '#1A1A1A',
|
||||
lineHeight: 20,
|
||||
},
|
||||
linkDescription: {
|
||||
fontSize: 13,
|
||||
color: '#666666',
|
||||
marginTop: 6,
|
||||
lineHeight: 18,
|
||||
},
|
||||
linkUrl: {
|
||||
fontSize: 12,
|
||||
color: '#8E8E93',
|
||||
marginTop: 6,
|
||||
fontWeight: '500',
|
||||
},
|
||||
|
||||
// 回复预览 - Telegram风格:简洁设计
|
||||
replyPreview: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 0,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.sm + 2,
|
||||
borderRadius: 6,
|
||||
width: '100%',
|
||||
minWidth: 200,
|
||||
// 移除阴影
|
||||
shadowColor: 'transparent',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0,
|
||||
shadowRadius: 0,
|
||||
elevation: 0,
|
||||
},
|
||||
replyMe: {
|
||||
backgroundColor: 'rgba(25, 118, 210, 0.1)',
|
||||
},
|
||||
replyOther: {
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.03)',
|
||||
},
|
||||
replyLine: {
|
||||
width: 0,
|
||||
marginRight: 0,
|
||||
},
|
||||
replyContent: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flexWrap: 'nowrap',
|
||||
},
|
||||
replySender: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
color: '#1976D2',
|
||||
marginRight: 6,
|
||||
flexShrink: 0,
|
||||
},
|
||||
replyText: {
|
||||
fontSize: 13,
|
||||
color: '#666666',
|
||||
flex: 1,
|
||||
flexShrink: 1,
|
||||
},
|
||||
});
|
||||
|
||||
export default MessageSegmentsRenderer;
|
||||
169
src/screens/message/components/ChatScreen/constants.ts
Normal file
169
src/screens/message/components/ChatScreen/constants.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* ChatScreen 常量定义
|
||||
*/
|
||||
|
||||
import { MoreAction } from './types';
|
||||
|
||||
// 常用表情列表
|
||||
export const EMOJIS = [
|
||||
// 笑脸和情感
|
||||
'😀', '😃', '😄', '😁', '😆', '😅', '🤣', '😂',
|
||||
'🙂', '🙃', '😉', '😊', '😇', '🥰', '😍', '🤩',
|
||||
'😘', '😗', '😚', '😙', '🥲', '😋', '😛', '😜',
|
||||
'🤪', '😝', '🤑', '🤗', '🤭', '🤫', '🤔', '🤐',
|
||||
'🤨', '😐', '😑', '😶', '😶🌫️', '😏', '😒', '🙄',
|
||||
'😬', '🤥', '😌', '😔', '😪', '🤤', '😴', '😷',
|
||||
'🤒', '🤕', '🤢', '🤮', '🤧', '🥵', '🥶', '🥴',
|
||||
'😵', '😵💫', '🤯', '🤠', '🥳', '🥸', '😎', '🤓',
|
||||
'🧐', '😕', '😟', '🙁', '☹️', '😮', '😯', '😲',
|
||||
'😳', '🥺', '😦', '😧', '😨', '😰', '😥', '😢',
|
||||
'😭', '😱', '😖', '😣', '😞', '😓', '😩', '😫',
|
||||
'🥱', '😤', '😡', '😠', '🤬', '😈', '👿', '💀',
|
||||
'☠️', '💩', '🤡', '👹', '👺', '👻', '👽', '👾',
|
||||
'🤖', '😺', '😸', '😹', '😻', '😼', '😽', '🙀',
|
||||
'😿', '😾', '🙈', '🙉', '🙊', '💋', '💌', '💘',
|
||||
// 人物和手势
|
||||
'👋', '🤚', '🖐️', '✋', '🖖', '👌', '🤌', '🤏',
|
||||
'✌️', '🤞', '🤟', '🤘', '🤙', '👈', '👉', '👆',
|
||||
'🖕', '👇', '☝️', '👍', '👎', '✊', '👊', '🤛',
|
||||
'🤜', '👏', '🙌', '👐', '🤲', '🤝', '🙏', '✍️',
|
||||
'💅', '🤳', '💪', '🦾', '🦿', '🦵', '🦶', '👂',
|
||||
'🦻', '👃', '🧠', '🫀', '🫁', '🦷', '🦴', '👀',
|
||||
'👁️', '👅', '👄', '👶', '🧒', '👦', '👧', '🧑',
|
||||
'👨', '👩', '🧓', '👴', '👵', '👸', '🤴', '👰',
|
||||
// 动物
|
||||
'🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼',
|
||||
'🐨', '🐯', '🦁', '🐮', '🐷', '🐸', '🐵', '🙈',
|
||||
'🐔', '🐧', '🐦', '🐤', '🐣', '🐥', '🦆', '🦅',
|
||||
'🦉', '🦇', '🐺', '🐗', '🐴', '🦄', '🐝', '🐛',
|
||||
'🦋', '🐌', '🐞', '🐜', '🦟', '🦗', '🕷️', '🦂',
|
||||
'🐢', '🐍', '🦎', '🦖', '🦕', '🐙', '🦑', '🦐',
|
||||
'🦞', '🦀', '🐡', '🐠', '🐟', '🐬', '🐳', '🐋',
|
||||
'🦈', '🐊', '🐅', '🐆', '🦓', '🦍', '🦧', '🐘',
|
||||
'🦛', '🦏', '🐪', '🐫', '🦒', '🦘', '🦬', '🐃',
|
||||
'🐂', '🐄', '🐎', '🐖', '🐏', '🐑', '🦙', '🐐',
|
||||
'🦌', '🐕', '🐩', '🦮', '🐈', '🐈⬛', '🐓', '🦃',
|
||||
'🦚', '🦜', '🦢', '🦩', '🕊️', '🐕🦺', '🐰', '🐻❄️',
|
||||
// 食物和饮料
|
||||
'🍎', '🍏', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇',
|
||||
'🍓', '🫐', '🍈', '🍒', '🍑', '🥭', '🍍', '🥥',
|
||||
'🥝', '🍅', '🍆', '🥑', '🥦', '🥬', '🥒', '🌶️',
|
||||
'🫑', '🌽', '🥕', '🫒', '🧄', '🧅', '🥔', '🍠',
|
||||
'🥐', '🥯', '🍞', '🥖', '🥨', '🧀', '🥚', '🍳',
|
||||
'🧈', '🥞', '🧇', '🥓', '🥩', '🍗', '🍖', '🦴',
|
||||
'🌭', '🍔', '🍟', '🍕', '🫓', '🥪', '🥙', '🧆',
|
||||
'🌮', '🌯', '🫔', '🥗', '🥘', '🫕', '🍝', '🍜',
|
||||
'🍲', '🍛', '🍣', '🍱', '🥟', '🦪', '🍤', '🍙',
|
||||
'🍚', '🍘', '🍥', '🥠', '🥮', '🍢', '🍡', '🍧',
|
||||
'🍨', '🍦', '🥧', '🧁', '🍰', '🎂', '🍮', '🍭',
|
||||
'🍬', '🍫', '🍿', '🍩', '🍪', '🌰', '🥜', '🍯',
|
||||
'🥛', '🍼', '☕', '🫖', '🍵', '🍶', '🍾', '🍷',
|
||||
'🍸', '🍹', '🍺', '🍻', '🥂', '🥃', '🥤', '🧋',
|
||||
'🧃', '🧉', '🧊', '🥢', '🍽️', '🍴', '🥄', '🔪',
|
||||
// 自然和天气
|
||||
'🌸', '💮', '🏵️', '🌹', '🥀', '🌺', '🌻', '🌼',
|
||||
'🌷', '🌱', '🪴', '🌲', '🌳', '🌴', '🌵', '🌾',
|
||||
'🌿', '☘️', '🍀', '🍁', '🍂', '🍃', '🍄', '🌰',
|
||||
'🌍', '🌎', '🌏', '🌐', '🗺️', '🗾', '🧭', '🏔️',
|
||||
'⛰️', '🌋', '🗻', '🏕️', '🏖️', '🏜️', '🏝️', '🏞️',
|
||||
'🏟️', '🏛️', '🏗️', '🧱', '🏘️', '🏚️', '🏠', '🏢',
|
||||
'🌚', '🌕', '🌔', '🌓', '🌒', '🌑', '🌙', '🌎',
|
||||
'⭐', '🌟', '🌠', '☁️', '⛅', '⛈️', '🌤️', '🌥️',
|
||||
'🌦️', '🌧️', '🌨️', '🌩️', '🌪️', '🌫️', '🌬️', '🌀',
|
||||
'🌈', '🌂', '☔', '⚡', '❄️', '☃️', '⛄', '☄️',
|
||||
'🔥', '💧', '🌊', '💨', '☀️', '🌙', '⭐', '✨',
|
||||
// 心形和符号
|
||||
'❤️', '🧡', '💛', '💚', '💙', '💜', '🖤', '🤍',
|
||||
'🤎', '💔', '❤️🔥', '❤️🩹', '💕', '💞', '💓', '💗',
|
||||
'💖', '💘', '💝', '💟', '☮️', '✝️', '☪️', '🕉️',
|
||||
'☸️', '✡️', '🔯', '🕎', '☯️', '☦️', '🛐', '⛎',
|
||||
'🆔', '⚛️', '🉑', '☢️', '☣️', '📴', '📳', '🈶',
|
||||
'🈚', '🈸', '🈺', '🈷️', '✴️', '🆚', '💮', '🉐',
|
||||
'㊙️', '㊗️', '🈴', '🈵', '🈹', '🈲', '🅰️', '🅱️',
|
||||
'🆎', '🆑', '🅾️', '🆘', '❌', '⭕', '🛑', '⛔',
|
||||
'📛', '🚫', '💯', '💢', '♨️', '🚷', '🚯', '🚳',
|
||||
'🚱', '🔞', '📵', '🚭', '❗', '❕', '❓', '❔',
|
||||
'‼️', '⁉️', '🔔', '🔕', '🎵', '🎶', '➡️', '⬅️',
|
||||
'⬆️', '⬇️', '↗️', '↘️', '↙️', '↖️', '↕️', '↔️',
|
||||
'↩️', '↪️', '⤴️', '⤵️', '🔃', '🔄', '🔙', '🔚',
|
||||
'🔛', '🔜', '🔝', '🛐', '⚛️', '🕉️', '✡️', '☸️',
|
||||
// 庆祝和物品
|
||||
'🎉', '🎊', '🎈', '🎏', '🎀', '🎗️', '🎟️', '🎫',
|
||||
'🎖️', '🏆', '🏅', '🥇', '🥈', '🥉', '⚽', '⚾',
|
||||
'🥎', '🏀', '🏐', '🏈', '🏉', '🎾', '🥏', '🎳',
|
||||
'🏏', '🏑', '🏒', '🥍', '🏓', '🏸', '🥊', '🥋',
|
||||
'🥅', '⛳', '⛸️', '🎣', '🤿', '🎽', '🎿', '🛷',
|
||||
'🥌', '🎯', '🪀', '🪁', '🎱', '🔮', '🧿', '🎮',
|
||||
'🕹️', '🎰', '🎲', '🧩', '🧸', '🪅', '🪆', '♠️',
|
||||
'♥️', '♦️', '♣️', '♟️', '🃏', '🀄', '🎴', '🎭',
|
||||
'🖼️', '🎨', '🧵', '🪡', '🧶', '🪢', '👓', '🕶️',
|
||||
'🥽', '🥼', '🦺', '👔', '👕', '👖', '🧣', '🧤',
|
||||
'🧥', '🧦', '👗', '👘', '🥻', '🩱', '🩲', '🩳',
|
||||
'👙', '👚', '👛', '👜', '👝', '🛍️', '🎒', '🩴',
|
||||
'👞', '👟', '🥾', '🥿', '👠', '👡', '👢', '🩰',
|
||||
'👑', '👒', '🎩', '🎓', '🧢', '🪖', '⛑️', '📿',
|
||||
'💄', '💍', '💎', '🔇', '🔈', '🔉', '🔊', '📢',
|
||||
'📣', '📯', '🔔', '🔕', '🎼', '🎵', '🎶', '🎙️',
|
||||
'🎚️', '🎛️', '🎤', '🎧', '📻', '🎷', '🎸', '🎹',
|
||||
'🎺', '🎻', '🪕', '🥁', '🪘', '📱', '📲', '☎️',
|
||||
'📞', '📟', '📠', '🔋', '🔌', '💻', '🖥️', '🖨️',
|
||||
'⌨️', '🖱️', '🖲️', '💽', '💾', '💿', '📀', '🧮',
|
||||
// 灯光和标志
|
||||
'💡', '🔦', '🏮', '🪔', '📔', '📕', '📖', '📗',
|
||||
'📘', '📙', '📚', '📓', '📒', '📃', '📜', '📄',
|
||||
'📰', '🗞️', '📑', '🔖', '🏷️', '💰', '💴', '💵',
|
||||
'💶', '💷', '💸', '💳', '🧾', '💹', '✉️', '📧',
|
||||
'📨', '📩', '📤', '📥', '📦', '📫', '📪', '📬',
|
||||
'📭', '📮', '🗳️', '✏️', '✒️', '🖋️', '🖊️', '🖌️',
|
||||
'🖍️', '📝', '💼', '📁', '📂', '🗂️', '📅', '📆',
|
||||
'🗒️', '🗓️', '📇', '📈', '📉', '📊', '📋', '📌',
|
||||
'📍', '📎', '🖇️', '📏', '📐', '✂️', '🗃️', '🗄️',
|
||||
'🗑️', '🔒', '🔓', '🔏', '🔐', '🔑', '🗝️', '🔨',
|
||||
'🪓', '⛏️', '⚒️', '🛠️', '🗡️', '⚔️', '🔫', '🪃',
|
||||
'🏹', '🛡️', '🪚', '🔧', '🪛', '🔩', '⚙️', '🗜️',
|
||||
'⚖️', '🦯', '🔗', '⛓️', '🪝', '🧰', '🧲', '🪜',
|
||||
// 交通和旅行
|
||||
'🚗', '🚕', '🚙', '🚌', '🚎', '🏎️', '🚓', '🚑',
|
||||
'🚒', '🚐', '🚚', '🚛', '🚜', '🦯', '🦽', '🦼',
|
||||
'🛴', '🚲', '🛵', '🏍️', '🛺', '🚨', '🚔', '🚍',
|
||||
'🚘', '🚖', '🚡', '🚠', '🚟', '🚃', '🚋', '🚞',
|
||||
'🚝', '🚄', '🚅', '🚈', '🚂', '🚆', '🚇', '🚊',
|
||||
'🚉', '✈️', '🛫', '🛬', '🛩️', '💺', '🛰️', '🚀',
|
||||
'🛸', '🚁', '🛶', '⛵', '🚤', '🛥️', '🛳️', '⛴️',
|
||||
'🚢', '⚓', '⛽', '🚧', '🚦', '🚥', '🚏', '🗺️',
|
||||
'🗿', '🗽', '🗼', '🏰', '🏯', '🏟️', '🎡', '🎢',
|
||||
'🎠', '⛲', '⛱️', '🏖️', '🏝️', '🏜️', '🌋', '⛰️',
|
||||
'🏔️', '🗻', '🏕️', '⛺', '🏠', '🏡', '🏘️', '🏚️',
|
||||
'🏗️', '🏭', '🏢', '🏬', '🏣', '🏤', '🏥', '🏦',
|
||||
'🏨', '🏪', '🏫', '🏩', '🏛️', '⛪', '🕌', '🕍',
|
||||
'🛕', '🕋', '⛩️', '🛤️', '🛣️', '🗾', '🎑', '🏞️',
|
||||
'🌅', '🌄', '🌠', '🎇', '🎆', '🌇', '🌆', '🏙️',
|
||||
'🌃', '🌌', '🌉', '🌫️', '🌊', '🕳️', '💊', '💉',
|
||||
];
|
||||
|
||||
// 更多功能项
|
||||
export const MORE_ACTIONS: MoreAction[] = [
|
||||
{ id: 'image', icon: 'image', name: '图片', color: '#FF6B6B' },
|
||||
{ id: 'camera', icon: 'camera', name: '拍摄', color: '#4ECDC4' },
|
||||
{ id: 'file', icon: 'file-document', name: '文件', color: '#45B7D1' },
|
||||
{ id: 'location', icon: 'map-marker', name: '位置', color: '#96CEB4' },
|
||||
];
|
||||
|
||||
// 消息撤回时间限制(毫秒)
|
||||
export const RECALL_TIME_LIMIT = 2 * 60 * 1000; // 2分钟
|
||||
|
||||
// 时间分隔间隔(毫秒)
|
||||
export const TIME_SEPARATOR_INTERVAL = 5 * 60 * 1000; // 5分钟
|
||||
|
||||
// 面板高度
|
||||
export const PANEL_HEIGHTS = {
|
||||
mention: 250,
|
||||
emoji: 350,
|
||||
more: 350,
|
||||
};
|
||||
|
||||
// 输入框最大长度
|
||||
export const MAX_INPUT_LENGTH = 500;
|
||||
|
||||
// 群成员加载每页数量
|
||||
export const MEMBERS_PAGE_SIZE = 100;
|
||||
32
src/screens/message/components/ChatScreen/index.ts
Normal file
32
src/screens/message/components/ChatScreen/index.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* ChatScreen 组件导出
|
||||
*/
|
||||
|
||||
// 类型
|
||||
export * from './types';
|
||||
|
||||
// 常量
|
||||
export * from './constants';
|
||||
|
||||
// 样式
|
||||
export { chatScreenStyles } from './styles';
|
||||
|
||||
// 组件
|
||||
export { EmojiPanel } from './EmojiPanel';
|
||||
export { MorePanel } from './MorePanel';
|
||||
export { MentionPanel } from './MentionPanel';
|
||||
export { LongPressMenu } from './LongPressMenu';
|
||||
export { ChatHeader } from './ChatHeader';
|
||||
export { MessageBubble } from './MessageBubble';
|
||||
export { ChatInput } from './ChatInput';
|
||||
|
||||
// Segment 渲染组件
|
||||
export {
|
||||
MessageSegmentsRenderer,
|
||||
ReplyPreviewSegment,
|
||||
renderSegment
|
||||
} from './SegmentRenderer';
|
||||
export type { SegmentRendererProps, ReplyPreviewSegmentProps } from './SegmentRenderer';
|
||||
|
||||
// Hook
|
||||
export { useChatScreen } from './useChatScreen';
|
||||
1167
src/screens/message/components/ChatScreen/styles.ts
Normal file
1167
src/screens/message/components/ChatScreen/styles.ts
Normal file
File diff suppressed because it is too large
Load Diff
213
src/screens/message/components/ChatScreen/types.ts
Normal file
213
src/screens/message/components/ChatScreen/types.ts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* ChatScreen 类型定义
|
||||
*/
|
||||
|
||||
import { MessageResponse, UserDTO, GroupMemberResponse, GroupResponse } from '../../../../types/dto';
|
||||
|
||||
// 面板类型
|
||||
export type PanelType = 'none' | 'emoji' | 'more' | 'mention';
|
||||
|
||||
// 消息状态
|
||||
export type MessageStatus = 'normal' | 'recalled' | 'deleted';
|
||||
|
||||
// 用户角色
|
||||
export type UserRole = 'owner' | 'admin' | 'member';
|
||||
|
||||
// 扩展消息类型以支持群聊
|
||||
export interface GroupMessage extends MessageResponse {
|
||||
sender?: UserDTO;
|
||||
mention_users?: string[];
|
||||
mention_all?: boolean;
|
||||
is_system_notice?: boolean;
|
||||
notice_content?: string;
|
||||
// category 继承自 MessageResponse,用于判断消息类别
|
||||
// 'chat' - 普通聊天消息
|
||||
// 'notification' - 通知类消息(如禁言通知)
|
||||
// 'announcement' - 系统公告
|
||||
}
|
||||
|
||||
// 路由参数
|
||||
export interface ChatRouteParams {
|
||||
conversationId?: string;
|
||||
userId?: string;
|
||||
isGroupChat?: boolean;
|
||||
groupId?: string;
|
||||
groupName?: string;
|
||||
}
|
||||
|
||||
// 发送者信息
|
||||
export interface SenderInfo {
|
||||
nickname: string;
|
||||
avatar: string | null;
|
||||
userId?: string;
|
||||
}
|
||||
|
||||
// 更多功能项
|
||||
export interface MoreAction {
|
||||
id: string;
|
||||
icon: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// 长按菜单项
|
||||
export interface MenuItem {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
color?: string;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
// ChatScreen Props
|
||||
export interface ChatScreenProps {
|
||||
// 路由参数通过 useRoute 获取
|
||||
}
|
||||
|
||||
// 消息气泡 Props
|
||||
export interface MessageBubbleProps {
|
||||
message: GroupMessage;
|
||||
index: number;
|
||||
currentUserId: string;
|
||||
currentUser: { id?: string; nickname?: string; avatar?: string | null } | null;
|
||||
otherUser: { id?: string; nickname?: string; avatar?: string | null } | null;
|
||||
isGroupChat: boolean;
|
||||
groupMembers: GroupMemberResponse[];
|
||||
/** @deprecated 发送者信息现在直接从 message.sender 获取,不再使用 senderCache */
|
||||
senderCache?: Map<string, UserDTO>;
|
||||
otherUserLastReadSeq: number;
|
||||
selectedMessageId: string | null;
|
||||
// 消息映射,用于查找被回复的消息
|
||||
messageMap?: Map<string, GroupMessage>;
|
||||
onLongPress: (message: GroupMessage, position?: MenuPosition) => void;
|
||||
onAvatarPress: (userId: string) => void;
|
||||
onAvatarLongPress: (senderId: string, nickname: string) => void;
|
||||
formatTime: (dateString: string) => string;
|
||||
shouldShowTime: (index: number) => boolean;
|
||||
// 图片点击回调
|
||||
onImagePress?: (images: { id: string; url: string; thumbnail_url?: string; width?: number; height?: number }[], index: number) => void;
|
||||
}
|
||||
|
||||
// 输入框 Props
|
||||
export interface ChatInputProps {
|
||||
inputText: string;
|
||||
onInputChange: (text: string) => void;
|
||||
onSend: () => void;
|
||||
onToggleEmoji: () => void;
|
||||
onToggleMore: () => void;
|
||||
activePanel: PanelType;
|
||||
sending: boolean;
|
||||
isMuted: boolean;
|
||||
isGroupChat: boolean;
|
||||
muteAll: boolean;
|
||||
replyingTo: GroupMessage | null;
|
||||
onCancelReply: () => void;
|
||||
onFocus: () => void;
|
||||
restrictionHint?: string | null;
|
||||
disableMore?: boolean;
|
||||
}
|
||||
|
||||
// 表情面板 Props
|
||||
export interface EmojiPanelProps {
|
||||
onInsertEmoji: (emoji: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// 更多功能面板 Props
|
||||
export interface MorePanelProps {
|
||||
onAction: (actionId: string) => void;
|
||||
disabledActionIds?: string[];
|
||||
}
|
||||
|
||||
// @成员选择面板 Props
|
||||
export interface MentionPanelProps {
|
||||
members: GroupMemberResponse[];
|
||||
currentUserId: string;
|
||||
mentionQuery: string;
|
||||
currentUserRole: UserRole;
|
||||
onSelectMention: (member: GroupMemberResponse) => void;
|
||||
onMentionAll: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// 菜单位置信息
|
||||
export interface MenuPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
pressX?: number; // 按压点X坐标
|
||||
pressY?: number; // 按压点Y坐标
|
||||
}
|
||||
|
||||
// 长按菜单 Props
|
||||
export interface LongPressMenuProps {
|
||||
visible: boolean;
|
||||
message: GroupMessage | null;
|
||||
currentUserId: string;
|
||||
position?: MenuPosition;
|
||||
onClose: () => void;
|
||||
onReply: (message: GroupMessage) => void;
|
||||
onRecall: (messageId: string) => void;
|
||||
onDelete: (messageId: string) => void;
|
||||
onAddSticker?: (imageUrl: string) => void;
|
||||
}
|
||||
|
||||
// 聊天头部 Props
|
||||
export interface ChatHeaderProps {
|
||||
isGroupChat: boolean;
|
||||
groupInfo: { name?: string; member_count?: number } | null;
|
||||
otherUser: { id?: string; nickname?: string; avatar?: string | null } | null;
|
||||
routeGroupName?: string;
|
||||
typingHint: string | null;
|
||||
onBack: () => void;
|
||||
onTitlePress: () => void;
|
||||
onMorePress: () => void;
|
||||
}
|
||||
|
||||
// 回复预览 Props
|
||||
export interface ReplyPreviewProps {
|
||||
replyingTo: GroupMessage | null;
|
||||
currentUserId: string;
|
||||
currentUser: UserDTO | null;
|
||||
otherUser: UserDTO | null;
|
||||
isGroupChat: boolean;
|
||||
getSenderInfo: (senderId: string) => SenderInfo;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
// ChatScreen 状态接口
|
||||
export interface ChatScreenState {
|
||||
// 基础状态
|
||||
messages: GroupMessage[];
|
||||
conversationId: string | null;
|
||||
inputText: string;
|
||||
otherUser: UserDTO | null;
|
||||
currentUser: UserDTO | null;
|
||||
keyboardHeight: number;
|
||||
loading: boolean;
|
||||
sending: boolean;
|
||||
currentUserId: string;
|
||||
lastSeq: number;
|
||||
otherUserLastReadSeq: number;
|
||||
activePanel: PanelType;
|
||||
sendingImage: boolean;
|
||||
|
||||
// 回复消息状态
|
||||
replyingTo: GroupMessage | null;
|
||||
|
||||
// 长按菜单状态
|
||||
longPressMenuVisible: boolean;
|
||||
selectedMessage: GroupMessage | null;
|
||||
|
||||
// 群聊相关状态
|
||||
groupInfo: GroupResponse | null;
|
||||
groupMembers: GroupMemberResponse[];
|
||||
typingUsers: string[];
|
||||
currentUserRole: UserRole;
|
||||
mentionQuery: string;
|
||||
selectedMentions: string[];
|
||||
mentionAll: boolean;
|
||||
isMuted: boolean;
|
||||
muteAll: boolean;
|
||||
}
|
||||
1242
src/screens/message/components/ChatScreen/useChatScreen.ts
Normal file
1242
src/screens/message/components/ChatScreen/useChatScreen.ts
Normal file
File diff suppressed because it is too large
Load Diff
409
src/screens/message/components/EmbeddedChat.tsx
Normal file
409
src/screens/message/components/EmbeddedChat.tsx
Normal file
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* EmbeddedChat.tsx
|
||||
* 嵌入式聊天组件 - 用于桌面端双栏布局右侧
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
TextInput,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, shadows } from '../../../theme';
|
||||
import { ConversationResponse, MessageResponse, MessageSegment } from '../../../types/dto';
|
||||
import { Avatar, Text } from '../../../components/common';
|
||||
import { useAuthStore, messageManager, MessageEvent, MessageSubscriber } from '../../../stores';
|
||||
import { extractTextFromSegments } from '../../../types/dto';
|
||||
|
||||
interface EmbeddedChatProps {
|
||||
conversation: ConversationResponse;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
const { width: screenWidth } = Dimensions.get('window');
|
||||
|
||||
export const EmbeddedChat: React.FC<EmbeddedChatProps> = ({ conversation, onBack }) => {
|
||||
const navigation = useNavigation();
|
||||
const currentUser = useAuthStore(state => state.currentUser);
|
||||
|
||||
// 状态
|
||||
const [messages, setMessages] = useState<MessageResponse[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const flatListRef = useRef<FlatList>(null);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
// 获取会话信息
|
||||
const isGroupChat = conversation.type === 'group';
|
||||
const chatTitle = isGroupChat
|
||||
? conversation.group?.name || '群聊'
|
||||
: conversation.participants?.[0]?.nickname || conversation.participants?.[0]?.username || '聊天';
|
||||
const chatAvatar = isGroupChat
|
||||
? conversation.group?.avatar
|
||||
: conversation.participants?.[0]?.avatar;
|
||||
|
||||
// 加载消息
|
||||
const loadMessages = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
await messageManager.fetchMessages(String(conversation.id));
|
||||
setMessages(messageManager.getMessages(String(conversation.id)));
|
||||
} catch (error) {
|
||||
console.error('[EmbeddedChat] 加载消息失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [conversation.id]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadMessages();
|
||||
|
||||
// 订阅消息事件
|
||||
const subscriber: MessageSubscriber = (event: MessageEvent) => {
|
||||
if (
|
||||
event.type === 'messages_updated' &&
|
||||
String(event.payload?.conversationId) === String(conversation.id)
|
||||
) {
|
||||
setMessages(event.payload.messages || []);
|
||||
setTimeout(() => {
|
||||
flatListRef.current?.scrollToEnd({ animated: true });
|
||||
}, 100);
|
||||
}
|
||||
};
|
||||
|
||||
const unsubscribe = messageManager.subscribe(subscriber);
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
}, [conversation.id, loadMessages]);
|
||||
|
||||
// 发送消息
|
||||
const handleSend = useCallback(async () => {
|
||||
if (!inputText.trim() || sending) return;
|
||||
|
||||
const text = inputText.trim();
|
||||
setInputText('');
|
||||
|
||||
try {
|
||||
setSending(true);
|
||||
const segments: MessageSegment[] = [{ type: 'text', data: { text } }];
|
||||
await messageManager.sendMessage(String(conversation.id), segments);
|
||||
} catch (error) {
|
||||
console.error('[EmbeddedChat] 发送消息失败:', error);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}, [inputText, sending, conversation.id]);
|
||||
|
||||
// 导航到完整聊天页面
|
||||
const handleNavigateToFullChat = () => {
|
||||
if (isGroupChat && conversation.group) {
|
||||
(navigation as any).navigate('Chat', {
|
||||
conversationId: String(conversation.id),
|
||||
groupId: String(conversation.group.id),
|
||||
groupName: conversation.group.name,
|
||||
isGroupChat: true,
|
||||
});
|
||||
} else {
|
||||
const currentUserId = currentUser?.id;
|
||||
const otherUser = conversation.participants?.find(p => String(p.id) !== String(currentUserId));
|
||||
(navigation as any).navigate('Chat', {
|
||||
conversationId: String(conversation.id),
|
||||
userId: otherUser ? String(otherUser.id) : undefined,
|
||||
userName: otherUser?.nickname || otherUser?.username,
|
||||
isGroupChat: false,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染消息
|
||||
const renderMessage = ({ item }: { item: MessageResponse }) => {
|
||||
const isMe = String(item.sender_id) === String(currentUser?.id);
|
||||
|
||||
return (
|
||||
<View style={[styles.messageRow, isMe ? styles.messageRowRight : styles.messageRowLeft]}>
|
||||
{!isMe && (
|
||||
<Avatar
|
||||
source={item.sender?.avatar}
|
||||
size={36}
|
||||
name={item.sender?.nickname || item.sender?.username}
|
||||
/>
|
||||
)}
|
||||
<View style={[styles.messageBubble, isMe ? styles.messageBubbleMe : styles.messageBubbleOther]}>
|
||||
{isGroupChat && !isMe && (
|
||||
<Text style={styles.senderName}>{item.sender?.nickname || item.sender?.username}</Text>
|
||||
)}
|
||||
<Text style={[styles.messageText, isMe ? styles.messageTextMe : styles.messageTextOther]}>
|
||||
{extractTextFromSegments(item.segments)}
|
||||
</Text>
|
||||
</View>
|
||||
{isMe && (
|
||||
<Avatar
|
||||
source={currentUser?.avatar}
|
||||
size={36}
|
||||
name={currentUser?.nickname || currentUser?.username}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{/* 头部 */}
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity onPress={onBack} style={styles.headerButton}>
|
||||
<MaterialCommunityIcons name="arrow-left" size={24} color="#666" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.headerCenter}>
|
||||
<Avatar source={chatAvatar} size={36} name={chatTitle} />
|
||||
<Text style={styles.headerTitle} numberOfLines={1}>
|
||||
{chatTitle}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity onPress={handleNavigateToFullChat} style={styles.headerButton}>
|
||||
<MaterialCommunityIcons name="arrow-expand" size={22} color="#666" />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 消息列表 */}
|
||||
<View style={styles.messageList}>
|
||||
{loading ? (
|
||||
<View style={styles.centerContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
) : messages.length === 0 ? (
|
||||
<View style={styles.centerContainer}>
|
||||
<MaterialCommunityIcons name="message-text-outline" size={48} color="#DDD" />
|
||||
<Text style={styles.emptyText}>暂无消息</Text>
|
||||
<Text style={styles.emptySubtext}>发送第一条消息开始聊天</Text>
|
||||
</View>
|
||||
) : (
|
||||
<FlatList
|
||||
ref={flatListRef}
|
||||
data={messages}
|
||||
renderItem={renderMessage}
|
||||
keyExtractor={(item) => String(item.id)}
|
||||
contentContainerStyle={styles.listContent}
|
||||
showsVerticalScrollIndicator={true}
|
||||
onLayout={() => {
|
||||
if (messages.length > 0) {
|
||||
flatListRef.current?.scrollToEnd({ animated: false });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 输入框区域 */}
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
keyboardVerticalOffset={Platform.OS === 'ios' ? 90 : 0}
|
||||
>
|
||||
<View style={styles.inputArea}>
|
||||
<View style={styles.inputRow}>
|
||||
<TouchableOpacity style={styles.iconButton}>
|
||||
<MaterialCommunityIcons name="plus-circle-outline" size={28} color="#666" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.inputContainer}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={styles.textInput}
|
||||
placeholder="发送消息..."
|
||||
placeholderTextColor="#999"
|
||||
value={inputText}
|
||||
onChangeText={setInputText}
|
||||
multiline={false}
|
||||
returnKeyType="send"
|
||||
onSubmitEditing={handleSend}
|
||||
blurOnSubmit={false}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity style={styles.iconButton}>
|
||||
<MaterialCommunityIcons name="emoticon-outline" size={28} color="#666" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.sendButton, (!inputText.trim() || sending) && styles.sendButtonDisabled]}
|
||||
onPress={handleSend}
|
||||
disabled={!inputText.trim() || sending}
|
||||
>
|
||||
{sending ? (
|
||||
<ActivityIndicator size="small" color="#FFF" />
|
||||
) : (
|
||||
<MaterialCommunityIcons name="send" size={20} color="#FFF" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F7FA',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: '#FFF',
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: '#E8E8E8',
|
||||
...shadows.sm,
|
||||
height: 56,
|
||||
},
|
||||
headerButton: {
|
||||
padding: spacing.xs,
|
||||
width: 40,
|
||||
height: 40,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
headerCenter: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: fontSizes.lg,
|
||||
fontWeight: '600',
|
||||
color: '#333',
|
||||
marginLeft: spacing.sm,
|
||||
maxWidth: 200,
|
||||
},
|
||||
messageList: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F7FA',
|
||||
},
|
||||
centerContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
},
|
||||
emptyText: {
|
||||
marginTop: spacing.md,
|
||||
fontSize: 16,
|
||||
color: '#999',
|
||||
},
|
||||
emptySubtext: {
|
||||
marginTop: spacing.xs,
|
||||
fontSize: 14,
|
||||
color: '#BBB',
|
||||
},
|
||||
listContent: {
|
||||
padding: spacing.md,
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
messageRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
marginBottom: spacing.md,
|
||||
maxWidth: screenWidth * 0.7,
|
||||
},
|
||||
messageRowLeft: {
|
||||
alignSelf: 'flex-start',
|
||||
},
|
||||
messageRowRight: {
|
||||
alignSelf: 'flex-end',
|
||||
flexDirection: 'row-reverse',
|
||||
},
|
||||
messageBubble: {
|
||||
maxWidth: screenWidth * 0.5,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: 18,
|
||||
marginHorizontal: spacing.sm,
|
||||
},
|
||||
messageBubbleMe: {
|
||||
backgroundColor: colors.primary.main,
|
||||
borderBottomRightRadius: 4,
|
||||
},
|
||||
messageBubbleOther: {
|
||||
backgroundColor: '#FFF',
|
||||
borderBottomLeftRadius: 4,
|
||||
...shadows.sm,
|
||||
},
|
||||
senderName: {
|
||||
fontSize: 12,
|
||||
color: '#999',
|
||||
marginBottom: 2,
|
||||
},
|
||||
messageText: {
|
||||
fontSize: 15,
|
||||
lineHeight: 20,
|
||||
},
|
||||
messageTextMe: {
|
||||
color: '#FFF',
|
||||
},
|
||||
messageTextOther: {
|
||||
color: '#333',
|
||||
},
|
||||
inputArea: {
|
||||
backgroundColor: '#FFF',
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: '#E8E8E8',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.sm,
|
||||
paddingBottom: Platform.OS === 'ios' ? spacing.md : spacing.sm,
|
||||
},
|
||||
inputRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
iconButton: {
|
||||
padding: spacing.xs,
|
||||
width: 44,
|
||||
height: 44,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
inputContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: '#F5F5F5',
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: spacing.md,
|
||||
minHeight: 40,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
textInput: {
|
||||
fontSize: 15,
|
||||
color: '#333',
|
||||
padding: 0,
|
||||
maxHeight: 100,
|
||||
},
|
||||
sendButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
backgroundColor: colors.primary.main,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
sendButtonDisabled: {
|
||||
backgroundColor: '#E0E0E0',
|
||||
},
|
||||
});
|
||||
155
src/screens/message/components/GroupRequestShared.tsx
Normal file
155
src/screens/message/components/GroupRequestShared.tsx
Normal file
@@ -0,0 +1,155 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, TouchableOpacity, ActivityIndicator } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { Avatar, Text } from '../../../components/common';
|
||||
import { colors, spacing, borderRadius, shadows } from '../../../theme';
|
||||
|
||||
interface GroupInfoSummaryCardProps {
|
||||
groupName?: string;
|
||||
groupAvatar?: string;
|
||||
groupNo?: string;
|
||||
groupDescription?: string;
|
||||
memberCountText?: string;
|
||||
}
|
||||
|
||||
export const GroupInfoSummaryCard: React.FC<GroupInfoSummaryCardProps> = ({
|
||||
groupName,
|
||||
groupAvatar,
|
||||
groupNo,
|
||||
groupDescription,
|
||||
memberCountText,
|
||||
}) => {
|
||||
return (
|
||||
<View style={styles.headerCard}>
|
||||
<View style={styles.groupHeader}>
|
||||
<Avatar source={groupAvatar || ''} size={88} name={groupName} />
|
||||
<View style={styles.groupInfo}>
|
||||
<Text variant="h3" style={styles.groupName} numberOfLines={1}>{groupName || '群聊'}</Text>
|
||||
<View style={styles.groupMeta}>
|
||||
<MaterialCommunityIcons name="account-group" size={16} color={colors.text.secondary} />
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
{memberCountText || '- 人'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.groupNoText}>
|
||||
群号:{groupNo || '-'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.descriptionContainer}>
|
||||
<MaterialCommunityIcons name="information-outline" size={16} color={colors.text.secondary} />
|
||||
<Text variant="body" color={colors.text.secondary} style={styles.groupDesc}>
|
||||
{groupDescription || '暂无群描述'}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
interface DecisionFooterProps {
|
||||
canAction: boolean;
|
||||
submitting: boolean;
|
||||
onReject: () => void;
|
||||
onApprove: () => void;
|
||||
processedText?: string;
|
||||
}
|
||||
|
||||
export const DecisionFooter: React.FC<DecisionFooterProps> = ({
|
||||
canAction,
|
||||
submitting,
|
||||
onReject,
|
||||
onApprove,
|
||||
processedText = '该请求已处理',
|
||||
}) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
if (canAction) {
|
||||
return (
|
||||
<View style={[styles.footer, { paddingBottom: Math.max(insets.bottom, spacing.sm) }]}>
|
||||
<TouchableOpacity style={[styles.btn, styles.reject]} onPress={onReject} disabled={submitting}>
|
||||
<Text variant="body" color={colors.error.main}>拒绝</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={[styles.btn, styles.approve]} onPress={onApprove} disabled={submitting}>
|
||||
{submitting ? <ActivityIndicator color="#fff" /> : <Text variant="body" color="#fff">同意</Text>}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<View style={[styles.statusBox, { paddingBottom: Math.max(insets.bottom, spacing.sm) }]}>
|
||||
<Text variant="caption" color={colors.text.secondary}>{processedText}</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
headerCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.lg,
|
||||
marginBottom: spacing.md,
|
||||
...shadows.sm,
|
||||
},
|
||||
groupHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
groupInfo: {
|
||||
marginLeft: spacing.lg,
|
||||
flex: 1,
|
||||
},
|
||||
groupName: {
|
||||
marginBottom: spacing.xs,
|
||||
fontWeight: '700',
|
||||
},
|
||||
groupMeta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
groupNoText: {
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
descriptionContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
marginTop: spacing.md,
|
||||
},
|
||||
groupDesc: {
|
||||
marginLeft: spacing.sm,
|
||||
flex: 1,
|
||||
lineHeight: 20,
|
||||
},
|
||||
footer: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.sm,
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
btn: {
|
||||
flex: 1,
|
||||
height: 42,
|
||||
borderRadius: borderRadius.md,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
reject: {
|
||||
marginRight: spacing.sm,
|
||||
backgroundColor: colors.error.light + '25',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.error.light,
|
||||
},
|
||||
approve: {
|
||||
marginLeft: spacing.sm,
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
statusBox: {
|
||||
alignItems: 'center',
|
||||
paddingTop: spacing.sm,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
});
|
||||
301
src/screens/message/components/MutualFollowSelectorModal.tsx
Normal file
301
src/screens/message/components/MutualFollowSelectorModal.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
FlatList,
|
||||
ListRenderItem,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../../theme';
|
||||
import { authService } from '../../../services/authService';
|
||||
import { useAuthStore } from '../../../stores';
|
||||
import { Avatar, EmptyState, Loading, Text } from '../../../components/common';
|
||||
import { User } from '../../../types';
|
||||
|
||||
type MutualFollowSelectorModalProps = {
|
||||
visible: boolean;
|
||||
title?: string;
|
||||
confirmText?: string;
|
||||
confirmingText?: string;
|
||||
confirmLoading?: boolean;
|
||||
excludeUserIds?: string[];
|
||||
initialSelectedIds?: string[];
|
||||
onClose: () => void;
|
||||
onConfirm: (selectedUsers: User[]) => void | Promise<void>;
|
||||
};
|
||||
|
||||
const MutualFollowSelectorModal: React.FC<MutualFollowSelectorModalProps> = ({
|
||||
visible,
|
||||
title = '邀请成员',
|
||||
confirmText = '确认',
|
||||
confirmingText = '处理中...',
|
||||
confirmLoading = false,
|
||||
excludeUserIds = [],
|
||||
initialSelectedIds = [],
|
||||
onClose,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const { currentUser } = useAuthStore();
|
||||
|
||||
const [friendList, setFriendList] = useState<User[]>([]);
|
||||
const [friendLoading, setFriendLoading] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const currentUserId = currentUser?.id;
|
||||
const excludeIdsKey = useMemo(() => excludeUserIds.join(','), [excludeUserIds]);
|
||||
const initialSelectedIdsKey = useMemo(() => initialSelectedIds.join(','), [initialSelectedIds]);
|
||||
const stableExcludeUserIds = useMemo(() => excludeUserIds, [excludeIdsKey]);
|
||||
const stableInitialSelectedIds = useMemo(() => initialSelectedIds, [initialSelectedIdsKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
if (!currentUserId) return;
|
||||
|
||||
const nextSelectedIds = new Set(stableInitialSelectedIds);
|
||||
setSelectedIds(prev => {
|
||||
if (prev.size !== nextSelectedIds.size) return nextSelectedIds;
|
||||
for (const id of prev) {
|
||||
if (!nextSelectedIds.has(id)) return nextSelectedIds;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
|
||||
let cancelled = false;
|
||||
const loadMutualFollows = async () => {
|
||||
setFriendLoading(true);
|
||||
try {
|
||||
const excludedIdSet = new Set(stableExcludeUserIds);
|
||||
const [following, followers] = await Promise.all([
|
||||
authService.getFollowingList(currentUserId, 1, 100),
|
||||
authService.getFollowersList(currentUserId, 1, 100),
|
||||
]);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const followerIdSet = new Set(followers.map(user => user.id));
|
||||
const mutualFriends = following.filter(user => followerIdSet.has(user.id));
|
||||
const availableFriends = mutualFriends.filter(user => !excludedIdSet.has(user.id));
|
||||
setFriendList(availableFriends);
|
||||
} catch (error) {
|
||||
console.error('获取互关好友失败:', error);
|
||||
Alert.alert('错误', '获取互关好友失败');
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setFriendLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadMutualFollows();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [visible, currentUserId, stableExcludeUserIds, stableInitialSelectedIds]);
|
||||
|
||||
const toggleSelection = (user: User) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(user.id)) {
|
||||
next.delete(user.id);
|
||||
} else {
|
||||
next.add(user.id);
|
||||
}
|
||||
setSelectedIds(next);
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
const selectedUsers = friendList.filter(user => selectedIds.has(user.id));
|
||||
onConfirm(selectedUsers);
|
||||
};
|
||||
|
||||
const renderFriendItem: ListRenderItem<User> = ({ item }) => {
|
||||
const isSelected = selectedIds.has(item.id);
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.friendItem, isSelected && styles.friendItemSelected]}
|
||||
onPress={() => toggleSelection(item)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Avatar source={item.avatar} size={48} name={item.nickname} />
|
||||
<View style={styles.friendInfo}>
|
||||
<Text variant="body" style={styles.nickname} numberOfLines={1}>
|
||||
{item.nickname}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
|
||||
@{item.username}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={[styles.checkbox, isSelected && styles.checkboxSelected]}>
|
||||
{isSelected && (
|
||||
<MaterialCommunityIcons name="check" size={16} color={colors.background.paper} />
|
||||
)}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
animationType="slide"
|
||||
transparent
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<KeyboardAvoidingView
|
||||
style={styles.modalOverlay}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
>
|
||||
<View style={styles.modalContent}>
|
||||
<View style={styles.modalHeader}>
|
||||
<TouchableOpacity onPress={onClose} style={styles.modalHeaderButton}>
|
||||
<Text variant="body" color={colors.text.secondary}>取消</Text>
|
||||
</TouchableOpacity>
|
||||
<Text variant="h3" style={styles.modalTitle}>{title}</Text>
|
||||
<TouchableOpacity
|
||||
onPress={handleConfirm}
|
||||
disabled={selectedIds.size === 0 || confirmLoading}
|
||||
style={styles.modalHeaderButton}
|
||||
>
|
||||
<Text
|
||||
variant="body"
|
||||
color={selectedIds.size === 0 || confirmLoading ? colors.text.disabled : colors.primary.main}
|
||||
style={styles.confirmButton}
|
||||
>
|
||||
{confirmLoading ? confirmingText : confirmText}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.tipContainer}>
|
||||
<Text variant="body" color={colors.primary.main} style={styles.tipText}>
|
||||
仅显示互相关注用户
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{selectedIds.size > 0 && (
|
||||
<View style={styles.selectedBadge}>
|
||||
<Text variant="caption" color={colors.primary.main}>
|
||||
已选择 {selectedIds.size} 人
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{friendLoading ? (
|
||||
<Loading />
|
||||
) : (
|
||||
<FlatList
|
||||
data={friendList}
|
||||
renderItem={renderFriendItem}
|
||||
keyExtractor={item => item.id}
|
||||
contentContainerStyle={styles.friendListContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
ListEmptyComponent={(
|
||||
<EmptyState
|
||||
title="暂无可选择的互关好友"
|
||||
description="互相关注后才可邀请加入群聊"
|
||||
icon="account-plus-outline"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
modalOverlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
modalContent: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderTopLeftRadius: borderRadius['2xl'],
|
||||
borderTopRightRadius: borderRadius['2xl'],
|
||||
height: '80%',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingTop: spacing.lg,
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
modalHeader: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
modalHeaderButton: {
|
||||
paddingVertical: spacing.sm,
|
||||
minWidth: 60,
|
||||
},
|
||||
modalTitle: {
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes.xl,
|
||||
},
|
||||
confirmButton: {
|
||||
fontWeight: '600',
|
||||
textAlign: 'right',
|
||||
},
|
||||
tipContainer: {
|
||||
flexDirection: 'row',
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.xs,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
tipText: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
selectedBadge: {
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.sm,
|
||||
alignSelf: 'flex-start',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
friendListContent: {
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
friendItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.sm,
|
||||
borderRadius: borderRadius.lg,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
friendItemSelected: {
|
||||
backgroundColor: colors.primary.light + '15',
|
||||
},
|
||||
friendInfo: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.md,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
nickname: {
|
||||
fontWeight: '500',
|
||||
marginBottom: 2,
|
||||
},
|
||||
checkbox: {
|
||||
width: 26,
|
||||
height: 26,
|
||||
borderRadius: 13,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.divider,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
checkboxSelected: {
|
||||
backgroundColor: colors.primary.main,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
});
|
||||
|
||||
export default MutualFollowSelectorModal;
|
||||
14
src/screens/message/index.ts
Normal file
14
src/screens/message/index.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* 消息模块导出
|
||||
*/
|
||||
|
||||
export { MessageListScreen } from './MessageListScreen';
|
||||
export { ChatScreen } from './ChatScreen';
|
||||
export { NotificationsScreen } from './NotificationsScreen';
|
||||
export { default as CreateGroupScreen } from './CreateGroupScreen';
|
||||
export { default as GroupInfoScreen } from './GroupInfoScreen';
|
||||
export { default as GroupMembersScreen } from './GroupMembersScreen';
|
||||
export { default as PrivateChatInfoScreen } from './PrivateChatInfoScreen';
|
||||
export { default as JoinGroupScreen } from './JoinGroupScreen';
|
||||
export { default as GroupRequestDetailScreen } from './GroupRequestDetailScreen';
|
||||
export { default as GroupInviteDetailScreen } from './GroupInviteDetailScreen';
|
||||
447
src/screens/profile/AccountSecurityScreen.tsx
Normal file
447
src/screens/profile/AccountSecurityScreen.tsx
Normal file
@@ -0,0 +1,447 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { Text, ResponsiveContainer } from '../../components/common';
|
||||
import { useResponsive } from '../../hooks';
|
||||
import { authService, resolveAuthApiError } from '../../services/authService';
|
||||
import { showPrompt } from '../../services/promptService';
|
||||
import { useAuthStore } from '../../stores';
|
||||
|
||||
export const AccountSecurityScreen: React.FC = () => {
|
||||
const { isWideScreen } = useResponsive();
|
||||
const currentUser = useAuthStore((state) => state.currentUser);
|
||||
const fetchCurrentUser = useAuthStore((state) => state.fetchCurrentUser);
|
||||
|
||||
const [email, setEmail] = useState(currentUser?.email || '');
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [sendingCode, setSendingCode] = useState(false);
|
||||
const [verifyingEmail, setVerifyingEmail] = useState(false);
|
||||
const [countdown, setCountdown] = useState(0);
|
||||
|
||||
const [oldPassword, setOldPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [changePasswordCode, setChangePasswordCode] = useState('');
|
||||
const [sendingChangePwdCode, setSendingChangePwdCode] = useState(false);
|
||||
const [changePwdCountdown, setChangePwdCountdown] = useState(0);
|
||||
const [updatingPassword, setUpdatingPassword] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentUser?.email) {
|
||||
setEmail(currentUser.email);
|
||||
}
|
||||
}, [currentUser?.email]);
|
||||
|
||||
useEffect(() => {
|
||||
if (countdown <= 0) {
|
||||
return;
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
setCountdown((prev) => (prev <= 1 ? 0 : prev - 1));
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [countdown]);
|
||||
|
||||
useEffect(() => {
|
||||
if (changePwdCountdown <= 0) {
|
||||
return;
|
||||
}
|
||||
const timer = setInterval(() => {
|
||||
setChangePwdCountdown((prev) => (prev <= 1 ? 0 : prev - 1));
|
||||
}, 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [changePwdCountdown]);
|
||||
|
||||
const isEmailVerified = !!currentUser?.email_verified;
|
||||
const emailStatusText = useMemo(() => {
|
||||
if (!currentUser?.email) {
|
||||
return '未绑定邮箱';
|
||||
}
|
||||
return isEmailVerified ? '已验证' : '未验证';
|
||||
}, [currentUser?.email, isEmailVerified]);
|
||||
|
||||
const validateEmail = (value: string) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
|
||||
const handleSendCode = async () => {
|
||||
const targetEmail = email.trim();
|
||||
if (!validateEmail(targetEmail)) {
|
||||
showPrompt({ title: '提示', message: '请输入正确的邮箱地址', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (countdown > 0) {
|
||||
return;
|
||||
}
|
||||
setSendingCode(true);
|
||||
try {
|
||||
const ok = await authService.sendCurrentUserEmailVerifyCode(targetEmail);
|
||||
if (ok) {
|
||||
setCountdown(60);
|
||||
showPrompt({ title: '发送成功', message: '验证码已发送,请查收邮箱', type: 'success' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
showPrompt({ title: '发送失败', message: resolveAuthApiError(error, '验证码发送失败'), type: 'error' });
|
||||
} finally {
|
||||
setSendingCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVerifyEmail = async () => {
|
||||
const targetEmail = email.trim();
|
||||
if (!validateEmail(targetEmail)) {
|
||||
showPrompt({ title: '提示', message: '请输入正确的邮箱地址', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (!verificationCode.trim()) {
|
||||
showPrompt({ title: '提示', message: '请输入验证码', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
|
||||
setVerifyingEmail(true);
|
||||
try {
|
||||
const ok = await authService.verifyCurrentUserEmail({
|
||||
email: targetEmail,
|
||||
verification_code: verificationCode.trim(),
|
||||
});
|
||||
if (ok) {
|
||||
setVerificationCode('');
|
||||
await fetchCurrentUser();
|
||||
showPrompt({ title: '验证成功', message: '邮箱已完成验证', type: 'success' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
showPrompt({ title: '验证失败', message: resolveAuthApiError(error, '邮箱验证失败'), type: 'error' });
|
||||
} finally {
|
||||
setVerifyingEmail(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (!oldPassword) {
|
||||
showPrompt({ title: '提示', message: '请输入当前密码', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (!changePasswordCode.trim()) {
|
||||
showPrompt({ title: '提示', message: '请输入邮箱验证码', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 6) {
|
||||
showPrompt({ title: '提示', message: '新密码至少 6 位', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
showPrompt({ title: '提示', message: '两次输入的新密码不一致', type: 'warning' });
|
||||
return;
|
||||
}
|
||||
setUpdatingPassword(true);
|
||||
try {
|
||||
const ok = await authService.changePassword(oldPassword, newPassword, changePasswordCode.trim());
|
||||
if (ok) {
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setChangePasswordCode('');
|
||||
showPrompt({ title: '修改成功', message: '密码已更新', type: 'success' });
|
||||
} else {
|
||||
showPrompt({ title: '修改失败', message: '请检查当前密码后重试', type: 'error' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
showPrompt({ title: '修改失败', message: resolveAuthApiError(error, '请稍后重试'), type: 'error' });
|
||||
} finally {
|
||||
setUpdatingPassword(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendChangePasswordCode = async () => {
|
||||
if (changePwdCountdown > 0) {
|
||||
return;
|
||||
}
|
||||
setSendingChangePwdCode(true);
|
||||
try {
|
||||
const ok = await authService.sendChangePasswordCode();
|
||||
if (ok) {
|
||||
setChangePwdCountdown(60);
|
||||
showPrompt({ title: '发送成功', message: '改密验证码已发送到你的邮箱', type: 'success' });
|
||||
}
|
||||
} catch (error: any) {
|
||||
showPrompt({ title: '发送失败', message: resolveAuthApiError(error, '验证码发送失败'), type: 'error' });
|
||||
} finally {
|
||||
setSendingChangePwdCode(false);
|
||||
}
|
||||
};
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialCommunityIcons name="email-check-outline" size={18} color={colors.primary.main} />
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||||
邮箱验证
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.statusRow}>
|
||||
<Text variant="body" color={colors.text.primary}>当前状态</Text>
|
||||
<View style={[styles.statusBadge, isEmailVerified ? styles.statusVerified : styles.statusUnverified]}>
|
||||
<Text variant="caption" color={isEmailVerified ? '#1B5E20' : '#B26A00'}>
|
||||
{emailStatusText}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputWrapper}>
|
||||
<MaterialCommunityIcons name="email-outline" size={20} color={colors.primary.main} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="请输入邮箱"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.codeRow}>
|
||||
<View style={[styles.inputWrapper, styles.codeInput]}>
|
||||
<MaterialCommunityIcons name="shield-key-outline" size={20} color={colors.primary.main} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="验证码"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={verificationCode}
|
||||
onChangeText={setVerificationCode}
|
||||
keyboardType="number-pad"
|
||||
maxLength={6}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.sendCodeButton, (sendingCode || countdown > 0) && styles.buttonDisabled]}
|
||||
onPress={handleSendCode}
|
||||
disabled={sendingCode || countdown > 0}
|
||||
>
|
||||
{sendingCode ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.sendCodeButtonText}>{countdown > 0 ? `${countdown}s` : '发送验证码'}</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryButton, verifyingEmail && styles.buttonDisabled]}
|
||||
onPress={handleVerifyEmail}
|
||||
disabled={verifyingEmail}
|
||||
>
|
||||
{verifyingEmail ? <ActivityIndicator size="small" color="#fff" /> : <Text style={styles.primaryButtonText}>验证邮箱</Text>}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialCommunityIcons name="lock-reset" size={18} color={colors.primary.main} />
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||||
修改密码
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
<View style={styles.inputWrapper}>
|
||||
<MaterialCommunityIcons name="lock-outline" size={20} color={colors.primary.main} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="当前密码"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={oldPassword}
|
||||
onChangeText={setOldPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.inputWrapper}>
|
||||
<MaterialCommunityIcons name="lock-plus-outline" size={20} color={colors.primary.main} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="新密码(至少6位)"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={newPassword}
|
||||
onChangeText={setNewPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.codeRow}>
|
||||
<View style={[styles.inputWrapper, styles.codeInput]}>
|
||||
<MaterialCommunityIcons name="shield-key-outline" size={20} color={colors.primary.main} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="邮箱验证码"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={changePasswordCode}
|
||||
onChangeText={setChangePasswordCode}
|
||||
keyboardType="number-pad"
|
||||
maxLength={6}
|
||||
/>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={[styles.sendCodeButton, (sendingChangePwdCode || changePwdCountdown > 0) && styles.buttonDisabled]}
|
||||
onPress={handleSendChangePasswordCode}
|
||||
disabled={sendingChangePwdCode || changePwdCountdown > 0}
|
||||
>
|
||||
{sendingChangePwdCode ? (
|
||||
<ActivityIndicator size="small" color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.sendCodeButtonText}>
|
||||
{changePwdCountdown > 0 ? `${changePwdCountdown}s` : '发送验证码'}
|
||||
</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.inputWrapper}>
|
||||
<MaterialCommunityIcons name="lock-check-outline" size={20} color={colors.primary.main} style={styles.inputIcon} />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="确认新密码"
|
||||
placeholderTextColor={colors.text.hint}
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
secureTextEntry
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.primaryButton, updatingPassword && styles.buttonDisabled]}
|
||||
onPress={handleChangePassword}
|
||||
disabled={updatingPassword}
|
||||
>
|
||||
{updatingPassword ? <ActivityIndicator size="small" color="#fff" /> : <Text style={styles.primaryButtonText}>更新密码</Text>}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={800}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>{content}</ScrollView>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>{content}</ScrollView>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
section: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
marginBottom: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
marginHorizontal: spacing.lg,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
borderRadius: borderRadius.sm,
|
||||
},
|
||||
statusVerified: {
|
||||
backgroundColor: '#E8F5E9',
|
||||
},
|
||||
statusUnverified: {
|
||||
backgroundColor: '#FFF3E0',
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider,
|
||||
paddingHorizontal: spacing.md,
|
||||
height: 48,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
inputIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
color: colors.text.primary,
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
codeRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
codeInput: {
|
||||
flex: 1,
|
||||
marginBottom: 0,
|
||||
},
|
||||
sendCodeButton: {
|
||||
height: 48,
|
||||
minWidth: 110,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.primary.main,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingHorizontal: spacing.sm,
|
||||
},
|
||||
sendCodeButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: fontSizes.sm,
|
||||
fontWeight: '600',
|
||||
},
|
||||
primaryButton: {
|
||||
height: 48,
|
||||
borderRadius: borderRadius.lg,
|
||||
backgroundColor: colors.primary.main,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
primaryButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '700',
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
});
|
||||
|
||||
export default AccountSecurityScreen;
|
||||
177
src/screens/profile/BlockedUsersScreen.tsx
Normal file
177
src/screens/profile/BlockedUsersScreen.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
FlatList,
|
||||
RefreshControl,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { Avatar, Button, EmptyState, ResponsiveContainer, Text } from '../../components/common';
|
||||
import { authService } from '../../services';
|
||||
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
||||
import { User } from '../../types';
|
||||
import { RootStackParamList } from '../../navigation/types';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
export const BlockedUsersScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [processingUserId, setProcessingUserId] = useState<string | null>(null);
|
||||
|
||||
const loadBlockedUsers = useCallback(async () => {
|
||||
try {
|
||||
const response = await authService.getBlockedUsers(1, 100);
|
||||
setUsers(response.list || []);
|
||||
} catch (error) {
|
||||
console.error('加载黑名单失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadBlockedUsers();
|
||||
}, [loadBlockedUsers]);
|
||||
|
||||
const onRefresh = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
loadBlockedUsers();
|
||||
}, [loadBlockedUsers]);
|
||||
|
||||
const handleUnblock = useCallback((user: User) => {
|
||||
Alert.alert(
|
||||
'取消拉黑',
|
||||
`确定取消拉黑 ${user.nickname} 吗?`,
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '确定',
|
||||
onPress: async () => {
|
||||
try {
|
||||
setProcessingUserId(user.id);
|
||||
const ok = await authService.unblockUser(user.id);
|
||||
if (!ok) {
|
||||
Alert.alert('失败', '取消拉黑失败,请稍后重试');
|
||||
return;
|
||||
}
|
||||
setUsers(prev => prev.filter(u => u.id !== user.id));
|
||||
} finally {
|
||||
setProcessingUserId(null);
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const renderItem = ({ item }: { item: User }) => (
|
||||
<TouchableOpacity
|
||||
style={styles.item}
|
||||
activeOpacity={0.75}
|
||||
onPress={() => navigation.navigate('UserProfile', { userId: item.id })}
|
||||
>
|
||||
<Avatar source={item.avatar} size={46} name={item.nickname} />
|
||||
<View style={styles.content}>
|
||||
<Text variant="body" style={styles.nickname} numberOfLines={1}>
|
||||
{item.nickname}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
|
||||
@{item.username}
|
||||
</Text>
|
||||
</View>
|
||||
<Button
|
||||
title={processingUserId === item.id ? '处理中...' : '取消拉黑'}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onPress={() => handleUnblock(item)}
|
||||
disabled={processingUserId === item.id}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<View style={styles.loadingWrap}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<ResponsiveContainer maxWidth={900}>
|
||||
<FlatList
|
||||
data={users}
|
||||
keyExtractor={item => item.id}
|
||||
renderItem={renderItem}
|
||||
contentContainerStyle={[styles.listContent, users.length === 0 && styles.emptyContent]}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
ListEmptyComponent={
|
||||
<EmptyState
|
||||
title="黑名单为空"
|
||||
description="你还没有拉黑任何用户"
|
||||
icon="account-off-outline"
|
||||
variant="modern"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</ResponsiveContainer>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
loadingWrap: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
listContent: {
|
||||
padding: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
emptyContent: {
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
},
|
||||
item: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
...shadows.sm,
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.md,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
nickname: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
export default BlockedUsersScreen;
|
||||
791
src/screens/profile/EditProfileScreen.tsx
Normal file
791
src/screens/profile/EditProfileScreen.tsx
Normal file
@@ -0,0 +1,791 @@
|
||||
/**
|
||||
* 编辑资料页 EditProfileScreen(响应式适配)
|
||||
* 胡萝卜BBS - 编辑用户资料
|
||||
* 与用户资料页样式完全一致
|
||||
* 表单在宽屏下居中显示
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
Alert,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
|
||||
import { useAuthStore } from '../../stores';
|
||||
import { Avatar, Button, Text, ResponsiveContainer } from '../../components/common';
|
||||
import { authService, uploadService } from '../../services';
|
||||
import { useResponsive } from '../../hooks';
|
||||
|
||||
// 表单输入项组件
|
||||
interface FormFieldProps {
|
||||
icon: string;
|
||||
label: string;
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
placeholder: string;
|
||||
maxLength?: number;
|
||||
multiline?: boolean;
|
||||
numberOfLines?: number;
|
||||
autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters';
|
||||
keyboardType?: 'default' | 'email-address' | 'numeric' | 'phone-pad' | 'url';
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
const FormField: React.FC<FormFieldProps> = ({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
maxLength,
|
||||
multiline = false,
|
||||
numberOfLines = 1,
|
||||
autoCapitalize = 'sentences',
|
||||
keyboardType = 'default',
|
||||
editable = true,
|
||||
}) => {
|
||||
return (
|
||||
<View style={styles.formField}>
|
||||
<View style={styles.fieldIconContainer}>
|
||||
<MaterialCommunityIcons name={icon as any} size={22} color={colors.primary.main} />
|
||||
</View>
|
||||
<View style={styles.fieldContent}>
|
||||
<Text variant="caption" style={styles.fieldLabel}>{label}</Text>
|
||||
<TextInput
|
||||
style={[
|
||||
styles.fieldInput,
|
||||
multiline && styles.textArea,
|
||||
!editable && styles.disabledInput
|
||||
]}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.text.hint}
|
||||
maxLength={maxLength}
|
||||
multiline={multiline}
|
||||
numberOfLines={numberOfLines}
|
||||
autoCapitalize={autoCapitalize}
|
||||
keyboardType={keyboardType}
|
||||
editable={editable}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
export const EditProfileScreen: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
const { currentUser, updateUser } = useAuthStore();
|
||||
const { isWideScreen, isMobile, width } = useResponsive();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [nickname, setNickname] = useState(currentUser?.nickname || '');
|
||||
const [bio, setBio] = useState(currentUser?.bio || '');
|
||||
const [location, setLocation] = useState(currentUser?.location || '');
|
||||
const [website, setWebsite] = useState(currentUser?.website || '');
|
||||
const [phone, setPhone] = useState(currentUser?.phone || '');
|
||||
const [email, setEmail] = useState(currentUser?.email || '');
|
||||
const [avatar, setAvatar] = useState(currentUser?.avatar || '');
|
||||
const [coverUrl, setCoverUrl] = useState(currentUser?.cover_url || '');
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const [uploadingCover, setUploadingCover] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const bottomSafeDistance = isMobile ? insets.bottom + 92 : spacing.xl;
|
||||
|
||||
// 根据屏幕宽度计算封面高度
|
||||
const coverHeight = isWideScreen ? Math.min((width * 9) / 16, 300) : (width * 9) / 16;
|
||||
|
||||
// 选择头图
|
||||
const handlePickCover = async () => {
|
||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
|
||||
if (!permissionResult.granted) {
|
||||
Alert.alert('权限不足', '需要访问相册权限来选择头图');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: 'images',
|
||||
allowsEditing: true,
|
||||
aspect: [16, 9],
|
||||
quality: 0.9,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
const selectedImage = result.assets[0];
|
||||
setCoverUrl(selectedImage.uri);
|
||||
|
||||
try {
|
||||
setUploadingCover(true);
|
||||
const uploadResult = await uploadService.uploadCover({
|
||||
uri: selectedImage.uri,
|
||||
name: selectedImage.fileName || 'cover.jpg',
|
||||
type: selectedImage.mimeType || 'image/jpeg',
|
||||
});
|
||||
|
||||
if (uploadResult) {
|
||||
updateUser({ cover_url: uploadResult.url });
|
||||
Alert.alert('成功', '头图已更新');
|
||||
} else {
|
||||
Alert.alert('错误', '头图上传失败,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传头图失败:', error);
|
||||
Alert.alert('错误', '头图上传失败,请重试');
|
||||
} finally {
|
||||
setUploadingCover(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 选择头像
|
||||
const handlePickAvatar = async () => {
|
||||
const permissionResult = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
|
||||
if (!permissionResult.granted) {
|
||||
Alert.alert('权限不足', '需要访问相册权限来选择头像');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: 'images',
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
quality: 0.8,
|
||||
});
|
||||
|
||||
if (!result.canceled && result.assets[0]) {
|
||||
const selectedImage = result.assets[0];
|
||||
setAvatar(selectedImage.uri);
|
||||
|
||||
try {
|
||||
setUploadingAvatar(true);
|
||||
const uploadResult = await uploadService.uploadAvatar({
|
||||
uri: selectedImage.uri,
|
||||
name: selectedImage.fileName || 'avatar.jpg',
|
||||
type: selectedImage.mimeType || 'image/jpeg',
|
||||
});
|
||||
|
||||
if (uploadResult) {
|
||||
updateUser({ avatar: uploadResult.url });
|
||||
Alert.alert('成功', '头像已更新');
|
||||
} else {
|
||||
Alert.alert('错误', '头像上传失败,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传头像失败:', error);
|
||||
Alert.alert('错误', '头像上传失败,请重试');
|
||||
} finally {
|
||||
setUploadingAvatar(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 保存资料
|
||||
const handleSave = async () => {
|
||||
if (!nickname.trim()) {
|
||||
Alert.alert('错误', '昵称不能为空');
|
||||
return;
|
||||
}
|
||||
|
||||
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||
Alert.alert('错误', '请输入正确的邮箱地址');
|
||||
return;
|
||||
}
|
||||
|
||||
if (phone && !/^1[3-9]\d{9}$/.test(phone)) {
|
||||
Alert.alert('错误', '请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
const updatedUser = await authService.updateUser({
|
||||
nickname: nickname.trim(),
|
||||
bio: bio.trim() || undefined,
|
||||
location: location.trim() || undefined,
|
||||
website: website.trim() || undefined,
|
||||
phone: phone.trim() || undefined,
|
||||
email: email.trim() || undefined,
|
||||
});
|
||||
|
||||
if (updatedUser) {
|
||||
updateUser({
|
||||
nickname: nickname.trim(),
|
||||
bio: bio.trim() || null,
|
||||
location: location.trim() || null,
|
||||
website: website.trim() || null,
|
||||
phone: phone.trim() || null,
|
||||
email: email.trim() || null,
|
||||
});
|
||||
|
||||
Alert.alert('成功', '资料已更新', [
|
||||
{ text: '确定', onPress: () => navigation.goBack() }
|
||||
]);
|
||||
} else {
|
||||
Alert.alert('错误', '保存失败,请重试');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存资料失败:', error);
|
||||
Alert.alert('错误', '保存失败,请重试');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染表单内容
|
||||
const renderFormContent = () => (
|
||||
<>
|
||||
{/* ===== 用户资料预览区域 - 与 UserProfileHeader 完全一致 ===== */}
|
||||
<View style={styles.previewContainer}>
|
||||
{/* 封面背景 */}
|
||||
<View style={[styles.coverContainer, { height: coverHeight }]}>
|
||||
<TouchableOpacity
|
||||
style={styles.coverTouchable}
|
||||
onPress={handlePickCover}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{coverUrl ? (
|
||||
<Image
|
||||
source={{ uri: coverUrl }}
|
||||
style={styles.coverImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
) : (
|
||||
<LinearGradient
|
||||
colors={['#FF8F66', '#FF6B35', '#E5521D']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.gradient}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 头图编辑蒙版 */}
|
||||
{uploadingCover ? (
|
||||
<View style={styles.coverUploadingOverlay}>
|
||||
<MaterialCommunityIcons name="loading" size={32} color={colors.text.inverse} />
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.coverEditOverlay}>
|
||||
<MaterialCommunityIcons name="camera" size={28} color={colors.text.inverse} />
|
||||
<Text style={styles.coverEditText}>更换头图</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 装饰性波浪 */}
|
||||
<View style={styles.waveDecoration}>
|
||||
<View style={styles.wave} />
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 用户信息卡片 */}
|
||||
<View style={styles.profileCard}>
|
||||
{/* 悬浮头像 */}
|
||||
<View style={styles.avatarWrapper}>
|
||||
<TouchableOpacity
|
||||
style={styles.avatarContainer}
|
||||
onPress={handlePickAvatar}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Avatar
|
||||
source={avatar || null}
|
||||
size={isWideScreen ? 110 : 90}
|
||||
name={nickname}
|
||||
/>
|
||||
{uploadingAvatar && (
|
||||
<View style={styles.avatarUploadingOverlay}>
|
||||
<MaterialCommunityIcons name="loading" size={32} color={colors.text.inverse} />
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.editAvatarButton}>
|
||||
<MaterialCommunityIcons name="camera" size={14} color={colors.text.inverse} />
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 用户名和简介 */}
|
||||
<View style={styles.userInfo}>
|
||||
<Text variant="h2" style={styles.nickname}>
|
||||
{nickname || currentUser?.nickname}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.username}>
|
||||
@{currentUser?.username}
|
||||
</Text>
|
||||
|
||||
{bio ? (
|
||||
<Text variant="body" color={colors.text.secondary} style={styles.bio}>
|
||||
{bio}
|
||||
</Text>
|
||||
) : (
|
||||
<Text variant="body" color={colors.text.hint} style={styles.bioPlaceholder}>
|
||||
这个人很懒,还没有写简介~
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 个人信息标签 */}
|
||||
<View style={styles.metaInfo}>
|
||||
{location ? (
|
||||
<View style={styles.metaTag}>
|
||||
<MaterialCommunityIcons name="map-marker-outline" size={12} color={colors.primary.main} />
|
||||
<Text variant="caption" color={colors.primary.main} style={styles.metaTagText}>
|
||||
{location}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.metaTag}>
|
||||
<MaterialCommunityIcons name="map-marker-outline" size={12} color={colors.text.hint} />
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.metaTagText}>
|
||||
未设置地区
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
{website ? (
|
||||
<View style={styles.metaTag}>
|
||||
<MaterialCommunityIcons name="link-variant" size={12} color={colors.info.main} />
|
||||
<Text variant="caption" color={colors.info.main} style={styles.metaTagText}>
|
||||
{website.replace(/^https?:\/\//, '')}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.metaTag}>
|
||||
<MaterialCommunityIcons name="link-variant" size={12} color={colors.text.hint} />
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.metaTagText}>
|
||||
未设置网站
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={styles.metaTag}>
|
||||
<MaterialCommunityIcons name="calendar-outline" size={12} color={colors.text.secondary} />
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.metaTagText}>
|
||||
加入于 {new Date(currentUser?.created_at || Date.now()).getFullYear()}年
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 编辑提示 */}
|
||||
<View style={styles.editHint}>
|
||||
<MaterialCommunityIcons name="information-outline" size={14} color={colors.text.hint} />
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.editHintText}>
|
||||
点击头像或头图可更换照片
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* ===== 表单区域 ===== */}
|
||||
<View style={styles.formCard}>
|
||||
<Text variant="body" style={[styles.sectionTitle, { fontWeight: '600' }]}>
|
||||
个人资料
|
||||
</Text>
|
||||
|
||||
<FormField
|
||||
icon="account-outline"
|
||||
label="昵称"
|
||||
value={nickname}
|
||||
onChangeText={setNickname}
|
||||
placeholder="请输入昵称"
|
||||
maxLength={20}
|
||||
/>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<FormField
|
||||
icon="information-outline"
|
||||
label="个人简介"
|
||||
value={bio}
|
||||
onChangeText={setBio}
|
||||
placeholder="介绍一下自己,让大家更了解你"
|
||||
maxLength={100}
|
||||
multiline
|
||||
numberOfLines={3}
|
||||
/>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<FormField
|
||||
icon="map-marker-outline"
|
||||
label="地区"
|
||||
value={location}
|
||||
onChangeText={setLocation}
|
||||
placeholder="你所在的城市"
|
||||
maxLength={30}
|
||||
/>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<FormField
|
||||
icon="link-variant"
|
||||
label="个人网站"
|
||||
value={website}
|
||||
onChangeText={setWebsite}
|
||||
placeholder="https://example.com"
|
||||
maxLength={100}
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 联系方式区域 */}
|
||||
<View style={styles.formCard}>
|
||||
<Text variant="body" style={[styles.sectionTitle, { fontWeight: '600' }]}>
|
||||
联系方式
|
||||
</Text>
|
||||
|
||||
<FormField
|
||||
icon="phone-outline"
|
||||
label="手机号"
|
||||
value={phone}
|
||||
onChangeText={setPhone}
|
||||
placeholder="请输入手机号"
|
||||
maxLength={11}
|
||||
keyboardType="phone-pad"
|
||||
/>
|
||||
|
||||
<View style={styles.divider} />
|
||||
|
||||
<FormField
|
||||
icon="email-outline"
|
||||
label="邮箱"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholder="请输入邮箱地址"
|
||||
maxLength={100}
|
||||
autoCapitalize="none"
|
||||
keyboardType="email-address"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 保存按钮 */}
|
||||
<View style={[styles.buttonContainer, { marginBottom: bottomSafeDistance }]}>
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.saveButton,
|
||||
(saving || uploadingAvatar || uploadingCover) && styles.saveButtonDisabled
|
||||
]}
|
||||
onPress={handleSave}
|
||||
disabled={saving || uploadingAvatar || uploadingCover}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<View style={styles.saveButtonContent}>
|
||||
{saving ? (
|
||||
<MaterialCommunityIcons
|
||||
name="loading"
|
||||
size={20}
|
||||
color={colors.text.inverse}
|
||||
style={styles.buttonIcon}
|
||||
/>
|
||||
) : (
|
||||
<MaterialCommunityIcons
|
||||
name="check"
|
||||
size={20}
|
||||
color={colors.text.inverse}
|
||||
style={styles.buttonIcon}
|
||||
/>
|
||||
)}
|
||||
<Text variant="body" style={[styles.saveButtonText, { fontWeight: '600' }]}>
|
||||
{saving ? '保存中...' : '保存修改'}
|
||||
</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={styles.keyboardView}
|
||||
>
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={800}>
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{renderFormContent()}
|
||||
</ScrollView>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<ScrollView
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{renderFormContent()}
|
||||
</ScrollView>
|
||||
)}
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
keyboardView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: spacing.lg,
|
||||
},
|
||||
|
||||
// ===== 预览区域 - 与 UserProfileHeader 完全一致 =====
|
||||
previewContainer: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
coverContainer: {
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: borderRadius.lg,
|
||||
},
|
||||
coverTouchable: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
coverImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
gradient: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
coverUploadingOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
coverEditOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: '60%',
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
coverEditText: {
|
||||
color: colors.text.inverse,
|
||||
fontSize: fontSizes.sm,
|
||||
marginTop: spacing.xs,
|
||||
fontWeight: '500',
|
||||
},
|
||||
waveDecoration: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 40,
|
||||
},
|
||||
wave: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderTopLeftRadius: 30,
|
||||
borderTopRightRadius: 30,
|
||||
},
|
||||
|
||||
// 用户信息卡片
|
||||
profileCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
marginHorizontal: spacing.md,
|
||||
marginTop: -50,
|
||||
borderRadius: borderRadius.xl,
|
||||
padding: spacing.lg,
|
||||
...shadows.md,
|
||||
},
|
||||
avatarWrapper: {
|
||||
alignItems: 'center',
|
||||
marginTop: -60,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
avatarContainer: {
|
||||
position: 'relative',
|
||||
padding: 4,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: 50,
|
||||
},
|
||||
avatarUploadingOverlay: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
borderRadius: 60,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
editAvatarButton: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
width: 28,
|
||||
height: 28,
|
||||
borderRadius: 14,
|
||||
backgroundColor: colors.primary.main,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background.paper,
|
||||
},
|
||||
userInfo: {
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
nickname: {
|
||||
marginBottom: spacing.xs,
|
||||
fontWeight: '700',
|
||||
},
|
||||
username: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
bio: {
|
||||
textAlign: 'center',
|
||||
marginTop: spacing.sm,
|
||||
lineHeight: 20,
|
||||
},
|
||||
bioPlaceholder: {
|
||||
textAlign: 'center',
|
||||
marginTop: spacing.sm,
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
metaInfo: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
flexWrap: 'wrap',
|
||||
marginBottom: spacing.md,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
metaTag: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: spacing.xs,
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
metaTagText: {
|
||||
marginLeft: spacing.xs,
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
editHint: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingVertical: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
editHintText: {
|
||||
fontSize: fontSizes.xs,
|
||||
},
|
||||
|
||||
// ===== 表单区域 =====
|
||||
formCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.lg,
|
||||
marginBottom: spacing.lg,
|
||||
shadowColor: colors.text.primary,
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.05,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
sectionTitle: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
formField: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
fieldIconContainer: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
fieldContent: {
|
||||
flex: 1,
|
||||
},
|
||||
fieldLabel: {
|
||||
marginBottom: spacing.xs,
|
||||
fontWeight: '500',
|
||||
},
|
||||
fieldInput: {
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
textArea: {
|
||||
height: 80,
|
||||
textAlignVertical: 'top',
|
||||
},
|
||||
disabledInput: {
|
||||
color: colors.text.disabled,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.divider,
|
||||
marginVertical: spacing.md,
|
||||
marginLeft: 56,
|
||||
},
|
||||
|
||||
// 保存按钮
|
||||
buttonContainer: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
saveButton: {
|
||||
backgroundColor: colors.primary.main,
|
||||
borderRadius: borderRadius.lg,
|
||||
paddingVertical: spacing.md,
|
||||
shadowColor: colors.primary.main,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.3,
|
||||
shadowRadius: 8,
|
||||
elevation: 4,
|
||||
},
|
||||
saveButtonDisabled: {
|
||||
opacity: 0.6,
|
||||
},
|
||||
saveButtonContent: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
buttonIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
saveButtonText: {
|
||||
color: colors.text.inverse,
|
||||
},
|
||||
});
|
||||
|
||||
export default EditProfileScreen;
|
||||
495
src/screens/profile/FollowListScreen.tsx
Normal file
495
src/screens/profile/FollowListScreen.tsx
Normal file
@@ -0,0 +1,495 @@
|
||||
/**
|
||||
* FollowListScreen 关注/粉丝列表页面(响应式适配)
|
||||
* 显示用户的关注列表或粉丝列表
|
||||
* 支持互关状态显示和关注/回关操作
|
||||
* 在宽屏下使用网格布局
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
RefreshControl,
|
||||
TouchableOpacity,
|
||||
ListRenderItem,
|
||||
ActivityIndicator,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { colors, spacing, borderRadius, shadows } from '../../theme';
|
||||
import { User } from '../../types';
|
||||
import { useAuthStore, useUserStore } from '../../stores';
|
||||
import { authService } from '../../services';
|
||||
import { Avatar, Text, Button, Loading, EmptyState, ResponsiveContainer } from '../../components/common';
|
||||
import { HomeStackParamList } from '../../navigation/types';
|
||||
import { useResponsive, useColumnCount } from '../../hooks';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'FollowList'>;
|
||||
type FollowListRouteProp = RouteProp<HomeStackParamList, 'FollowList'>;
|
||||
|
||||
const FollowListScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const route = useRoute<FollowListRouteProp>();
|
||||
const { userId, type } = route.params;
|
||||
|
||||
const { currentUser } = useAuthStore();
|
||||
const { followUser, unfollowUser } = useUserStore();
|
||||
|
||||
// 响应式布局
|
||||
const { isWideScreen, isDesktop, width } = useResponsive();
|
||||
const columnCount = useColumnCount({
|
||||
xs: 1,
|
||||
sm: 1,
|
||||
md: 2,
|
||||
lg: 2,
|
||||
xl: 3,
|
||||
'2xl': 3,
|
||||
'3xl': 4,
|
||||
'4xl': 4,
|
||||
});
|
||||
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
|
||||
const isCurrentUser = currentUser?.id === userId;
|
||||
const title = type === 'following' ? '关注' : '粉丝';
|
||||
|
||||
// 加载用户列表
|
||||
const loadUsers = useCallback(async (pageNum: number = 1, refresh: boolean = false) => {
|
||||
if (!hasMore && !refresh) return;
|
||||
|
||||
try {
|
||||
const pageSize = 20;
|
||||
let userList: User[] = [];
|
||||
|
||||
if (type === 'following') {
|
||||
userList = await authService.getFollowingList(userId, pageNum, pageSize);
|
||||
} else {
|
||||
userList = await authService.getFollowersList(userId, pageNum, pageSize);
|
||||
}
|
||||
|
||||
if (refresh) {
|
||||
setUsers(userList);
|
||||
setPage(1);
|
||||
} else {
|
||||
setUsers(prev => [...prev, ...userList]);
|
||||
}
|
||||
|
||||
setHasMore(userList.length === pageSize);
|
||||
} catch (error) {
|
||||
console.error('加载用户列表失败:', error);
|
||||
}
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}, [userId, type, hasMore]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
loadUsers(1, true);
|
||||
}, [userId, type]);
|
||||
|
||||
// 下拉刷新
|
||||
const onRefresh = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
setHasMore(true);
|
||||
loadUsers(1, true);
|
||||
}, [loadUsers]);
|
||||
|
||||
// 加载更多
|
||||
const loadMore = useCallback(() => {
|
||||
if (!loading && hasMore) {
|
||||
const nextPage = page + 1;
|
||||
setPage(nextPage);
|
||||
loadUsers(nextPage);
|
||||
}
|
||||
}, [loading, hasMore, page, loadUsers]);
|
||||
|
||||
// 关注/取消关注
|
||||
const handleFollowToggle = async (user: User) => {
|
||||
const isFollowing = user.is_following ?? false;
|
||||
|
||||
// 乐观更新
|
||||
setUsers(prev => prev.map(u => {
|
||||
if (u.id === user.id) {
|
||||
return {
|
||||
...u,
|
||||
is_following: !isFollowing,
|
||||
followers_count: isFollowing ? u.followers_count - 1 : u.followers_count + 1,
|
||||
};
|
||||
}
|
||||
return u;
|
||||
}));
|
||||
|
||||
if (isFollowing) {
|
||||
await unfollowUser(user.id);
|
||||
} else {
|
||||
await followUser(user.id);
|
||||
}
|
||||
};
|
||||
|
||||
// 跳转到用户主页
|
||||
const handleUserPress = (targetUserId: string) => {
|
||||
if (targetUserId !== currentUser?.id) {
|
||||
navigation.push('UserProfile', { userId: targetUserId });
|
||||
}
|
||||
};
|
||||
|
||||
// 获取按钮状态
|
||||
const getButtonConfig = (user: User): { title: string; variant: 'primary' | 'outline' } => {
|
||||
const isFollowing = user.is_following ?? false;
|
||||
const isFollowingMe = user.is_following_me ?? false;
|
||||
|
||||
if (isFollowing && isFollowingMe) {
|
||||
// 已互关
|
||||
return { title: '互相关注', variant: 'outline' };
|
||||
} else if (isFollowing) {
|
||||
// 已关注但对方未回关
|
||||
return { title: '已关注', variant: 'outline' };
|
||||
} else if (isFollowingMe) {
|
||||
// 对方关注了我,但我没关注对方
|
||||
return { title: '回关', variant: 'primary' };
|
||||
} else {
|
||||
// 互不关注
|
||||
return { title: '关注', variant: 'primary' };
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染用户项 - 列表模式(移动端)
|
||||
const renderUserListItem: ListRenderItem<User> = ({ item }) => {
|
||||
const buttonConfig = getButtonConfig(item);
|
||||
const isSelf = item.id === currentUser?.id;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.userItem}
|
||||
onPress={() => handleUserPress(item.id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Avatar
|
||||
source={item.avatar}
|
||||
size={52}
|
||||
name={item.nickname}
|
||||
/>
|
||||
<View style={styles.userInfo}>
|
||||
<Text variant="body" style={styles.nickname} numberOfLines={1}>
|
||||
{item.nickname}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
|
||||
@{item.username}
|
||||
</Text>
|
||||
{item.bio && (
|
||||
<Text variant="caption" color={colors.text.hint} numberOfLines={1} style={styles.bio}>
|
||||
{item.bio}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{!isSelf && (
|
||||
<Button
|
||||
title={buttonConfig.title}
|
||||
variant={buttonConfig.variant}
|
||||
size="sm"
|
||||
onPress={() => handleFollowToggle(item)}
|
||||
style={styles.followButton}
|
||||
/>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染用户卡片 - 网格模式(宽屏)
|
||||
const renderUserGridItem: ListRenderItem<User> = ({ item }) => {
|
||||
const buttonConfig = getButtonConfig(item);
|
||||
const isSelf = item.id === currentUser?.id;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={styles.userCard}
|
||||
onPress={() => handleUserPress(item.id)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.userCardHeader}>
|
||||
<Avatar
|
||||
source={item.avatar}
|
||||
size={56}
|
||||
name={item.nickname}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.userCardContent}>
|
||||
<Text variant="body" style={styles.userCardNickname} numberOfLines={1}>
|
||||
{item.nickname}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary} numberOfLines={1}>
|
||||
@{item.username}
|
||||
</Text>
|
||||
{item.bio && (
|
||||
<Text variant="caption" color={colors.text.hint} numberOfLines={2} style={styles.userCardBio}>
|
||||
{item.bio}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
{!isSelf && (
|
||||
<View style={styles.userCardFooter}>
|
||||
<Button
|
||||
title={buttonConfig.title}
|
||||
variant={buttonConfig.variant}
|
||||
size="sm"
|
||||
onPress={() => handleFollowToggle(item)}
|
||||
style={styles.userCardFollowButton}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染空状态
|
||||
const renderEmpty = () => {
|
||||
if (loading) return <Loading />;
|
||||
|
||||
const emptyText = type === 'following'
|
||||
? '还没有关注任何人'
|
||||
: '还没有粉丝';
|
||||
const emptyDesc = type === 'following'
|
||||
? '去发现更多有趣的用户吧'
|
||||
: '发布更多优质内容来吸引粉丝吧';
|
||||
|
||||
return (
|
||||
<EmptyState
|
||||
title={emptyText}
|
||||
description={emptyDesc}
|
||||
icon={type === 'following' ? 'account-plus-outline' : 'account-group-outline'}
|
||||
variant="modern"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const renderListHeader = () => (
|
||||
<View style={styles.headerCard}>
|
||||
<View style={styles.headerAccent} />
|
||||
<View style={styles.headerMainRow}>
|
||||
<Text variant="h2" style={styles.headerTitle}>{title}</Text>
|
||||
<View style={styles.headerCountPill}>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
共 {users.length}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.headerSubtitle}>
|
||||
{type === 'following' ? '你已关注的用户' : '关注你的用户'}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
const renderFooter = () => {
|
||||
if (!hasMore || loading || users.length === 0) return null;
|
||||
return (
|
||||
<View style={styles.footerLoading}>
|
||||
<ActivityIndicator size="small" color={colors.primary.main} />
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.footerLoadingText}>
|
||||
正在加载更多
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 宽屏使用网格布局
|
||||
if (isWideScreen && columnCount > 1) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<ResponsiveContainer maxWidth={1200}>
|
||||
<FlatList
|
||||
data={users}
|
||||
renderItem={renderUserGridItem}
|
||||
keyExtractor={item => item.id}
|
||||
key={`grid-${columnCount}`}
|
||||
numColumns={columnCount}
|
||||
contentContainerStyle={[
|
||||
styles.gridContent,
|
||||
users.length === 0 && styles.emptyGridContent,
|
||||
]}
|
||||
columnWrapperStyle={styles.gridRow}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.3}
|
||||
ListHeaderComponent={renderListHeader}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
ListFooterComponent={renderFooter}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
</ResponsiveContainer>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
// 移动端使用列表布局
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<FlatList
|
||||
data={users}
|
||||
renderItem={renderUserListItem}
|
||||
keyExtractor={item => item.id}
|
||||
contentContainerStyle={styles.listContent}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
onEndReached={loadMore}
|
||||
onEndReachedThreshold={0.3}
|
||||
ListHeaderComponent={renderListHeader}
|
||||
ListEmptyComponent={renderEmpty}
|
||||
ListFooterComponent={renderFooter}
|
||||
showsVerticalScrollIndicator={false}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
// 列表布局样式
|
||||
listContent: {
|
||||
flexGrow: 1,
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.md,
|
||||
paddingBottom: spacing.lg,
|
||||
},
|
||||
headerCard: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.xl,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
marginBottom: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider + '4A',
|
||||
...shadows.sm,
|
||||
},
|
||||
headerAccent: {
|
||||
width: 28,
|
||||
height: 3,
|
||||
borderRadius: 999,
|
||||
backgroundColor: colors.primary.main + 'A6',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
headerMainRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
headerTitle: {
|
||||
fontWeight: '700',
|
||||
},
|
||||
headerCountPill: {
|
||||
paddingHorizontal: spacing.sm,
|
||||
paddingVertical: 4,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
headerSubtitle: {
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
userItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.sm,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider + '45',
|
||||
...shadows.sm,
|
||||
},
|
||||
userInfo: {
|
||||
flex: 1,
|
||||
marginLeft: spacing.md,
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
nickname: {
|
||||
fontWeight: '600',
|
||||
marginBottom: 2,
|
||||
},
|
||||
bio: {
|
||||
marginTop: 2,
|
||||
},
|
||||
followButton: {
|
||||
minWidth: 82,
|
||||
},
|
||||
// 网格布局样式
|
||||
gridContent: {
|
||||
flexGrow: 1,
|
||||
padding: spacing.lg,
|
||||
},
|
||||
emptyGridContent: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
gridRow: {
|
||||
justifyContent: 'flex-start',
|
||||
gap: spacing.md,
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
userCard: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.xl,
|
||||
padding: spacing.md,
|
||||
alignItems: 'center',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.divider + '45',
|
||||
...shadows.sm,
|
||||
},
|
||||
userCardHeader: {
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
userCardContent: {
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
},
|
||||
userCardNickname: {
|
||||
fontWeight: '600',
|
||||
marginBottom: 2,
|
||||
textAlign: 'center',
|
||||
},
|
||||
userCardBio: {
|
||||
marginTop: spacing.xs,
|
||||
textAlign: 'center',
|
||||
},
|
||||
userCardFooter: {
|
||||
marginTop: spacing.md,
|
||||
width: '100%',
|
||||
},
|
||||
userCardFollowButton: {
|
||||
width: '100%',
|
||||
},
|
||||
footerLoading: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
footerLoadingText: {
|
||||
marginLeft: spacing.xs,
|
||||
},
|
||||
});
|
||||
|
||||
export default FollowListScreen;
|
||||
234
src/screens/profile/NotificationSettingsScreen.tsx
Normal file
234
src/screens/profile/NotificationSettingsScreen.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
/**
|
||||
* 通知设置页 NotificationSettingsScreen(响应式适配)
|
||||
* 胡萝卜BBS - 通知相关设置
|
||||
* 在宽屏下居中显示
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
Switch,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { Text, ResponsiveContainer } from '../../components/common';
|
||||
import { setVibrationEnabled } from '../../services/backgroundService';
|
||||
import { useResponsive } from '../../hooks';
|
||||
|
||||
const VIBRATION_ENABLED_KEY = 'vibration_enabled';
|
||||
|
||||
interface NotificationSettingItem {
|
||||
key: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
icon: string;
|
||||
value: boolean;
|
||||
onValueChange: (value: boolean) => void;
|
||||
}
|
||||
|
||||
export const NotificationSettingsScreen: React.FC = () => {
|
||||
const [vibrationEnabled, setVibrationEnabledState] = useState(true);
|
||||
const [pushEnabled, setPushEnabled] = useState(true);
|
||||
const [soundEnabled, setSoundEnabled] = useState(true);
|
||||
const { isWideScreen } = useResponsive();
|
||||
|
||||
// 加载设置
|
||||
useEffect(() => {
|
||||
const loadSettings = async () => {
|
||||
try {
|
||||
const vibrationStored = await AsyncStorage.getItem(VIBRATION_ENABLED_KEY);
|
||||
if (vibrationStored !== null) {
|
||||
const enabled = JSON.parse(vibrationStored);
|
||||
setVibrationEnabledState(enabled);
|
||||
setVibrationEnabled(enabled);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载震动设置失败:', error);
|
||||
}
|
||||
};
|
||||
loadSettings();
|
||||
}, []);
|
||||
|
||||
// 切换震动
|
||||
const toggleVibration = async (value: boolean) => {
|
||||
try {
|
||||
setVibrationEnabledState(value);
|
||||
setVibrationEnabled(value);
|
||||
await AsyncStorage.setItem(VIBRATION_ENABLED_KEY, JSON.stringify(value));
|
||||
} catch (error) {
|
||||
console.error('保存震动设置失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const settings: NotificationSettingItem[] = [
|
||||
{
|
||||
key: 'push',
|
||||
title: '接收推送通知',
|
||||
subtitle: '接收新消息、点赞、评论等推送',
|
||||
icon: 'bell-outline',
|
||||
value: pushEnabled,
|
||||
onValueChange: setPushEnabled,
|
||||
},
|
||||
{
|
||||
key: 'vibration',
|
||||
title: '消息震动',
|
||||
subtitle: '收到新消息时震动提醒',
|
||||
icon: 'vibrate',
|
||||
value: vibrationEnabled,
|
||||
onValueChange: toggleVibration,
|
||||
},
|
||||
{
|
||||
key: 'sound',
|
||||
title: '消息提示音',
|
||||
subtitle: '收到新消息时播放提示音',
|
||||
icon: 'volume-high',
|
||||
value: soundEnabled,
|
||||
onValueChange: setSoundEnabled,
|
||||
},
|
||||
];
|
||||
|
||||
// 渲染内容
|
||||
const renderContent = () => (
|
||||
<>
|
||||
{/* 消息通知设置 */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<MaterialCommunityIcons name="message-text-outline" size={18} color={colors.primary.main} />
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.sectionTitle}>
|
||||
消息通知
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
{settings.map((item, index) => (
|
||||
<View key={item.key}>
|
||||
<View style={styles.settingItem}>
|
||||
<View style={styles.iconContainer}>
|
||||
<MaterialCommunityIcons
|
||||
name={item.icon as any}
|
||||
size={20}
|
||||
color={colors.primary.main}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.settingContent}>
|
||||
<Text variant="body" color={colors.text.primary}>
|
||||
{item.title}
|
||||
</Text>
|
||||
{item.subtitle && (
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.subtitle}>
|
||||
{item.subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<Switch
|
||||
value={item.value}
|
||||
onValueChange={item.onValueChange}
|
||||
trackColor={{ false: colors.divider, true: colors.primary.main }}
|
||||
thumbColor={colors.background.paper}
|
||||
/>
|
||||
</View>
|
||||
{index < settings.length - 1 && <View style={styles.divider} />}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 提示信息 */}
|
||||
<View style={styles.tipContainer}>
|
||||
<MaterialCommunityIcons name="information-outline" size={16} color={colors.text.hint} />
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.tipText}>
|
||||
关闭推送通知后,您将不再收到任何消息提醒,但消息仍会在应用内显示。
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={800}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
{renderContent()}
|
||||
</ScrollView>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
{renderContent()}
|
||||
</ScrollView>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
section: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
sectionHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
marginBottom: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
sectionTitle: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
marginHorizontal: spacing.lg,
|
||||
borderRadius: borderRadius.lg,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
settingItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
settingContent: {
|
||||
flex: 1,
|
||||
},
|
||||
subtitle: {
|
||||
marginTop: 2,
|
||||
},
|
||||
divider: {
|
||||
height: 1,
|
||||
backgroundColor: colors.divider,
|
||||
marginLeft: 36 + spacing.md + spacing.md,
|
||||
},
|
||||
tipContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
marginHorizontal: spacing.lg,
|
||||
padding: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
tipText: {
|
||||
flex: 1,
|
||||
lineHeight: 20,
|
||||
},
|
||||
});
|
||||
|
||||
export default NotificationSettingsScreen;
|
||||
490
src/screens/profile/ProfileScreen.tsx
Normal file
490
src/screens/profile/ProfileScreen.tsx
Normal file
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* 个人主页 ProfileScreen - 美化版(响应式适配)
|
||||
* 胡萝卜BBS - 当前用户个人主页
|
||||
* 采用现代卡片式设计,优化视觉层次和交互体验
|
||||
* 支持桌面端双栏布局
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
RefreshControl,
|
||||
Animated,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
|
||||
import { colors, spacing } from '../../theme';
|
||||
import { Post } from '../../types';
|
||||
import { useAuthStore, useUserStore } from '../../stores';
|
||||
import { postService } from '../../services';
|
||||
import { UserProfileHeader, PostCard, TabBar } from '../../components/business';
|
||||
import { Loading, EmptyState, Text } from '../../components/common';
|
||||
import { ResponsiveContainer } from '../../components/common';
|
||||
import { useResponsive } from '../../hooks';
|
||||
import { ProfileStackParamList, HomeStackParamList, RootStackParamList } from '../../navigation/types';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<ProfileStackParamList, 'Profile'>;
|
||||
type HomeNavigationProp = NativeStackNavigationProp<HomeStackParamList>;
|
||||
type RootNavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
const TABS = ['帖子', '收藏'];
|
||||
const TAB_ICONS = ['file-document-outline', 'bookmark-outline'];
|
||||
|
||||
export const ProfileScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const insets = useSafeAreaInsets();
|
||||
const tabBarHeight = useBottomTabBarHeight();
|
||||
const homeNavigation = useNavigation<HomeNavigationProp>();
|
||||
// 使用 any 类型来访问根导航
|
||||
const rootNavigation = useNavigation<RootNavigationProp>();
|
||||
const { currentUser, updateUser, fetchCurrentUser } = useAuthStore();
|
||||
const { followUser, unfollowUser, likePost, unlikePost, favoritePost, unfavoritePost, posts: storePosts } = useUserStore();
|
||||
|
||||
// 响应式布局
|
||||
const { isDesktop, isTablet, width } = useResponsive();
|
||||
|
||||
// 页面滚动底部安全间距,避免内容被底部 TabBar 遮挡
|
||||
const scrollBottomInset = useMemo(() => {
|
||||
if (isDesktop) return spacing.lg;
|
||||
return tabBarHeight + insets.bottom + spacing.md;
|
||||
}, [isDesktop, tabBarHeight, insets.bottom]);
|
||||
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [favorites, setFavorites] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const scrollY = new Animated.Value(0);
|
||||
|
||||
// 跳转到关注列表
|
||||
const handleFollowingPress = useCallback(() => {
|
||||
if (currentUser) {
|
||||
(rootNavigation as any).navigate('FollowList', { userId: currentUser.id, type: 'following' });
|
||||
}
|
||||
}, [currentUser, rootNavigation]);
|
||||
|
||||
// 跳转到粉丝列表
|
||||
const handleFollowersPress = useCallback(() => {
|
||||
if (currentUser) {
|
||||
(rootNavigation as any).navigate('FollowList', { userId: currentUser.id, type: 'followers' });
|
||||
}
|
||||
}, [currentUser, rootNavigation]);
|
||||
|
||||
// 加载用户帖子
|
||||
const loadUserPosts = useCallback(async () => {
|
||||
if (currentUser) {
|
||||
try {
|
||||
const response = await postService.getUserPosts(currentUser.id);
|
||||
setPosts(response.list);
|
||||
} catch (error) {
|
||||
console.error('获取用户帖子失败:', error);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
}, [currentUser]);
|
||||
|
||||
// 加载用户收藏
|
||||
const loadUserFavorites = useCallback(async () => {
|
||||
if (currentUser) {
|
||||
try {
|
||||
console.log('[ProfileScreen] load, userUserFavorites calledId:', currentUser.id);
|
||||
const response = await postService.getUserFavorites(currentUser.id);
|
||||
console.log('[ProfileScreen] getUserFavorites response:', response);
|
||||
setFavorites(response.list);
|
||||
} catch (error) {
|
||||
console.error('获取用户收藏失败:', error);
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
}, [currentUser]);
|
||||
|
||||
// 监听 tab 切换,只在数据为空时加载对应数据
|
||||
React.useEffect(() => {
|
||||
if (activeTab === 0 && posts.length === 0) {
|
||||
loadUserPosts();
|
||||
} else if (activeTab === 1 && favorites.length === 0) {
|
||||
loadUserFavorites();
|
||||
}
|
||||
}, [activeTab, loadUserPosts, loadUserFavorites, posts.length, favorites.length]);
|
||||
|
||||
// 初始加载
|
||||
React.useEffect(() => {
|
||||
loadUserPosts();
|
||||
}, [loadUserPosts]);
|
||||
|
||||
// 同步 store 中的帖子状态到本地(用于点赞、收藏等状态更新)
|
||||
React.useEffect(() => {
|
||||
// 同步帖子列表状态
|
||||
if (posts.length > 0) {
|
||||
let hasChanges = false;
|
||||
const updatedPosts = posts.map(localPost => {
|
||||
const storePost = storePosts.find(sp => sp.id === localPost.id);
|
||||
if (storePost && (
|
||||
storePost.is_liked !== localPost.is_liked ||
|
||||
storePost.is_favorited !== localPost.is_favorited ||
|
||||
storePost.likes_count !== localPost.likes_count ||
|
||||
storePost.favorites_count !== localPost.favorites_count
|
||||
)) {
|
||||
hasChanges = true;
|
||||
return {
|
||||
...localPost,
|
||||
is_liked: storePost.is_liked,
|
||||
is_favorited: storePost.is_favorited,
|
||||
likes_count: storePost.likes_count,
|
||||
favorites_count: storePost.favorites_count,
|
||||
};
|
||||
}
|
||||
return localPost;
|
||||
});
|
||||
|
||||
if (hasChanges) {
|
||||
setPosts(updatedPosts);
|
||||
}
|
||||
}
|
||||
|
||||
// 同步收藏列表状态
|
||||
if (favorites.length > 0) {
|
||||
let hasChanges = false;
|
||||
const updatedFavorites = favorites.map(localPost => {
|
||||
const storePost = storePosts.find(sp => sp.id === localPost.id);
|
||||
if (storePost && (
|
||||
storePost.is_liked !== localPost.is_liked ||
|
||||
storePost.is_favorited !== localPost.is_favorited ||
|
||||
storePost.likes_count !== localPost.likes_count ||
|
||||
storePost.favorites_count !== localPost.favorites_count
|
||||
)) {
|
||||
hasChanges = true;
|
||||
return {
|
||||
...localPost,
|
||||
is_liked: storePost.is_liked,
|
||||
is_favorited: storePost.is_favorited,
|
||||
likes_count: storePost.likes_count,
|
||||
favorites_count: storePost.favorites_count,
|
||||
};
|
||||
}
|
||||
return localPost;
|
||||
});
|
||||
|
||||
if (hasChanges) {
|
||||
setFavorites(updatedFavorites);
|
||||
}
|
||||
}
|
||||
}, [storePosts]);
|
||||
|
||||
// 下拉刷新
|
||||
const onRefresh = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
// 刷新用户信息
|
||||
await fetchCurrentUser();
|
||||
// 刷新帖子列表
|
||||
await loadUserPosts();
|
||||
} catch (error) {
|
||||
console.error('刷新失败:', error);
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [fetchCurrentUser, loadUserPosts]);
|
||||
|
||||
// 跳转到设置页
|
||||
const handleSettings = useCallback(() => {
|
||||
navigation.navigate('Settings');
|
||||
}, [navigation]);
|
||||
|
||||
// 跳转到编辑资料页
|
||||
const handleEditProfile = useCallback(() => {
|
||||
navigation.navigate('EditProfile');
|
||||
}, [navigation]);
|
||||
|
||||
// 关注/取消关注
|
||||
const handleFollow = useCallback(() => {
|
||||
if (!currentUser) return;
|
||||
if (currentUser.is_following) {
|
||||
unfollowUser(currentUser.id);
|
||||
} else {
|
||||
followUser(currentUser.id);
|
||||
}
|
||||
}, [currentUser, unfollowUser, followUser]);
|
||||
|
||||
// 跳转到帖子详情
|
||||
const handlePostPress = useCallback((postId: string, scrollToComments: boolean = false) => {
|
||||
homeNavigation.navigate('PostDetail', { postId, scrollToComments });
|
||||
}, [homeNavigation]);
|
||||
|
||||
// 跳转到用户主页(当前用户)
|
||||
const handleUserPress = useCallback((userId: string) => {
|
||||
// 个人主页点击自己的头像不跳转
|
||||
}, []);
|
||||
|
||||
// 删除帖子
|
||||
const handleDeletePost = useCallback(async (postId: string) => {
|
||||
try {
|
||||
const success = await postService.deletePost(postId);
|
||||
if (success) {
|
||||
// 从帖子列表中移除
|
||||
setPosts(prev => prev.filter(p => p.id !== postId));
|
||||
// 也从收藏列表中移除(如果存在)
|
||||
setFavorites(prev => prev.filter(p => p.id !== postId));
|
||||
} else {
|
||||
console.error('删除帖子失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除帖子失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 渲染内容
|
||||
const renderContent = useCallback(() => {
|
||||
if (loading) return <Loading />;
|
||||
|
||||
if (activeTab === 0) {
|
||||
// 帖子
|
||||
if (posts.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="还没有帖子"
|
||||
description="分享你的想法,发布第一条帖子吧"
|
||||
icon="file-document-edit-outline"
|
||||
variant="modern"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.postsContainer}>
|
||||
{posts.map((post, index) => {
|
||||
const isPostAuthor = currentUser?.id === post.author?.id;
|
||||
return (
|
||||
<View key={post.id} style={[
|
||||
styles.postWrapper,
|
||||
index === posts.length - 1 && styles.lastPost,
|
||||
]}>
|
||||
<PostCard
|
||||
post={post}
|
||||
onPress={() => handlePostPress(post.id)}
|
||||
onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}}
|
||||
onLike={() => post.is_liked ? unlikePost(post.id) : likePost(post.id)}
|
||||
onComment={() => handlePostPress(post.id, true)}
|
||||
onBookmark={() => post.is_favorited ? unfavoritePost(post.id) : favoritePost(post.id)}
|
||||
onShare={() => {}}
|
||||
onDelete={() => handleDeletePost(post.id)}
|
||||
isPostAuthor={isPostAuthor}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === 1) {
|
||||
// 收藏
|
||||
if (favorites.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="还没有收藏"
|
||||
description="发现喜欢的内容,点击收藏按钮保存"
|
||||
icon="bookmark-heart-outline"
|
||||
variant="modern"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.postsContainer}>
|
||||
{favorites.map((post, index) => {
|
||||
const isPostAuthor = currentUser?.id === post.author?.id;
|
||||
return (
|
||||
<View key={post.id} style={[
|
||||
styles.postWrapper,
|
||||
index === favorites.length - 1 && styles.lastPost,
|
||||
]}>
|
||||
<PostCard
|
||||
post={post}
|
||||
onPress={() => handlePostPress(post.id)}
|
||||
onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}}
|
||||
onLike={() => post.is_liked ? unlikePost(post.id) : likePost(post.id)}
|
||||
onComment={() => handlePostPress(post.id, true)}
|
||||
onBookmark={() => post.is_favorited ? unfavoritePost(post.id) : favoritePost(post.id)}
|
||||
onShare={() => {}}
|
||||
onDelete={() => handleDeletePost(post.id)}
|
||||
isPostAuthor={isPostAuthor}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [loading, activeTab, posts, favorites, currentUser?.id, handlePostPress, handleUserPress, handleDeletePost, unlikePost, likePost, unfavoritePost, favoritePost]);
|
||||
|
||||
// 渲染用户信息头部 - 不随 tab 变化,使用 useMemo 缓存
|
||||
const renderUserHeader = useMemo(() => (
|
||||
<UserProfileHeader
|
||||
user={currentUser!}
|
||||
isCurrentUser={true}
|
||||
onFollow={handleFollow}
|
||||
onSettings={handleSettings}
|
||||
onEditProfile={handleEditProfile}
|
||||
onFollowingPress={handleFollowingPress}
|
||||
onFollowersPress={handleFollowersPress}
|
||||
/>
|
||||
), [currentUser, handleFollow, handleSettings, handleEditProfile, handleFollowingPress, handleFollowersPress]);
|
||||
|
||||
// 渲染 TabBar 和内容
|
||||
const renderTabBarAndContent = useMemo(() => (
|
||||
<>
|
||||
<View style={styles.tabBarContainer}>
|
||||
<TabBar
|
||||
tabs={TABS}
|
||||
activeIndex={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
variant="modern"
|
||||
icons={TAB_ICONS}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.contentContainer}>
|
||||
{renderContent()}
|
||||
</View>
|
||||
</>
|
||||
), [activeTab, renderContent]);
|
||||
|
||||
if (!currentUser) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<EmptyState
|
||||
title="未登录"
|
||||
description="请先登录"
|
||||
icon="account-off-outline"
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
// 桌面端使用双栏布局
|
||||
if (isDesktop || isTablet) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<ResponsiveContainer maxWidth={1400}>
|
||||
<View style={styles.desktopContainer}>
|
||||
{/* 左侧:用户信息 */}
|
||||
<View style={styles.desktopSidebar}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: scrollBottomInset }}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{renderUserHeader}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* 右侧:帖子列表 */}
|
||||
<View style={styles.desktopContent}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={[styles.desktopScrollContent, { paddingBottom: scrollBottomInset }]}
|
||||
>
|
||||
{renderTabBarAndContent}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</ResponsiveContainer>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
// 移动端使用单栏布局
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top', 'bottom']}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
contentContainerStyle={[styles.scrollContent, { paddingBottom: scrollBottomInset }]}
|
||||
>
|
||||
{/* 用户信息头部 - 固定在顶部,不受 tab 切换影响 */}
|
||||
{renderUserHeader}
|
||||
|
||||
{/* TabBar - 分离出来,切换 tab 不会影响上面的用户信息 */}
|
||||
{renderTabBarAndContent}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
scrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
// 桌面端双栏布局
|
||||
desktopContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
gap: spacing.lg,
|
||||
padding: spacing.lg,
|
||||
},
|
||||
desktopSidebar: {
|
||||
width: 380,
|
||||
flexShrink: 0,
|
||||
},
|
||||
desktopContent: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
desktopScrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
tabBarContainer: {
|
||||
marginTop: spacing.xs,
|
||||
marginBottom: 2,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
minHeight: 350,
|
||||
paddingTop: spacing.xs,
|
||||
},
|
||||
postsContainer: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
postWrapper: {
|
||||
marginBottom: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
lastPost: {
|
||||
marginBottom: spacing['2xl'],
|
||||
},
|
||||
});
|
||||
|
||||
export default ProfileScreen;
|
||||
306
src/screens/profile/SettingsScreen.tsx
Normal file
306
src/screens/profile/SettingsScreen.tsx
Normal file
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* 设置页 SettingsScreen(响应式适配)
|
||||
* 胡萝卜BBS - 应用设置
|
||||
* 在宽屏下居中显示,最大宽度限制
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
ScrollView,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import { useAuthStore } from '../../stores';
|
||||
import { Text, ResponsiveContainer } from '../../components/common';
|
||||
import { useResponsive } from '../../hooks';
|
||||
|
||||
interface SettingsItem {
|
||||
key: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
onPress?: () => void;
|
||||
showArrow?: boolean;
|
||||
danger?: boolean;
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
// 设置分组配置
|
||||
const SETTINGS_GROUPS = [
|
||||
{
|
||||
title: '账号与安全',
|
||||
icon: 'shield-check-outline',
|
||||
items: [
|
||||
{ key: 'edit_profile', title: '编辑资料', icon: 'account-edit-outline', showArrow: true },
|
||||
{ key: 'privacy', title: '隐私设置', icon: 'shield-account-outline', showArrow: true },
|
||||
{ key: 'security', title: '账号安全', icon: 'lock-outline', showArrow: true },
|
||||
{ key: 'blocked_users', title: '黑名单', icon: 'account-off-outline', showArrow: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '通知与通用',
|
||||
icon: 'bell-outline',
|
||||
items: [
|
||||
{ key: 'notification_settings', title: '通知设置', icon: 'bell-cog-outline', showArrow: true, subtitle: '推送、震动、提示音' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: '关于与帮助',
|
||||
icon: 'information-outline',
|
||||
items: [
|
||||
{ key: 'about', title: '关于我们', icon: 'information-outline', showArrow: true, subtitle: '版本 1.0.2' },
|
||||
{ key: 'help', title: '帮助与反馈', icon: 'help-circle-outline', showArrow: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const SettingsScreen: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
const { logout } = useAuthStore();
|
||||
const { isWideScreen } = useResponsive();
|
||||
|
||||
// 处理设置项点击
|
||||
const handleItemPress = (key: string) => {
|
||||
switch (key) {
|
||||
case 'edit_profile':
|
||||
navigation.navigate('EditProfile' as never);
|
||||
break;
|
||||
case 'notification_settings':
|
||||
navigation.navigate('NotificationSettings' as never);
|
||||
break;
|
||||
case 'blocked_users':
|
||||
navigation.navigate('BlockedUsers' as never);
|
||||
break;
|
||||
case 'security':
|
||||
navigation.navigate('AccountSecurity' as never);
|
||||
break;
|
||||
case 'logout':
|
||||
Alert.alert(
|
||||
'退出登录',
|
||||
'确定要退出登录吗?',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '确定',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
logout();
|
||||
}
|
||||
},
|
||||
]
|
||||
);
|
||||
break;
|
||||
default:
|
||||
console.log('Settings item pressed:', key);
|
||||
}
|
||||
};
|
||||
|
||||
// 渲染单个设置项
|
||||
const renderSettingItem = (item: SettingsItem, index: number, total: number) => (
|
||||
<TouchableOpacity
|
||||
key={item.key}
|
||||
style={[
|
||||
styles.settingItem,
|
||||
index === 0 && styles.settingItemFirst,
|
||||
index === total - 1 && styles.settingItemLast,
|
||||
]}
|
||||
onPress={() => item.onPress ? item.onPress() : handleItemPress(item.key)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.settingItemLeft}>
|
||||
<View style={[
|
||||
styles.iconContainer,
|
||||
item.danger && styles.dangerIconContainer
|
||||
]}>
|
||||
<MaterialCommunityIcons
|
||||
name={item.icon as any}
|
||||
size={20}
|
||||
color={item.danger ? colors.error.main : colors.primary.main}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.settingContent}>
|
||||
<Text
|
||||
variant="body"
|
||||
color={item.danger ? colors.error.main : colors.text.primary}
|
||||
>
|
||||
{item.title}
|
||||
</Text>
|
||||
{item.subtitle && (
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.subtitle}>
|
||||
{item.subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{item.showArrow && (
|
||||
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
// 渲染分组
|
||||
const renderGroup = (group: typeof SETTINGS_GROUPS[0], groupIndex: number) => (
|
||||
<View key={group.title} style={styles.groupContainer}>
|
||||
<View style={styles.groupHeader}>
|
||||
<MaterialCommunityIcons name={group.icon as any} size={16} color={colors.primary.main} />
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.groupTitle}>
|
||||
{group.title}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.card}>
|
||||
{group.items.map((item, index) =>
|
||||
renderSettingItem(item, index, group.items.length)
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
|
||||
// 退出登录按钮
|
||||
const renderLogoutButton = () => (
|
||||
<TouchableOpacity
|
||||
style={styles.logoutButton}
|
||||
onPress={() => handleItemPress('logout')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="logout-variant" size={20} color={colors.error.main} />
|
||||
<Text variant="body" color={colors.error.main} style={styles.logoutText}>
|
||||
退出登录
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
|
||||
// 渲染内容
|
||||
const renderContent = () => (
|
||||
<>
|
||||
{SETTINGS_GROUPS.map((group, index) => renderGroup(group, index))}
|
||||
{renderLogoutButton()}
|
||||
|
||||
{/* 底部版权信息 */}
|
||||
<View style={styles.footer}>
|
||||
<Text variant="caption" color={colors.text.hint}>
|
||||
萝卜社区 v1.0.2
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.copyright}>
|
||||
© 2024 Carrot BBS. All rights reserved.
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
{isWideScreen ? (
|
||||
<ResponsiveContainer maxWidth={800}>
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
{renderContent()}
|
||||
</ScrollView>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<ScrollView contentContainerStyle={styles.scrollContent}>
|
||||
{renderContent()}
|
||||
</ScrollView>
|
||||
)}
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
scrollContent: {
|
||||
paddingVertical: spacing.md,
|
||||
},
|
||||
groupContainer: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
groupHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
marginBottom: spacing.sm,
|
||||
gap: spacing.xs,
|
||||
},
|
||||
groupTitle: {
|
||||
fontWeight: '600',
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
marginHorizontal: spacing.lg,
|
||||
borderRadius: borderRadius.lg,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
settingItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.divider,
|
||||
},
|
||||
settingItemFirst: {
|
||||
borderTopLeftRadius: borderRadius.lg,
|
||||
borderTopRightRadius: borderRadius.lg,
|
||||
},
|
||||
settingItemLast: {
|
||||
borderBottomLeftRadius: borderRadius.lg,
|
||||
borderBottomRightRadius: borderRadius.lg,
|
||||
borderBottomWidth: 0,
|
||||
},
|
||||
settingItemLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
iconContainer: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: colors.primary.light + '20',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
dangerIconContainer: {
|
||||
backgroundColor: colors.error.light + '20',
|
||||
},
|
||||
settingContent: {
|
||||
flex: 1,
|
||||
},
|
||||
subtitle: {
|
||||
marginTop: 2,
|
||||
},
|
||||
logoutButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
marginHorizontal: spacing.lg,
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.lg,
|
||||
paddingVertical: spacing.md,
|
||||
borderRadius: borderRadius.lg,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
logoutText: {
|
||||
fontWeight: '500',
|
||||
},
|
||||
footer: {
|
||||
alignItems: 'center',
|
||||
marginTop: spacing.xl,
|
||||
paddingBottom: spacing.xl,
|
||||
},
|
||||
copyright: {
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
});
|
||||
|
||||
export default SettingsScreen;
|
||||
558
src/screens/profile/UserScreen.tsx
Normal file
558
src/screens/profile/UserScreen.tsx
Normal file
@@ -0,0 +1,558 @@
|
||||
/**
|
||||
* 用户主页 UserScreen(响应式适配)
|
||||
* 胡萝卜BBS - 查看其他用户资料
|
||||
* 支持桌面端双栏布局
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
FlatList,
|
||||
StyleSheet,
|
||||
RefreshControl,
|
||||
ScrollView,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useNavigation, useRoute, RouteProp } from '@react-navigation/native';
|
||||
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { colors, spacing } from '../../theme';
|
||||
import { Post, User } from '../../types';
|
||||
import { useUserStore } from '../../stores';
|
||||
import { useCurrentUser } from '../../stores/authStore';
|
||||
import { authService, postService, messageService } from '../../services';
|
||||
import { userManager } from '../../stores/userManager';
|
||||
import { UserProfileHeader, PostCard, TabBar } from '../../components/business';
|
||||
import { Loading, EmptyState, ResponsiveContainer } from '../../components/common';
|
||||
import { useResponsive } from '../../hooks';
|
||||
import { HomeStackParamList, RootStackParamList } from '../../navigation/types';
|
||||
|
||||
type NavigationProp = NativeStackNavigationProp<HomeStackParamList, 'UserProfile'>;
|
||||
type UserRouteProp = RouteProp<HomeStackParamList, 'UserProfile'>;
|
||||
|
||||
const TABS = ['帖子', '收藏'];
|
||||
const TAB_ICONS = ['file-document-outline', 'bookmark-outline'];
|
||||
|
||||
export const UserScreen: React.FC = () => {
|
||||
const navigation = useNavigation<NavigationProp>();
|
||||
const route = useRoute<UserRouteProp>();
|
||||
const userId = route.params?.userId || '';
|
||||
|
||||
// 使用 any 类型来访问根导航
|
||||
const rootNavigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
|
||||
const { followUser, unfollowUser, likePost, unlikePost, favoritePost, unfavoritePost, posts: storePosts } = useUserStore();
|
||||
const currentUser = useCurrentUser();
|
||||
|
||||
// 响应式布局
|
||||
const { isDesktop, isTablet } = useResponsive();
|
||||
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [posts, setPosts] = useState<Post[]>([]);
|
||||
const [favorites, setFavorites] = useState<Post[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState(0);
|
||||
const [isBlocked, setIsBlocked] = useState(false);
|
||||
|
||||
// 加载用户信息
|
||||
const loadUserData = useCallback(async (forceRefresh = false) => {
|
||||
if (!userId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 强制从服务器获取最新数据,确保关注状态是最新的
|
||||
const userData = await userManager.getUserById(userId, forceRefresh);
|
||||
setUser(userData || null);
|
||||
const blockStatus = await authService.getBlockStatus(userId);
|
||||
setIsBlocked(blockStatus);
|
||||
|
||||
const response = await postService.getUserPosts(userId);
|
||||
setPosts(response.list);
|
||||
} catch (error) {
|
||||
console.error('加载用户数据失败:', error);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [userId]);
|
||||
|
||||
// 加载用户收藏
|
||||
const loadUserFavorites = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
console.log('[UserScreen] getUserFavorites called, userId:', userId);
|
||||
const response = await postService.getUserFavorites(userId);
|
||||
console.log('[UserScreen] getUserFavorites response:', response);
|
||||
setFavorites(response.list);
|
||||
} catch (error) {
|
||||
console.error('获取用户收藏失败:', error);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
// 监听 tab 切换
|
||||
useEffect(() => {
|
||||
if (activeTab === 1) {
|
||||
loadUserFavorites();
|
||||
}
|
||||
}, [activeTab, loadUserFavorites]);
|
||||
|
||||
useEffect(() => {
|
||||
// 首次加载时强制刷新,确保关注状态是最新的
|
||||
loadUserData(true);
|
||||
}, [loadUserData]);
|
||||
|
||||
// 同步 store 中的帖子状态到本地(用于点赞、收藏等状态更新)
|
||||
useEffect(() => {
|
||||
// 同步帖子列表状态
|
||||
if (posts.length > 0) {
|
||||
let hasChanges = false;
|
||||
const updatedPosts = posts.map(localPost => {
|
||||
const storePost = storePosts.find(sp => sp.id === localPost.id);
|
||||
if (storePost && (
|
||||
storePost.is_liked !== localPost.is_liked ||
|
||||
storePost.is_favorited !== localPost.is_favorited ||
|
||||
storePost.likes_count !== localPost.likes_count ||
|
||||
storePost.favorites_count !== localPost.favorites_count
|
||||
)) {
|
||||
hasChanges = true;
|
||||
return {
|
||||
...localPost,
|
||||
is_liked: storePost.is_liked,
|
||||
is_favorited: storePost.is_favorited,
|
||||
likes_count: storePost.likes_count,
|
||||
favorites_count: storePost.favorites_count,
|
||||
};
|
||||
}
|
||||
return localPost;
|
||||
});
|
||||
|
||||
if (hasChanges) {
|
||||
setPosts(updatedPosts);
|
||||
}
|
||||
}
|
||||
|
||||
// 同步收藏列表状态
|
||||
if (favorites.length > 0) {
|
||||
let hasChanges = false;
|
||||
const updatedFavorites = favorites.map(localPost => {
|
||||
const storePost = storePosts.find(sp => sp.id === localPost.id);
|
||||
if (storePost && (
|
||||
storePost.is_liked !== localPost.is_liked ||
|
||||
storePost.is_favorited !== localPost.is_favorited ||
|
||||
storePost.likes_count !== localPost.likes_count ||
|
||||
storePost.favorites_count !== localPost.favorites_count
|
||||
)) {
|
||||
hasChanges = true;
|
||||
return {
|
||||
...localPost,
|
||||
is_liked: storePost.is_liked,
|
||||
is_favorited: storePost.is_favorited,
|
||||
likes_count: storePost.likes_count,
|
||||
favorites_count: storePost.favorites_count,
|
||||
};
|
||||
}
|
||||
return localPost;
|
||||
});
|
||||
|
||||
if (hasChanges) {
|
||||
setFavorites(updatedFavorites);
|
||||
}
|
||||
}
|
||||
}, [storePosts]);
|
||||
|
||||
// 下拉刷新
|
||||
const onRefresh = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
loadUserData(true);
|
||||
setRefreshing(false);
|
||||
}, [loadUserData]);
|
||||
|
||||
// 关注/取消关注
|
||||
const handleFollow = () => {
|
||||
if (!user) return;
|
||||
if (user.is_following) {
|
||||
unfollowUser(user.id);
|
||||
setUser({ ...user, is_following: false, followers_count: user.followers_count - 1 });
|
||||
} else {
|
||||
followUser(user.id);
|
||||
setUser({ ...user, is_following: true, followers_count: user.followers_count + 1 });
|
||||
}
|
||||
};
|
||||
|
||||
// 跳转到关注列表
|
||||
const handleFollowingPress = () => {
|
||||
(rootNavigation as any).navigate('FollowList', { userId, type: 'following' });
|
||||
};
|
||||
|
||||
// 跳转到粉丝列表
|
||||
const handleFollowersPress = () => {
|
||||
(rootNavigation as any).navigate('FollowList', { userId, type: 'followers' });
|
||||
};
|
||||
|
||||
// 跳转到帖子详情
|
||||
const handlePostPress = (postId: string, scrollToComments: boolean = false) => {
|
||||
navigation.navigate('PostDetail', { postId, scrollToComments });
|
||||
};
|
||||
|
||||
// 跳转到用户主页(这里不做处理)
|
||||
const handleUserPress = (postUserId: string) => {
|
||||
if (postUserId !== userId) {
|
||||
navigation.push('UserProfile', { userId: postUserId });
|
||||
}
|
||||
};
|
||||
|
||||
// 删除帖子
|
||||
const handleDeletePost = async (postId: string) => {
|
||||
try {
|
||||
const success = await postService.deletePost(postId);
|
||||
if (success) {
|
||||
// 从帖子列表中移除
|
||||
setPosts(prev => prev.filter(p => p.id !== postId));
|
||||
// 也从收藏列表中移除(如果存在)
|
||||
setFavorites(prev => prev.filter(p => p.id !== postId));
|
||||
} else {
|
||||
console.error('删除帖子失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除帖子失败:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// 跳转到聊天界面
|
||||
const handleMessage = async () => {
|
||||
if (!user) return;
|
||||
|
||||
try {
|
||||
// 前端只提供对方的用户ID,会话ID由后端生成
|
||||
const conversation = await messageService.createConversation(user.id);
|
||||
if (conversation) {
|
||||
// 跳转到聊天界面 - 使用 rootNavigation 确保在正确的导航栈中
|
||||
(rootNavigation as any).navigate('Chat', {
|
||||
conversationId: conversation.id.toString(),
|
||||
userId: user.id
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建会话失败:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 处理更多按钮点击
|
||||
const handleMore = () => {
|
||||
if (!user) return;
|
||||
|
||||
Alert.alert(
|
||||
'更多操作',
|
||||
undefined,
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: isBlocked ? '取消拉黑' : '拉黑用户',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
Alert.alert(
|
||||
isBlocked ? '确认取消拉黑' : '确认拉黑',
|
||||
isBlocked
|
||||
? '取消拉黑后,对方可以重新与你建立关系。'
|
||||
: '拉黑后,对方将无法给你发送私聊消息,且会互相移除关注关系。',
|
||||
[
|
||||
{ text: '取消', style: 'cancel' },
|
||||
{
|
||||
text: '确定',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
const ok = isBlocked
|
||||
? await authService.unblockUser(user.id)
|
||||
: await authService.blockUser(user.id);
|
||||
if (!ok) {
|
||||
Alert.alert('失败', isBlocked ? '取消拉黑失败,请稍后重试' : '拉黑失败,请稍后重试');
|
||||
return;
|
||||
}
|
||||
setUser(prev =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
is_following: false,
|
||||
is_following_me: false,
|
||||
}
|
||||
: prev
|
||||
);
|
||||
setIsBlocked(!isBlocked);
|
||||
Alert.alert('成功', isBlocked ? '已取消拉黑' : '已拉黑该用户');
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染内容
|
||||
const renderContent = () => {
|
||||
if (loading) return <Loading />;
|
||||
|
||||
if (activeTab === 0) {
|
||||
// 帖子
|
||||
if (posts.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="还没有帖子"
|
||||
description="这个用户还没有发布任何帖子"
|
||||
icon="file-document-edit-outline"
|
||||
variant="modern"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.postsContainer}>
|
||||
{posts.map((post, index) => {
|
||||
const isPostAuthor = currentUser?.id === post.author?.id;
|
||||
return (
|
||||
<View key={post.id} style={[
|
||||
styles.postWrapper,
|
||||
index === posts.length - 1 && styles.lastPost,
|
||||
]}>
|
||||
<PostCard
|
||||
post={post}
|
||||
onPress={() => handlePostPress(post.id)}
|
||||
onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}}
|
||||
onLike={() => post.is_liked ? unlikePost(post.id) : likePost(post.id)}
|
||||
onComment={() => handlePostPress(post.id, true)}
|
||||
onBookmark={() => post.is_favorited ? unfavoritePost(post.id) : favoritePost(post.id)}
|
||||
onShare={() => {}}
|
||||
onDelete={() => handleDeletePost(post.id)}
|
||||
isPostAuthor={isPostAuthor}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (activeTab === 1) {
|
||||
// 收藏
|
||||
if (favorites.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="还没有收藏"
|
||||
description="这个用户还没有收藏任何帖子"
|
||||
icon="bookmark-heart-outline"
|
||||
variant="modern"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.postsContainer}>
|
||||
{favorites.map((post, index) => {
|
||||
const isPostAuthor = currentUser?.id === post.author?.id;
|
||||
return (
|
||||
<View key={post.id} style={[
|
||||
styles.postWrapper,
|
||||
index === favorites.length - 1 && styles.lastPost,
|
||||
]}>
|
||||
<PostCard
|
||||
post={post}
|
||||
onPress={() => handlePostPress(post.id)}
|
||||
onUserPress={() => post.author ? handleUserPress(post.author.id) : () => {}}
|
||||
onLike={() => post.is_liked ? unlikePost(post.id) : likePost(post.id)}
|
||||
onComment={() => handlePostPress(post.id, true)}
|
||||
onBookmark={() => post.is_favorited ? unfavoritePost(post.id) : favoritePost(post.id)}
|
||||
onShare={() => {}}
|
||||
onDelete={() => handleDeletePost(post.id)}
|
||||
isPostAuthor={isPostAuthor}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// 渲染用户信息头部
|
||||
const renderUserHeader = () => {
|
||||
if (!user) return null;
|
||||
return (
|
||||
<UserProfileHeader
|
||||
user={user}
|
||||
isCurrentUser={false}
|
||||
onFollow={handleFollow}
|
||||
onMessage={handleMessage}
|
||||
onMore={handleMore}
|
||||
onFollowingPress={handleFollowingPress}
|
||||
onFollowersPress={handleFollowersPress}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染 TabBar 和内容
|
||||
const renderTabBarAndContent = () => (
|
||||
<>
|
||||
<View style={styles.tabBarContainer}>
|
||||
<TabBar
|
||||
tabs={TABS}
|
||||
activeIndex={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
variant="modern"
|
||||
icons={TAB_ICONS}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.contentContainer}>
|
||||
{renderContent()}
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <Loading fullScreen />;
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container}>
|
||||
<EmptyState
|
||||
title="用户不存在"
|
||||
description="该用户可能已被删除"
|
||||
icon="account-off-outline"
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
// 桌面端使用双栏布局
|
||||
if (isDesktop || isTablet) {
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<ResponsiveContainer maxWidth={1400}>
|
||||
<View style={styles.desktopContainer}>
|
||||
{/* 左侧:用户信息 */}
|
||||
<View style={styles.desktopSidebar}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{renderUserHeader()}
|
||||
</ScrollView>
|
||||
</View>
|
||||
|
||||
{/* 右侧:帖子列表 */}
|
||||
<View style={styles.desktopContent}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.desktopScrollContent}
|
||||
>
|
||||
{renderTabBarAndContent()}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</ResponsiveContainer>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
// 移动端使用单栏布局
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<FlatList
|
||||
data={[{ key: 'header' }]}
|
||||
renderItem={({ item }) => (
|
||||
<View>
|
||||
{renderUserHeader()}
|
||||
<View style={styles.tabBarContainer}>
|
||||
<TabBar
|
||||
tabs={TABS}
|
||||
activeIndex={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
variant="modern"
|
||||
icons={TAB_ICONS}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.contentContainer}>
|
||||
{renderContent()}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
keyExtractor={item => item.key}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={refreshing}
|
||||
onRefresh={onRefresh}
|
||||
colors={[colors.primary.main]}
|
||||
tintColor={colors.primary.main}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
// 桌面端双栏布局
|
||||
desktopContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
gap: spacing.lg,
|
||||
padding: spacing.lg,
|
||||
},
|
||||
desktopSidebar: {
|
||||
width: 380,
|
||||
flexShrink: 0,
|
||||
},
|
||||
desktopContent: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
desktopScrollContent: {
|
||||
flexGrow: 1,
|
||||
},
|
||||
tabBarContainer: {
|
||||
marginTop: spacing.sm,
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1,
|
||||
minHeight: 350,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
postsContainer: {
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingTop: spacing.sm,
|
||||
},
|
||||
postWrapper: {
|
||||
marginBottom: spacing.md,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.06,
|
||||
shadowRadius: 8,
|
||||
elevation: 2,
|
||||
},
|
||||
lastPost: {
|
||||
marginBottom: spacing['2xl'],
|
||||
},
|
||||
});
|
||||
|
||||
export default UserScreen;
|
||||
12
src/screens/profile/index.ts
Normal file
12
src/screens/profile/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 个人中心模块导出
|
||||
*/
|
||||
|
||||
export { ProfileScreen } from './ProfileScreen';
|
||||
export { SettingsScreen } from './SettingsScreen';
|
||||
export { EditProfileScreen } from './EditProfileScreen';
|
||||
export { UserScreen } from './UserScreen';
|
||||
export { default as FollowListScreen } from './FollowListScreen';
|
||||
export { NotificationSettingsScreen } from './NotificationSettingsScreen';
|
||||
export { BlockedUsersScreen } from './BlockedUsersScreen';
|
||||
export { AccountSecurityScreen } from './AccountSecurityScreen';
|
||||
55
src/services/alertOverride.ts
Normal file
55
src/services/alertOverride.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Alert } from 'react-native';
|
||||
import type { AlertButton, AlertOptions } from 'react-native';
|
||||
|
||||
import { showDialog } from './dialogService';
|
||||
import { showPrompt } from './promptService';
|
||||
|
||||
let installed = false;
|
||||
|
||||
const resolvePromptType = (title?: string) => {
|
||||
const source = title || '';
|
||||
if (source.includes('成功') || source.includes('完成') || source.includes('已')) return 'success' as const;
|
||||
if (source.includes('失败') || source.includes('错误') || source.includes('无法')) return 'error' as const;
|
||||
if (source.includes('请稍候') || source.includes('提示') || source.includes('权限')) return 'warning' as const;
|
||||
return 'info' as const;
|
||||
};
|
||||
|
||||
export const installAlertOverride = () => {
|
||||
if (installed) return;
|
||||
installed = true;
|
||||
|
||||
const nativeAlert = Alert.alert.bind(Alert);
|
||||
|
||||
Alert.alert = (
|
||||
title?: string,
|
||||
message?: string,
|
||||
buttons?: AlertButton[],
|
||||
options?: AlertOptions
|
||||
) => {
|
||||
const nextTitle = title || '提示';
|
||||
const nextMessage = message || '';
|
||||
const actionButtons = buttons ?? [];
|
||||
|
||||
if (actionButtons.length === 0) {
|
||||
showPrompt({
|
||||
title: nextTitle,
|
||||
message: nextMessage || nextTitle,
|
||||
type: resolvePromptType(nextTitle),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (actionButtons.length <= 3) {
|
||||
showDialog({
|
||||
title: nextTitle,
|
||||
message: nextMessage,
|
||||
actions: actionButtons,
|
||||
options,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 极少数复杂按钮场景,保底回退原生 Alert
|
||||
nativeAlert(nextTitle, nextMessage, buttons, options);
|
||||
};
|
||||
};
|
||||
316
src/services/api.ts
Normal file
316
src/services/api.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* API 客户端
|
||||
* 使用 fetch API 进行 HTTP 请求
|
||||
* 支持请求/响应拦截器
|
||||
*/
|
||||
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { CommonActions } from '@react-navigation/native';
|
||||
import Constants from 'expo-constants';
|
||||
|
||||
|
||||
const getBaseUrl = () => {
|
||||
const configuredBaseUrl = Constants.expoConfig?.extra?.apiBaseUrl;
|
||||
if (typeof configuredBaseUrl === 'string' && configuredBaseUrl.trim().length > 0) {
|
||||
return configuredBaseUrl;
|
||||
}
|
||||
return 'https://bbs.littlelan.cn/api/v1';
|
||||
};
|
||||
|
||||
const getWsUrl = () => {
|
||||
const configuredWsUrl = Constants.expoConfig?.extra?.wsUrl;
|
||||
if (typeof configuredWsUrl === 'string' && configuredWsUrl.trim().length > 0) {
|
||||
return configuredWsUrl;
|
||||
}
|
||||
return 'wss://bbs.littlelan.cn/ws';
|
||||
};
|
||||
|
||||
const BASE_URL = getBaseUrl();
|
||||
const WS_URL = getWsUrl();
|
||||
|
||||
// Token 存储键
|
||||
const TOKEN_KEY = 'auth_token';
|
||||
const REFRESH_TOKEN_KEY = 'refresh_token';
|
||||
|
||||
// API 响应格式
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
// 分页响应格式
|
||||
export interface PaginatedData<T> {
|
||||
list: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
// 错误类型
|
||||
export class ApiError extends Error {
|
||||
code: number;
|
||||
message: string;
|
||||
|
||||
constructor(code: number, message: string) {
|
||||
super(message);
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
this.name = 'ApiError';
|
||||
}
|
||||
}
|
||||
|
||||
// API 客户端类
|
||||
class ApiClient {
|
||||
private baseUrl: string;
|
||||
private navigation: any = null;
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
|
||||
// 设置导航对象(用于处理401跳转)
|
||||
setNavigation(navigation: any) {
|
||||
this.navigation = navigation;
|
||||
}
|
||||
|
||||
// 获取 token
|
||||
async getToken(): Promise<string | null> {
|
||||
try {
|
||||
return await AsyncStorage.getItem(TOKEN_KEY);
|
||||
} catch (error) {
|
||||
console.error('获取token失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存 token
|
||||
async setToken(token: string): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.setItem(TOKEN_KEY, token);
|
||||
} catch (error) {
|
||||
console.error('保存token失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取刷新 token
|
||||
async getRefreshToken(): Promise<string | null> {
|
||||
try {
|
||||
return await AsyncStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
} catch (error) {
|
||||
console.error('获取刷新token失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 保存刷新 token
|
||||
async setRefreshToken(token: string): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.setItem(REFRESH_TOKEN_KEY, token);
|
||||
} catch (error) {
|
||||
console.error('保存刷新token失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 清除 token
|
||||
async clearToken(): Promise<void> {
|
||||
try {
|
||||
await AsyncStorage.multiRemove([TOKEN_KEY, REFRESH_TOKEN_KEY]);
|
||||
} catch (error) {
|
||||
console.error('清除token失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 统一请求方法
|
||||
private async request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
params?: Record<string, any>,
|
||||
body?: any
|
||||
): Promise<ApiResponse<T>> {
|
||||
const url = new URL(`${this.baseUrl}${path}`);
|
||||
|
||||
// 添加查询参数
|
||||
if (params) {
|
||||
Object.keys(params).forEach(key => {
|
||||
if (params[key] !== undefined && params[key] !== null) {
|
||||
url.searchParams.append(key, String(params[key]));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 构建请求头
|
||||
const headers: HeadersInit = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
// 添加认证 token
|
||||
const token = await this.getToken();
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// 构建请求配置
|
||||
const config: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
};
|
||||
|
||||
// 添加请求体
|
||||
if (body && method !== 'GET') {
|
||||
config.body = JSON.stringify(body);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), config);
|
||||
|
||||
// 处理 401 未授权
|
||||
if (response.status === 401) {
|
||||
// 尝试刷新 token
|
||||
const refreshed = await this.refreshToken();
|
||||
if (!refreshed) {
|
||||
// 刷新失败,清除 token 并跳转登录
|
||||
await this.clearToken();
|
||||
if (this.navigation) {
|
||||
this.navigation.dispatch(
|
||||
CommonActions.reset({
|
||||
index: 0,
|
||||
routes: [{ name: 'Login' }],
|
||||
})
|
||||
);
|
||||
}
|
||||
throw new ApiError(401, '登录已过期,请重新登录');
|
||||
}
|
||||
|
||||
// 刷新成功后重试请求
|
||||
return this.request(method, path, params, body);
|
||||
}
|
||||
|
||||
// 解析响应
|
||||
const data: ApiResponse<T> = await response.json();
|
||||
|
||||
// 处理业务错误
|
||||
if (data.code !== 0) {
|
||||
throw new ApiError(data.code, data.message);
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
// 如果是 ApiError,直接抛出
|
||||
if (error instanceof ApiError) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// 网络错误
|
||||
console.error('API请求失败:', error);
|
||||
throw new ApiError(500, '网络请求失败,请检查网络连接');
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新 token
|
||||
private async refreshToken(): Promise<boolean> {
|
||||
try {
|
||||
const refreshToken = await this.getRefreshToken();
|
||||
if (!refreshToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${refreshToken}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.code === 0 && data.data?.token) {
|
||||
await this.setToken(data.data.token);
|
||||
// 后端返回refresh_token (snake_case),兼容两种命名
|
||||
if (data.data.refresh_token || data.data.refreshToken) {
|
||||
await this.setRefreshToken(data.data.refresh_token || data.data.refreshToken);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('刷新token失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// GET 请求
|
||||
async get<T>(path: string, params?: Record<string, any>): Promise<ApiResponse<T>> {
|
||||
return this.request<T>('GET', path, params);
|
||||
}
|
||||
|
||||
// POST 请求
|
||||
async post<T>(path: string, body?: any): Promise<ApiResponse<T>> {
|
||||
return this.request<T>('POST', path, undefined, body);
|
||||
}
|
||||
|
||||
// PUT 请求
|
||||
async put<T>(path: string, body?: any): Promise<ApiResponse<T>> {
|
||||
return this.request<T>('PUT', path, undefined, body);
|
||||
}
|
||||
|
||||
// DELETE 请求
|
||||
async delete<T>(path: string, body?: any): Promise<ApiResponse<T>> {
|
||||
return this.request<T>('DELETE', path, undefined, body);
|
||||
}
|
||||
|
||||
// 上传文件
|
||||
async upload<T>(
|
||||
path: string,
|
||||
file: { uri: string; name: string; type: string },
|
||||
additionalData?: Record<string, string>
|
||||
): Promise<ApiResponse<T>> {
|
||||
const formData = new FormData();
|
||||
|
||||
// 添加文件(使用image字段名)
|
||||
formData.append('image', {
|
||||
uri: file.uri,
|
||||
name: file.name,
|
||||
type: file.type,
|
||||
} as any);
|
||||
|
||||
// 添加额外数据
|
||||
if (additionalData) {
|
||||
Object.keys(additionalData).forEach(key => {
|
||||
formData.append(key, additionalData[key]);
|
||||
});
|
||||
}
|
||||
|
||||
const token = await this.getToken();
|
||||
const headers: HeadersInit = {};
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data: ApiResponse<T> = await response.json();
|
||||
|
||||
if (data.code !== 0) {
|
||||
throw new ApiError(data.code, data.message);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出 API 客户端实例
|
||||
export const api = new ApiClient(BASE_URL);
|
||||
|
||||
// 导出 WebSocket URL
|
||||
export { WS_URL, TOKEN_KEY, REFRESH_TOKEN_KEY };
|
||||
436
src/services/authService.ts
Normal file
436
src/services/authService.ts
Normal file
@@ -0,0 +1,436 @@
|
||||
/**
|
||||
* 认证服务
|
||||
* 处理用户登录、注册、登出等认证功能
|
||||
*/
|
||||
|
||||
import { api } from './api';
|
||||
import { User } from '../types';
|
||||
|
||||
export function resolveAuthApiError(error: any, fallback = '操作失败,请稍后重试'): string {
|
||||
const code: number = error?.code ?? 0;
|
||||
const msg: string = String(error?.message ?? '').toLowerCase();
|
||||
|
||||
if (msg.includes('network request failed') || msg.includes('failed to fetch')) {
|
||||
return '网络连接失败,请检查网络后重试';
|
||||
}
|
||||
if (code === 429 || msg.includes('too frequently')) {
|
||||
return '验证码发送过于频繁,请稍后再试';
|
||||
}
|
||||
if (msg.includes('verification code expired')) {
|
||||
return '验证码已过期,请重新获取';
|
||||
}
|
||||
if (msg.includes('invalid verification code')) {
|
||||
return '验证码错误,请重新输入';
|
||||
}
|
||||
if (msg.includes('email already exists')) {
|
||||
return '该邮箱已被注册,请直接登录';
|
||||
}
|
||||
if (msg.includes('email not bound')) {
|
||||
return '当前账号未绑定邮箱,请先完成邮箱绑定验证';
|
||||
}
|
||||
if (msg.includes('user not found')) {
|
||||
return '该邮箱尚未注册';
|
||||
}
|
||||
if (msg.includes('email service unavailable')) {
|
||||
return '邮件服务暂不可用,请稍后再试';
|
||||
}
|
||||
if (msg.includes('invalid email')) {
|
||||
return '邮箱格式不正确';
|
||||
}
|
||||
if (msg.includes('invalid username or password')) {
|
||||
return '账号或密码错误';
|
||||
}
|
||||
if (typeof error?.message === 'string' && error.message.trim().length > 0) {
|
||||
return error.message;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
// 登录请求参数
|
||||
export interface LoginRequest {
|
||||
username?: string;
|
||||
account?: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
// 注册请求参数
|
||||
export interface RegisterRequest {
|
||||
username: string;
|
||||
password: string;
|
||||
nickname: string;
|
||||
email: string;
|
||||
verification_code: string;
|
||||
phone?: string;
|
||||
}
|
||||
|
||||
export interface SendEmailCodeRequest {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface ResetPasswordRequest {
|
||||
email: string;
|
||||
verification_code: string;
|
||||
new_password: string;
|
||||
}
|
||||
|
||||
export interface VerifyCurrentUserEmailRequest {
|
||||
email: string;
|
||||
verification_code: string;
|
||||
}
|
||||
|
||||
// 认证响应
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
refreshToken?: string;
|
||||
refresh_token?: string; // 兼容后端的snake_case
|
||||
user: User;
|
||||
}
|
||||
|
||||
// 刷新Token响应
|
||||
export interface RefreshTokenResponse {
|
||||
token: string;
|
||||
refreshToken?: string;
|
||||
refresh_token?: string; // 兼容后端的snake_case
|
||||
}
|
||||
|
||||
// 用户信息响应 - API直接返回User对象,不需要包装
|
||||
export type UserResponse = User;
|
||||
|
||||
// 更新用户信息请求
|
||||
export interface UpdateUserRequest {
|
||||
nickname?: string;
|
||||
avatar?: string;
|
||||
bio?: string;
|
||||
website?: string;
|
||||
location?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export interface BlockedUserListResponse {
|
||||
list: User[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
total_pages: number;
|
||||
}
|
||||
|
||||
export interface BlockStatusResponse {
|
||||
is_blocked: boolean;
|
||||
}
|
||||
|
||||
// 认证服务类
|
||||
// 职责:纯 HTTP 层 + Token 管理,不涉及 SQLite
|
||||
// SQLite 的读写编排由各领域 Manager/store 统一负责
|
||||
class AuthService {
|
||||
// ── 内部辅助:从服务器获取用户 ──
|
||||
private async refreshCurrentUserFromServer(): Promise<User | null> {
|
||||
try {
|
||||
const response = await api.get<UserResponse>('/users/me');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[AuthService] 获取当前用户失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async refreshUserByIdFromServer(userId: string): Promise<User | null> {
|
||||
try {
|
||||
const response = await api.get<UserResponse>(`/users/${userId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[AuthService] 获取用户信息失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 公开 API:仅做 HTTP 请求 + Token 存储,不碰 SQLite ──
|
||||
|
||||
// 用户登录(只管 API + Token,DB 操作交给 authStore)
|
||||
async login(data: LoginRequest): Promise<AuthResponse> {
|
||||
const response = await api.post<AuthResponse>('/auth/login', data);
|
||||
|
||||
if (response.data.token) {
|
||||
await api.setToken(response.data.token);
|
||||
}
|
||||
const refreshToken = response.data.refresh_token || response.data.refreshToken;
|
||||
if (refreshToken) {
|
||||
await api.setRefreshToken(refreshToken);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 用户注册(只管 API + Token,DB 操作交给 authStore)
|
||||
async register(data: RegisterRequest): Promise<AuthResponse> {
|
||||
const response = await api.post<AuthResponse>('/auth/register', data);
|
||||
|
||||
if (response.data.token) {
|
||||
await api.setToken(response.data.token);
|
||||
}
|
||||
const refreshToken = response.data.refresh_token || response.data.refreshToken;
|
||||
if (refreshToken) {
|
||||
await api.setRefreshToken(refreshToken);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// 发送注册验证码
|
||||
async sendRegisterCode(email: string): Promise<boolean> {
|
||||
const response = await api.post<{ success: boolean }>('/auth/register/send-code', { email } as SendEmailCodeRequest);
|
||||
return response.code === 0;
|
||||
}
|
||||
|
||||
// 发送找回密码验证码
|
||||
async sendPasswordResetCode(email: string): Promise<boolean> {
|
||||
const response = await api.post<{ success: boolean }>('/auth/password/send-code', { email } as SendEmailCodeRequest);
|
||||
return response.code === 0;
|
||||
}
|
||||
|
||||
// 找回密码重置
|
||||
async resetPassword(data: ResetPasswordRequest): Promise<boolean> {
|
||||
const response = await api.post<{ success: boolean }>('/auth/password/reset', data);
|
||||
return response.code === 0;
|
||||
}
|
||||
|
||||
// 已登录用户发送邮箱验证验证码
|
||||
async sendCurrentUserEmailVerifyCode(email: string): Promise<boolean> {
|
||||
const response = await api.post<{ success: boolean }>('/users/me/email/send-code', { email } as SendEmailCodeRequest);
|
||||
return response.code === 0;
|
||||
}
|
||||
|
||||
// 已登录用户提交邮箱验证码
|
||||
async verifyCurrentUserEmail(data: VerifyCurrentUserEmailRequest): Promise<boolean> {
|
||||
const response = await api.post<{ success: boolean }>('/users/me/email/verify', data);
|
||||
return response.code === 0;
|
||||
}
|
||||
|
||||
// 用户登出(只管 API + Token,DB/缓存清理交给 authStore)
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
await api.post('/auth/logout');
|
||||
} catch (error) {
|
||||
console.error('[AuthService] 登出 API 失败:', error);
|
||||
} finally {
|
||||
await api.clearToken();
|
||||
}
|
||||
}
|
||||
|
||||
// 纯 API 获取当前用户(无任何 DB 依赖,供冷启动校验 Token 时使用)
|
||||
async fetchCurrentUserFromAPI(): Promise<User | null> {
|
||||
try {
|
||||
const response = await api.get<UserResponse>('/users/me');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[AuthService] fetchCurrentUserFromAPI 失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 刷新Token
|
||||
async refreshToken(): Promise<RefreshTokenResponse | null> {
|
||||
try {
|
||||
const response = await api.post<RefreshTokenResponse>('/auth/refresh');
|
||||
|
||||
if (response.data.token) {
|
||||
await api.setToken(response.data.token);
|
||||
}
|
||||
if (response.data.refreshToken) {
|
||||
await api.setRefreshToken(response.data.refreshToken);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('刷新Token失败:', error);
|
||||
await api.clearToken();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取当前用户信息(兼容旧调用,始终走服务端)
|
||||
async getCurrentUser(): Promise<User | null> {
|
||||
return this.refreshCurrentUserFromServer();
|
||||
}
|
||||
|
||||
// 强制从服务器获取当前用户信息(别名,保留兼容)
|
||||
async fetchCurrentUserFresh(): Promise<User | null> {
|
||||
return this.fetchCurrentUserFromAPI();
|
||||
}
|
||||
|
||||
async fetchUserByIdFromAPI(userId: string): Promise<User | null> {
|
||||
return this.refreshUserByIdFromServer(userId);
|
||||
}
|
||||
|
||||
// 获取用户信息(根据用户ID)
|
||||
async getUserById(userId: string, forceRefresh = false): Promise<User | null> {
|
||||
void forceRefresh; // 兼容旧签名:缓存由 userManager 统一编排
|
||||
return this.refreshUserByIdFromServer(userId);
|
||||
}
|
||||
|
||||
// 更新用户信息
|
||||
async updateUser(data: UpdateUserRequest): Promise<User | null> {
|
||||
try {
|
||||
const response = await api.put<UserResponse>('/users/me', data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('更新用户信息失败:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 修改密码
|
||||
async sendChangePasswordCode(): Promise<boolean> {
|
||||
const response = await api.post<{ success: boolean }>('/users/change-password/send-code');
|
||||
return response.code === 0;
|
||||
}
|
||||
|
||||
// 修改密码(需要邮箱验证码)
|
||||
async changePassword(oldPassword: string, newPassword: string, verificationCode: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await api.post('/users/change-password', {
|
||||
old_password: oldPassword,
|
||||
new_password: newPassword,
|
||||
verification_code: verificationCode,
|
||||
});
|
||||
return response.code === 0;
|
||||
} catch (error) {
|
||||
console.error('修改密码失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 检查用户名是否可用
|
||||
async checkUsernameAvailable(username: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await api.get<{ available: boolean }>('/auth/check-username', {
|
||||
username,
|
||||
});
|
||||
return response.data.available;
|
||||
} catch (error) {
|
||||
console.error('检查用户名失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 关注用户
|
||||
async followUser(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await api.post(`/users/${userId}/follow`);
|
||||
return response.code === 0;
|
||||
} catch (error) {
|
||||
console.error('关注用户失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 取消关注用户
|
||||
async unfollowUser(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await api.delete(`/users/${userId}/follow`);
|
||||
return response.code === 0;
|
||||
} catch (error) {
|
||||
console.error('取消关注用户失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户关注列表
|
||||
async getFollowingList(userId: string, page = 1, pageSize = 20): Promise<User[]> {
|
||||
try {
|
||||
const response = await api.get<{ list: User[] }>(`/users/${userId}/following`, {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
return response.data.list;
|
||||
} catch (error) {
|
||||
console.error('获取关注列表失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 获取用户粉丝列表
|
||||
async getFollowersList(userId: string, page = 1, pageSize = 20): Promise<User[]> {
|
||||
try {
|
||||
const response = await api.get<{ list: User[] }>(`/users/${userId}/followers`, {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
return response.data.list;
|
||||
} catch (error) {
|
||||
console.error('获取粉丝列表失败:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 拉黑用户
|
||||
async blockUser(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await api.post(`/users/${userId}/block`);
|
||||
return response.code === 0;
|
||||
} catch (error) {
|
||||
console.error('拉黑用户失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 取消拉黑
|
||||
async unblockUser(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await api.delete(`/users/${userId}/block`);
|
||||
return response.code === 0;
|
||||
} catch (error) {
|
||||
console.error('取消拉黑失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 获取黑名单
|
||||
async getBlockedUsers(page = 1, pageSize = 20): Promise<BlockedUserListResponse> {
|
||||
try {
|
||||
const response = await api.get<BlockedUserListResponse>('/users/blocks', {
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('获取黑名单失败:', error);
|
||||
return {
|
||||
list: [],
|
||||
total: 0,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
total_pages: 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 获取拉黑状态
|
||||
async getBlockStatus(userId: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await api.get<BlockStatusResponse>(`/users/${userId}/block-status`);
|
||||
return !!response.data.is_blocked;
|
||||
} catch (error) {
|
||||
console.error('获取拉黑状态失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索用户
|
||||
async searchUsers(keyword: string, page = 1, pageSize = 20): Promise<{ list: User[]; total: number }> {
|
||||
try {
|
||||
const response = await api.get<{ list: User[]; total: number }>('/users/search', {
|
||||
keyword,
|
||||
page,
|
||||
page_size: pageSize,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('搜索用户失败:', error);
|
||||
return { list: [], total: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出认证服务实例
|
||||
export const authService = new AuthService();
|
||||
319
src/services/backgroundService.ts
Normal file
319
src/services/backgroundService.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* 后台保活服务
|
||||
* 使用 expo-background-fetch 和 expo-task-manager 实现 App 后台保活
|
||||
*
|
||||
* 功能:
|
||||
* - 定期后台任务保持 App 在内存中
|
||||
* - 配合 WebSocket 服务保持长连接
|
||||
* - 收到消息时触发震动提示
|
||||
*/
|
||||
|
||||
import { AppState, AppStateStatus, Platform } from 'react-native';
|
||||
import * as BackgroundFetch from 'expo-background-fetch';
|
||||
import * as TaskManager from 'expo-task-manager';
|
||||
import * as Haptics from 'expo-haptics';
|
||||
import { websocketService } from './websocketService';
|
||||
|
||||
// 后台任务名称
|
||||
const BACKGROUND_FETCH_TASK = 'background-fetch-keepalive';
|
||||
const WEBSOCKET_KEEPALIVE_TASK = 'websocket-keepalive';
|
||||
|
||||
// 后台任务间隔(Android 最小 15 分钟,iOS 最小 15 分钟)
|
||||
const BACKGROUND_INTERVAL = 15; // 15 分钟
|
||||
|
||||
// 震动配置
|
||||
interface VibrationConfig {
|
||||
enabled: boolean; // 是否启用震动
|
||||
onChatMessage: boolean; // 私聊消息震动
|
||||
onGroupMessage: boolean; // 群聊消息震动
|
||||
onNotification: boolean; // 系统通知震动
|
||||
style: Haptics.ImpactFeedbackStyle; // 震动样式
|
||||
}
|
||||
|
||||
// 默认震动配置
|
||||
const defaultVibrationConfig: VibrationConfig = {
|
||||
enabled: true,
|
||||
onChatMessage: true,
|
||||
onGroupMessage: true,
|
||||
onNotification: true,
|
||||
style: Haptics.ImpactFeedbackStyle.Light,
|
||||
};
|
||||
|
||||
// 后台服务状态
|
||||
let isInitialized = false;
|
||||
let vibrationConfig: VibrationConfig = { ...defaultVibrationConfig };
|
||||
let appStateSubscription: any = null;
|
||||
|
||||
// 定义后台任务
|
||||
TaskManager.defineTask(BACKGROUND_FETCH_TASK, async () => {
|
||||
console.log('[BackgroundService] 后台任务执行中...');
|
||||
|
||||
try {
|
||||
// 检查 WebSocket 连接状态
|
||||
if (!websocketService.isConnected()) {
|
||||
console.log('[BackgroundService] WebSocket 断开,尝试重连');
|
||||
await websocketService.connect();
|
||||
}
|
||||
|
||||
// 返回收到新数据
|
||||
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||
} catch (error) {
|
||||
console.error('[BackgroundService] 后台任务执行失败:', error);
|
||||
return BackgroundFetch.BackgroundFetchResult.Failed;
|
||||
}
|
||||
});
|
||||
|
||||
// WebSocket 保活任务
|
||||
TaskManager.defineTask(WEBSOCKET_KEEPALIVE_TASK, async () => {
|
||||
console.log('[BackgroundService] WebSocket 保活任务执行...');
|
||||
|
||||
try {
|
||||
if (!websocketService.isConnected()) {
|
||||
console.log('[BackgroundService] WebSocket 断开,尝试重连');
|
||||
await websocketService.connect();
|
||||
}
|
||||
return BackgroundFetch.BackgroundFetchResult.NewData;
|
||||
} catch (error) {
|
||||
console.error('[BackgroundService] WebSocket 保活失败:', error);
|
||||
return BackgroundFetch.BackgroundFetchResult.Failed;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 触发震动反馈
|
||||
* @param type 震动类型
|
||||
*/
|
||||
export async function triggerVibration(
|
||||
type: 'light' | 'medium' | 'heavy' | 'success' | 'warning' | 'error' = 'light'
|
||||
): Promise<void> {
|
||||
if (!vibrationConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
switch (type) {
|
||||
case 'light':
|
||||
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
break;
|
||||
case 'medium':
|
||||
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
|
||||
break;
|
||||
case 'heavy':
|
||||
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Heavy);
|
||||
break;
|
||||
case 'success':
|
||||
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
||||
break;
|
||||
case 'warning':
|
||||
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Warning);
|
||||
break;
|
||||
case 'error':
|
||||
await Haptics.notificationAsync(Haptics.NotificationFeedbackType.Error);
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BackgroundService] 震动反馈失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 收到消息时触发震动
|
||||
* @param messageType 消息类型
|
||||
*/
|
||||
export async function vibrateOnMessage(messageType: 'chat' | 'group_message' | 'notification'): Promise<void> {
|
||||
if (!vibrationConfig.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (messageType) {
|
||||
case 'chat':
|
||||
if (vibrationConfig.onChatMessage) {
|
||||
await triggerVibration('light');
|
||||
}
|
||||
break;
|
||||
case 'group_message':
|
||||
if (vibrationConfig.onGroupMessage) {
|
||||
await triggerVibration('light');
|
||||
}
|
||||
break;
|
||||
case 'notification':
|
||||
if (vibrationConfig.onNotification) {
|
||||
await triggerVibration('medium');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置震动设置
|
||||
*/
|
||||
export function setVibrationConfig(config: Partial<VibrationConfig>): void {
|
||||
vibrationConfig = { ...vibrationConfig, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前震动配置
|
||||
*/
|
||||
export function getVibrationConfig(): VibrationConfig {
|
||||
return { ...vibrationConfig };
|
||||
}
|
||||
|
||||
/**
|
||||
* 启用/禁用震动
|
||||
*/
|
||||
export function setVibrationEnabled(enabled: boolean): void {
|
||||
vibrationConfig.enabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注册后台任务
|
||||
*/
|
||||
async function registerBackgroundTasks(): Promise<void> {
|
||||
try {
|
||||
// 检查是否已注册
|
||||
const isRegistered = await TaskManager.isTaskRegisteredAsync(BACKGROUND_FETCH_TASK);
|
||||
if (!isRegistered) {
|
||||
await BackgroundFetch.registerTaskAsync(BACKGROUND_FETCH_TASK, {
|
||||
minimumInterval: BACKGROUND_INTERVAL * 60, // 转换为秒
|
||||
stopOnTerminate: false, // App 终止后继续运行
|
||||
startOnBoot: true, // 设备启动后自动运行
|
||||
});
|
||||
console.log('[BackgroundService] 后台任务注册成功');
|
||||
}
|
||||
|
||||
// 注册 WebSocket 保活任务
|
||||
const isWsKeepaliveRegistered = await TaskManager.isTaskRegisteredAsync(WEBSOCKET_KEEPALIVE_TASK);
|
||||
if (!isWsKeepaliveRegistered) {
|
||||
await BackgroundFetch.registerTaskAsync(WEBSOCKET_KEEPALIVE_TASK, {
|
||||
minimumInterval: 60, // 1 分钟检查一次
|
||||
stopOnTerminate: false,
|
||||
startOnBoot: true,
|
||||
});
|
||||
console.log('[BackgroundService] WebSocket 保活任务注册成功');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[BackgroundService] 注册后台任务失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消后台任务
|
||||
*/
|
||||
async function unregisterBackgroundTasks(): Promise<void> {
|
||||
try {
|
||||
await BackgroundFetch.unregisterTaskAsync(BACKGROUND_FETCH_TASK);
|
||||
await BackgroundFetch.unregisterTaskAsync(WEBSOCKET_KEEPALIVE_TASK);
|
||||
console.log('[BackgroundService] 后台任务已取消');
|
||||
} catch (error) {
|
||||
console.error('[BackgroundService] 取消后台任务失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 App 状态监听
|
||||
*/
|
||||
function setupAppStateListener(): void {
|
||||
if (appStateSubscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
appStateSubscription = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
|
||||
console.log('[BackgroundService] App 状态变化:', nextAppState);
|
||||
|
||||
if (nextAppState === 'active') {
|
||||
// App 回到前台,确保连接
|
||||
console.log('[BackgroundService] App 回到前台');
|
||||
if (!websocketService.isConnected()) {
|
||||
websocketService.connect();
|
||||
}
|
||||
} else if (nextAppState === 'background') {
|
||||
// App 进入后台,启动保活
|
||||
console.log('[BackgroundService] App 进入后台,启动保活机制');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化后台保活服务
|
||||
*/
|
||||
export async function initBackgroundService(): Promise<boolean> {
|
||||
if (isInitialized) {
|
||||
console.log('[BackgroundService] 服务已初始化');
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[BackgroundService] 初始化后台保活服务...');
|
||||
|
||||
// 检查后台任务状态
|
||||
const status = await BackgroundFetch.getStatusAsync();
|
||||
if (status !== BackgroundFetch.BackgroundFetchStatus.Available) {
|
||||
console.warn('[BackgroundService] 后台任务不可用,状态:', status);
|
||||
// 即使后台任务不可用,也继续初始化其他功能
|
||||
}
|
||||
|
||||
// 注册后台任务
|
||||
await registerBackgroundTasks();
|
||||
|
||||
// 设置 App 状态监听
|
||||
setupAppStateListener();
|
||||
|
||||
isInitialized = true;
|
||||
console.log('[BackgroundService] 后台保活服务初始化完成');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[BackgroundService] 初始化失败:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止后台保活服务
|
||||
*/
|
||||
export async function stopBackgroundService(): Promise<void> {
|
||||
try {
|
||||
await unregisterBackgroundTasks();
|
||||
|
||||
if (appStateSubscription) {
|
||||
appStateSubscription.remove();
|
||||
appStateSubscription = null;
|
||||
}
|
||||
|
||||
isInitialized = false;
|
||||
console.log('[BackgroundService] 后台保活服务已停止');
|
||||
} catch (error) {
|
||||
console.error('[BackgroundService] 停止服务失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查后台服务状态
|
||||
*/
|
||||
export async function checkBackgroundStatus(): Promise<{
|
||||
isAvailable: boolean;
|
||||
isInitialized: boolean;
|
||||
registeredTasks: string[];
|
||||
}> {
|
||||
const status = await BackgroundFetch.getStatusAsync();
|
||||
const registeredTasks = await TaskManager.getRegisteredTasksAsync();
|
||||
|
||||
return {
|
||||
isAvailable: status === BackgroundFetch.BackgroundFetchStatus.Available,
|
||||
isInitialized,
|
||||
registeredTasks: registeredTasks.map(task => task.taskName),
|
||||
};
|
||||
}
|
||||
|
||||
// 导出服务实例
|
||||
export const backgroundService = {
|
||||
init: initBackgroundService,
|
||||
stop: stopBackgroundService,
|
||||
vibrate: triggerVibration,
|
||||
vibrateOnMessage,
|
||||
setVibrationConfig,
|
||||
getVibrationConfig,
|
||||
setVibrationEnabled,
|
||||
checkStatus: checkBackgroundStatus,
|
||||
};
|
||||
|
||||
export default backgroundService;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user