dev #5

Merged
lan merged 38 commits from dev into master 2026-03-25 20:54:10 +08:00
222 changed files with 22922 additions and 15835 deletions

View File

@@ -94,7 +94,7 @@ jobs:
test "${REMOTE}" = "${LOCAL}"
build-android-apk:
runs-on: ubuntu-latest
runs-on: android-builder
container:
image: reactnativecommunity/react-native-android:latest
env:
@@ -104,6 +104,7 @@ jobs:
NODE_ENV: "production"
NDK_NUM_JOBS: "4"
CMAKE_BUILD_PARALLEL_LEVEL: "4"
GRADLE_USER_HOME: /root/.gradle
permissions:
contents: read
steps:
@@ -122,7 +123,28 @@ jobs:
node-version: '25.6.1'
cache: 'npm'
- name: Cache Gradle
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
~/.android/build-cache
key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties', '**/gradle.properties') }}
restore-keys: |
${{ runner.os }}-gradle-
- name: Cache node_modules
uses: actions/cache@v4
id: cache-node-modules
with:
path: node_modules
key: ${{ runner.os }}-node-modules-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-modules-
- name: Install dependencies
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
- name: Generate Android native project
@@ -292,6 +314,8 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
provenance: false
cache-from: type=registry,ref=code.littlelan.cn/carrot_bbs/frontend-web:buildcache
cache-to: type=registry,ref=code.littlelan.cn/carrot_bbs/frontend-web:buildcache,mode=max
- name: Show image tags
run: |

3
.gitignore vendored
View File

@@ -51,3 +51,6 @@ dist-android-update.zip
# Backend
backend/data/
backend/logs/
doc/
docs/

136
App.tsx
View File

@@ -1,137 +1,7 @@
/**
* 萝卜BBS - 主应用入口
* 配置导航、主题、状态管理等
* 应用入口已由 Expo Router 接管package.json main: expo-router/entry
* 全局 Provider 与根布局见 app/_layout.tsx。
*/
import React, { useEffect, useRef } from 'react';
import { AppState, AppStateStatus, Platform, StyleSheet } 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();
// 注入全局CSS来移除React Native Web在浏览器中的默认focus outline
if (Platform.OS === 'web') {
const style = document.createElement('style');
style.textContent = `
/* 移除TextInput focus时的浏览器默认outline */
input:focus, textarea:focus, select:focus {
outline: none !important;
outline-width: 0 !important;
box-shadow: none !important;
}
/* 移除focus-visible时的默认outline */
input:focus-visible, textarea:focus-visible {
outline: none !important;
}
/* 移除所有:focus相关的默认样式 */
*:focus {
outline: none !important;
}
*:focus-visible {
outline: none !important;
}
`;
document.head.appendChild(style);
}
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();
// 监听 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;
void data;
}
);
// 监听通知到达(用于前台显示时额外处理)
const notificationReceivedSubscription = Notifications.addNotificationReceivedListener(
(notification) => {
void notification;
}
);
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>
);
return null;
}

View File

@@ -2,10 +2,11 @@
"expo": {
"name": "萝卜社区",
"slug": "qojo",
"version": "1.0.10",
"version": "1.0.11",
"orientation": "default",
"icon": "./assets/icon.png",
"userInterfaceStyle": "light",
"userInterfaceStyle": "automatic",
"scheme": "carrotbbs",
"splash": {
"image": "./assets/splash-icon.png",
"resizeMode": "contain",
@@ -33,7 +34,7 @@
},
"predictiveBackGestureEnabled": false,
"package": "skin.carrot.bbs",
"versionCode": 5,
"versionCode": 6,
"permissions": [
"VIBRATE",
"RECEIVE_BOOT_COMPLETED",
@@ -68,6 +69,12 @@
}
],
"expo-sqlite",
[
"expo-camera",
{
"cameraPermission": "允许萝卜社区访问您的相机以扫描二维码"
}
],
[
"expo-notifications",
{

View File

@@ -0,0 +1,127 @@
import { useMemo } from 'react';
import { Platform, useWindowDimensions } from 'react-native';
import { Tabs, usePathname } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useAppColors, shadows } from '../../../src/theme';
import { BREAKPOINTS } from '../../../src/hooks/useResponsive';
import { useHomeTabBarVisibilityStore, useTotalUnreadCount } from '../../../src/stores';
const TAB_BAR_HEIGHT = 56;
const TAB_BAR_FLOATING_MARGIN = 12;
const TAB_BAR_MARGIN = 20;
export default function TabsLayout() {
const colors = useAppColors();
const { width } = useWindowDimensions();
const insets = useSafeAreaInsets();
const pathname = usePathname();
const unreadCount = useTotalUnreadCount();
const scrollHideTabBar = useHomeTabBarVisibilityStore((s) => s.bottomTabBarHiddenByScroll);
const hideTabBar = Platform.OS === 'web' && width >= BREAKPOINTS.desktop;
const isHomeStackRoute = pathname === '/home' || pathname.startsWith('/home/');
const tabBarStyle = useMemo(() => {
if (hideTabBar) {
return { display: 'none' as const, height: 0, overflow: 'hidden' as const };
}
const visibleStyle = {
position: 'absolute' as const,
left: 0,
right: 0,
marginHorizontal: TAB_BAR_MARGIN,
bottom: TAB_BAR_FLOATING_MARGIN + insets.bottom,
height: TAB_BAR_HEIGHT,
borderRadius: 22,
backgroundColor: colors.background.paper,
...shadows.lg,
borderTopWidth: 0,
elevation: 100,
zIndex: 100,
};
if (isHomeStackRoute && scrollHideTabBar) {
return {
...visibleStyle,
display: 'none' as const,
height: 0,
opacity: 0,
overflow: 'hidden' as const,
};
}
return visibleStyle;
}, [hideTabBar, insets.bottom, isHomeStackRoute, scrollHideTabBar, colors]);
return (
<Tabs
screenOptions={{
headerShown: false,
tabBarActiveTintColor: colors.primary.main,
tabBarInactiveTintColor: colors.text.secondary,
tabBarStyle,
tabBarItemStyle: {
borderRadius: 16,
marginHorizontal: 2,
paddingVertical: 1,
},
tabBarShowLabel: true,
tabBarLabelStyle: { fontSize: 11, fontWeight: '600', marginTop: -1 },
}}
>
<Tabs.Screen
name="home"
options={{
title: '首页',
tabBarIcon: ({ color, focused }) => (
<MaterialCommunityIcons
name={focused ? 'home' : 'home-outline'}
size={focused ? 24 : 22}
color={color}
/>
),
}}
/>
<Tabs.Screen
name="apps"
options={{
title: '应用',
tabBarIcon: ({ color, focused }) => (
<MaterialCommunityIcons
name={focused ? 'view-grid' : 'view-grid-outline'}
size={focused ? 24 : 22}
color={color}
/>
),
}}
/>
<Tabs.Screen
name="messages"
options={{
title: '消息',
tabBarBadge: unreadCount > 0 ? (unreadCount > 99 ? 99 : unreadCount) : undefined,
tabBarIcon: ({ color, focused }) => (
<MaterialCommunityIcons
name={focused ? 'message-text' : 'message-text-outline'}
size={focused ? 24 : 22}
color={color}
/>
),
}}
/>
<Tabs.Screen
name="profile"
options={{
title: '我的',
tabBarIcon: ({ color, focused }) => (
<MaterialCommunityIcons
name={focused ? 'account' : 'account-outline'}
size={focused ? 24 : 22}
color={color}
/>
),
}}
/>
</Tabs>
);
}

View File

@@ -0,0 +1,5 @@
import { Stack } from 'expo-router';
export default function AppsStackLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

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

View File

@@ -0,0 +1,17 @@
import { Stack } from 'expo-router';
export default function ScheduleStackLayout() {
return (
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen
name="course"
options={{
headerShown: false,
presentation: 'transparentModal',
animation: 'fade',
}}
/>
</Stack>
);
}

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import { Stack } from 'expo-router';
export default function HomeStackLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import { Stack } from 'expo-router';
export default function MessagesStackLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

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

View File

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

View File

@@ -0,0 +1,41 @@
import { useMemo } from 'react';
import { Stack } from 'expo-router';
import { useRouter } from 'expo-router';
import { AppBackButton } from '../../../../src/components/common';
import { useAppColors } from '../../../../src/theme';
export default function ProfileStackLayout() {
const router = useRouter();
const colors = useAppColors();
const headerOptions = useMemo(
() => ({
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
headerTitleStyle: { fontWeight: '600' as const },
headerShadowVisible: false,
headerBackTitle: '',
}),
[colors]
);
return (
<Stack
screenOptions={{
...headerOptions,
headerBackVisible: false,
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
}}
>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="settings" options={{ headerShown: true, title: '设置' }} />
<Stack.Screen name="edit-profile" options={{ title: '编辑资料' }} />
<Stack.Screen name="account-security" options={{ title: '账号安全' }} />
<Stack.Screen name="my-posts" options={{ title: '我的帖子' }} />
<Stack.Screen name="bookmarks" options={{ title: '收藏' }} />
<Stack.Screen name="notification-settings" options={{ title: '通知设置' }} />
<Stack.Screen name="blocked-users" options={{ title: '黑名单' }} />
</Stack>
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

19
app/(app)/_layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { useEffect } from 'react';
import { Redirect } from 'expo-router';
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
import { messageManager, useAuthStore } from '../../src/stores';
export default function AppLayout() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
useEffect(() => {
messageManager.initialize();
}, []);
if (!isAuthenticated) {
return <Redirect href="/login" />;
}
return <AppRouteStack />;
}

28
app/(app)/_layout.web.tsx Normal file
View File

@@ -0,0 +1,28 @@
import { useEffect } from 'react';
import { useWindowDimensions } from 'react-native';
import { Redirect } from 'expo-router';
import { AppDesktopShell } from '../../src/app-navigation/AppDesktopShell';
import { AppRouteStack } from '../../src/app-navigation/AppRouteStack';
import { BREAKPOINTS } from '../../src/hooks/useResponsive';
import { messageManager, useAuthStore } from '../../src/stores';
export default function AppLayoutWeb() {
const { width } = useWindowDimensions();
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
const useDesktopShell = width >= BREAKPOINTS.desktop;
useEffect(() => {
messageManager.initialize();
}, []);
if (!isAuthenticated) {
return <Redirect href="/login" />;
}
if (useDesktopShell) {
return <AppDesktopShell />;
}
return <AppRouteStack />;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

5
app/(app)/group/join.tsx Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

5
app/(auth)/_layout.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { Stack } from 'expo-router';
export default function AuthLayout() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

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

5
app/(auth)/login.tsx Normal file
View File

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

5
app/(auth)/register.tsx Normal file
View File

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

205
app/_layout.tsx Normal file
View File

@@ -0,0 +1,205 @@
import React, { useEffect, useRef, useState } from 'react';
import { AppState, AppStateStatus, Platform, View, ActivityIndicator } from 'react-native';
import { Stack, useRouter } from 'expo-router';
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';
import * as SystemUI from 'expo-system-ui';
import { registerNotificationPresentationHandler } from '../src/services/notificationPreferences';
import {
ThemeBootstrap,
useAppColors,
usePaperThemeFromStore,
useResolvedColorScheme,
} from '../src/theme';
import { AppBackButton } from '../src/components/common';
import AppPromptBar from '../src/components/common/AppPromptBar';
import AppDialogHost from '../src/components/common/AppDialogHost';
import { installAlertOverride } from '../src/services/alertOverride';
import { useAuthStore } from '../src/stores';
registerNotificationPresentationHandler();
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
staleTime: 5 * 60 * 1000,
},
},
});
installAlertOverride();
if (Platform.OS === 'web' && typeof document !== 'undefined') {
const style = document.createElement('style');
style.textContent = `
input:focus, textarea:focus, select:focus {
outline: none !important;
outline-width: 0 !important;
box-shadow: none !important;
}
input:focus-visible, textarea:focus-visible {
outline: none !important;
}
*:focus { outline: none !important; }
*:focus-visible { outline: none !important; }
`;
document.head.appendChild(style);
}
function SystemChrome() {
const colors = useAppColors();
useEffect(() => {
void SystemUI.setBackgroundColorAsync(colors.background.default);
if (Platform.OS === 'web' && typeof document !== 'undefined') {
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
meta.setAttribute('content', colors.background.default);
}
}
}, [colors.background.default]);
return null;
}
function SessionGate({ children }: { children: React.ReactNode }) {
const [ready, setReady] = useState(false);
const fetchCurrentUser = useAuthStore((s) => s.fetchCurrentUser);
const colors = useAppColors();
useEffect(() => {
fetchCurrentUser().finally(() => setReady(true));
}, [fetchCurrentUser]);
if (!ready) {
return (
<View
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: colors.background.default,
}}
>
<ActivityIndicator size="large" color={colors.primary.main} />
</View>
);
}
return <>{children}</>;
}
function NotificationBootstrap() {
const appState = useRef<AppStateStatus>(AppState.currentState);
const notificationResponseListener = useRef<Notifications.EventSubscription | null>(null);
useEffect(() => {
const initNotifications = async () => {
const { loadNotificationPreferences } = await import('../src/services/notificationPreferences');
await loadNotificationPreferences();
const { systemNotificationService } = await import('../src/services/systemNotificationService');
await systemNotificationService.initialize();
const { initBackgroundService } = await import('../src/services/backgroundService');
await initBackgroundService();
const subscription = AppState.addEventListener('change', (nextAppState) => {
if (appState.current.match(/inactive|background/) && nextAppState === 'active') {
systemNotificationService.clearBadge();
}
appState.current = nextAppState;
});
notificationResponseListener.current = Notifications.addNotificationResponseReceivedListener(
(response) => {
void response.notification.request.content.data;
}
);
const notificationReceivedSubscription = Notifications.addNotificationReceivedListener(
(notification) => {
void notification;
}
);
return () => {
subscription.remove();
notificationResponseListener.current?.remove();
notificationReceivedSubscription.remove();
};
};
initNotifications();
}, []);
return null;
}
function ThemedStack() {
const router = useRouter();
const colors = useAppColors();
const resolved = useResolvedColorScheme();
return (
<>
<StatusBar style={resolved === 'dark' ? 'light' : 'dark'} />
<SystemChrome />
<SessionGate>
<NotificationBootstrap />
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" options={{ headerShown: false }} />
<Stack.Screen name="(auth)" options={{ headerShown: false }} />
<Stack.Screen name="(app)" options={{ headerShown: false }} />
<Stack.Screen
name="post/[postId]"
options={{
headerShown: true,
title: '',
// 与 PostDetailScreen 的 SafeAreaViewbackground.default同色避免出现 header/内容之间的色差线
headerStyle: { backgroundColor: colors.background.default },
headerTintColor: colors.text.primary,
headerShadowVisible: false,
headerBackVisible: false,
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
}}
/>
<Stack.Screen
name="user/[userId]"
options={{
headerShown: true,
title: '用户主页',
headerStyle: { backgroundColor: colors.background.paper },
headerTintColor: colors.text.primary,
headerBackVisible: false,
headerLeft: () => <AppBackButton onPress={() => router.back()} />,
}}
/>
</Stack>
</SessionGate>
</>
);
}
function ThemedProviders({ children }: { children: React.ReactNode }) {
const theme = usePaperThemeFromStore();
return <PaperProvider theme={theme}>{children}</PaperProvider>;
}
export default function RootLayout() {
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<SafeAreaProvider>
<ThemedProviders>
<QueryClientProvider client={queryClient}>
<ThemeBootstrap />
<ThemedStack />
<AppPromptBar />
<AppDialogHost />
</QueryClientProvider>
</ThemedProviders>
</SafeAreaProvider>
</GestureHandlerRootView>
);
}

11
app/index.tsx Normal file
View File

@@ -0,0 +1,11 @@
import { Redirect } from 'expo-router';
import { useAuthStore } from '../src/stores';
export default function Index() {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
if (isAuthenticated) {
return <Redirect href="/home" />;
}
return <Redirect href="/login" />;
}

5
app/post/[postId].tsx Normal file
View File

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

5
app/user/[userId].tsx Normal file
View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1 @@
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);
import 'expo-router/entry';

3118
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "carrot_bbs",
"version": "1.0.0",
"main": "index.ts",
"version": "1.0.1",
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"android": "expo run:android",
@@ -25,7 +25,9 @@
"axios": "^1.13.6",
"date-fns": "^4.1.0",
"expo": "~55.0.4",
"expo-background-fetch": "~55.0.9",
"expo-background-fetch": "~55.0.10",
"expo-background-task": "~55.0.10",
"expo-camera": "^55.0.10",
"expo-constants": "~55.0.7",
"expo-dev-client": "~55.0.10",
"expo-file-system": "~55.0.10",
@@ -43,6 +45,9 @@
"expo-task-manager": "~55.0.9",
"expo-updates": "^55.0.12",
"expo-video": "^55.0.10",
"katex": "^0.16.42",
"markdown-it": "^14.1.1",
"prismjs": "^1.30.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-native": "0.83.2",
@@ -54,6 +59,7 @@
"react-native-screens": "~4.23.0",
"react-native-sse": "^1.2.1",
"react-native-web": "^0.21.0",
"react-native-webview": "13.16.0",
"react-native-worklets": "0.7.2",
"zod": "^4.3.6",
"zustand": "^5.0.11"

View File

@@ -0,0 +1,394 @@
# Messages模块与PostService/Manager架构对比分析报告
## 一、Messages模块架构分析
### 1.1 架构层次结构
Messages模块采用了**清晰的分层架构**,各层职责明确:
```
┌─────────────────────────────────────────────────────────────┐
│ 表现层 (Screens) │
│ ChatScreen, MessageListScreen, NotificationsScreen │
├─────────────────────────────────────────────────────────────┤
│ Hooks层 │
│ useDifferentialMessages, useChatScreen │
├─────────────────────────────────────────────────────────────┤
│ 用例层 (UseCases) │
│ ProcessMessageUseCase │
├─────────────────────────────────────────────────────────────┤
│ 领域层 (Entities) │
│ Message, Conversation, GroupNotice │
├─────────────────────────────────────────────────────────────┤
│ 仓库层 (Repositories) │
│ MessageRepository, IMessageRepository │
├─────────────────────────────────────────────────────────────┤
│ 数据源层 (DataSources) │
│ SSEClient, LocalDataSource, ApiDataSource │
└─────────────────────────────────────────────────────────────┘
```
### 1.2 核心组件详解
#### 1.2.1 领域实体层 (Core/Entities)
- **Message.ts**: 定义消息、会话、群通知等核心领域模型
- 包含工厂函数:`createMessage`, `createConversation`
- 包含业务逻辑函数:`isMessageFromCurrentUser`, `shouldIncrementUnread`, `buildTextContent`
#### 1.2.2 用例层 (Core/UseCases)
- **ProcessMessageUseCase**: 核心业务逻辑编排器
- 订阅SSE事件chat, group_message, read, recall, typing, group_notice等
- 消息去重处理processedMessageIds Set
- 已读状态保护pendingReadMap + 版本号机制)
- 用户信息缓存与去重请求pendingUserRequests Map
- 事件发布订阅模式subscribers Set
#### 1.2.3 仓库层 (Data/Repositories)
- **IMessageRepository**: 定义数据访问接口
- 消息操作getMessages, saveMessage, updateMessageStatus, markAsRead
- 会话操作getConversations, saveConversation, updateUnreadCount
- 同步操作syncMessages, getServerLastSeq
- **MessageRepository**: 实现接口
- 封装SQLite数据库操作
- 消息与CachedMessage的转换
#### 1.2.4 映射器层 (Data/Mappers)
- **MessageMapper**: 数据转换
- `fromApiResponse`: API响应 → 应用模型
- `fromDbRecord`: 数据库记录 → 应用模型
- `toDbRecord`: 应用模型 → 数据库记录
- `toApiRequest`: 应用模型 → API请求
- 创建消息片段:`createTextSegment`, `createImageSegment`
#### 1.2.5 差异计算基础设施 (Infrastructure/Diff)
- **MessageDiffCalculator**: 消息列表差异计算
- 计算added, updated, deleted, moved, unchanged
- 变化比例检测changeRatio > 0.5 时触发重置)
- 增量差异计算(基于缓存)
- **MessageUpdateBatcher**: 批量更新处理器
- 16ms批量间隔约60fps
- 去重和合并更新
- 100ms最大等待时间
- 统计信息totalBatches, totalUpdates, averageBatchSize
- **types.ts**: 完整的类型定义
- MessageUpdateType枚举ADD, UPDATE, DELETE, MOVE, BATCH_*
- DiffResult, DiffConfig等接口
#### 1.2.6 Hook层
- **useDifferentialMessages**: 差异更新Hook
- 集成DiffCalculator和Batcher
- 自动处理批量更新
- 提供flush, reset, forceUpdate方法
- 支持配置enableDiff, enableBatching, maxMessageCount, changeRatioThreshold
### 1.3 数据流
```
SSE事件 → ProcessMessageUseCase → 事件发布 → useChatScreen → useDifferentialMessages
↓ ↓
MessageRepository 差异计算 + 批量处理
↓ ↓
SQLite数据库 优化后的消息列表
```
### 1.4 状态管理方式
- **发布-订阅模式**ProcessMessageUseCase作为事件中心
- **本地SQLite缓存**:消息持久化存储
- **内存状态**通过Hook返回组件直接消费
- **差异更新**通过MessageDiffCalculator + MessageUpdateBatcher减少重渲染
---
## 二、PostService/Manager架构分析
### 2.1 架构层次结构
Post模块采用了**扁平化架构**Service和Manager层职责混合
```
┌─────────────────────────────────────────────────────────────┐
│ 表现层 (Screens) │
│ HomeScreen, PostDetailScreen, CreatePostScreen │
├─────────────────────────────────────────────────────────────┤
│ Hooks层 │
│ useCursorPagination (通用), usePrefetch │
├─────────────────────────────────────────────────────────────┤
│ Store层 (Zustand) │
│ useUserStore (直接操作状态), postManager (缓存管理) │
├─────────────────────────────────────────────────────────────┤
│ Service层 │
│ postService (API调用 + 状态更新) │
├─────────────────────────────────────────────────────────────┤
│ 数据源 │
│ API (直接调用) │
└─────────────────────────────────────────────────────────────┘
```
### 2.2 核心组件详解
#### 2.2.1 Service层 (Services)
- **postService.ts**: 帖子服务
- CRUD操作getPosts, getPost, createPost, updatePost, deletePost
- 互动操作likePost, unlikePost, favoritePost, unfavoritePost
- 分页支持:传统分页 + 游标分页
- **问题**直接操作useUserStore违反分层原则
#### 2.2.2 Manager层 (Stores)
- **postManager.ts**: 帖子缓存管理器
- 继承CacheBus发布订阅基类
- 内存缓存listCache, detailCache
- 请求去重pendingRequests Map
- TTL管理LIST_TTL=30s, DETAIL_TTL=60s
- 后台刷新机制
#### 2.2.3 Store层 (Zustand)
- **useUserStore**: 用户状态存储
- 直接存储posts数组
- 帖子操作副作用直接修改状态
- 混合了用户数据和帖子数据
#### 2.2.4 映射器层 (Data/Mappers)
- **PostMapper**: 数据转换
- `fromApiResponse`: API响应 → 应用模型
- `fromApiResponseList`: 批量转换
- `toApiRequest`: 应用模型 → API请求
- 相比MessageMapper缺少数据库记录转换方法
### 2.3 数据流
```
HomeScreen → useCursorPagination → postService.getPostsCursor
useUserStore.setState(posts)
PostCard组件渲染
```
### 2.4 状态管理方式
- **Zustand Store**:集中式状态管理
- **内存缓存**postManager提供应用级缓存
- **请求去重**dedupe方法防止重复请求
- **直接修改**postService直接调用useUserStore.setState
---
## 三、主要架构差异
### 3.1 分层复杂度
| 维度 | Messages模块 | Post模块 |
|------|-------------|---------|
| 层次深度 | 6层Entity→UseCase→Repository→Mapper→Hook→Screen | 4层Service→Store→Hook→Screen |
| 用例层 | 独立ProcessMessageUseCase | 无 |
| 仓库接口 | IMessageRepository抽象接口 | 无 |
| 基础设施 | Diff模块Calculator + Batcher | 无 |
### 3.2 数据持久化
| 维度 | Messages模块 | Post模块 |
|------|-------------|---------|
| 本地存储 | SQLite数据库 | 无 |
| 缓存抽象 | MessageRepository封装 | postManager内存缓存 |
| 离线支持 | 完整 | 不支持 |
### 3.3 实时更新
| 维度 | Messages模块 | Post模块 |
|------|-------------|---------|
| 推送机制 | SSE实时推送 | 轮询/下拉刷新 |
| 事件订阅 | ProcessMessageUseCase发布订阅 | 无 |
| 增量更新 | MessageDiffCalculator | 无 |
### 3.4 状态管理
| 维度 | Messages模块 | Post模块 |
|------|-------------|---------|
| 管理方式 | 发布订阅 + Hook局部状态 | Zustand全局Store |
| 状态来源 | useChatScreen, useDifferentialMessages | useUserStore |
| 批量更新 | MessageUpdateBatcher | 无 |
| 差异检测 | MessageDiffCalculator | 无 |
### 3.5 依赖方向
**Messages模块正确**
```
Hook → UseCase → Repository → DataSource
Entity不依赖外部
```
**Post模块问题**
```
Service → Store循环依赖风险
API直接调用
```
### 3.6 核心问题总结
| # | 问题 | Messages | Post |
|---|------|---------|------|
| 1 | 违反分层原则 | 无 | postService直接操作useUserStore |
| 2 | 缺少UseCase层 | ProcessMessageUseCase | 无业务逻辑编排 |
| 3 | 缺少Repository抽象 | IMessageRepository | 无数据访问接口 |
| 4 | 无差异更新 | useDifferentialMessages | 无 |
| 5 | 无批量处理 | MessageUpdateBatcher | 无 |
| 6 | 无本地持久化 | SQLite | 无 |
| 7 | 无实时推送 | SSE | 无 |
---
## 四、对齐建议
### 4.1 架构重构目标
将Post模块对齐到Messages模块的架构模式
```
┌─────────────────────────────────────────────────────────────┐
│ 表现层 (Screens) │
│ HomeScreen, PostDetailScreen │
├─────────────────────────────────────────────────────────────┤
│ Hooks层 │
│ useDifferentialPosts (新增) │
├─────────────────────────────────────────────────────────────┤
│ 用例层 (UseCases) │
│ ProcessPostUseCase (新增) │
├─────────────────────────────────────────────────────────────┤
│ 领域层 (Entities) │
│ Post, PostComment (新增) │
├─────────────────────────────────────────────────────────────┤
│ 仓库层 (Repositories) │
│ PostRepository, IPostRepository (新增) │
├─────────────────────────────────────────────────────────────┤
│ Service层 │
│ postService (仅保留API调用移除状态操作) │
└─────────────────────────────────────────────────────────────┘
```
### 4.2 具体改造项
#### 4.2.1 创建Post领域实体
```typescript
// src/core/entities/Post.ts
export interface Post {
id: string;
authorId: string;
title: string;
content: string;
images: string[];
likeCount: number;
commentCount: number;
// ... 其他字段
}
```
#### 4.2.2 创建ProcessPostUseCase
```typescript
// src/core/usecases/ProcessPostUseCase.ts
class ProcessPostUseCase {
// 帖子增删改查
// 点赞/收藏逻辑
// 事件发布订阅
}
```
#### 4.2.3 创建IPostRepository接口
```typescript
// src/data/repositories/interfaces/IPostRepository.ts
interface IPostRepository {
getPosts(type: string, page: number, pageSize: number): Promise<Post[]>;
savePost(post: Post): Promise<void>;
updatePost(postId: string, updates: Partial<Post>): Promise<void>;
// ...
}
```
#### 4.2.4 创建PostRepository实现
```typescript
// src/data/repositories/PostRepository.ts
// 封装SQLite操作实现IPostRepository接口
```
#### 4.2.5 创建useDifferentialPosts Hook
```typescript
// src/hooks/useDifferentialPosts.ts
// 类似useDifferentialMessages处理帖子列表差异更新
```
#### 4.2.6 改造postService
- 移除对useUserStore的直接依赖
- 仅保留API调用职责
---
## 五、架构对比图
```mermaid
graph TB
subgraph "Messages模块 [理想架构]"
E1[Entity<br/>Message.ts]
U1[UseCase<br/>ProcessMessageUseCase]
R1[Repository<br/>MessageRepository]
M1[Mapper<br/>MessageMapper]
H1[Hook<br/>useDifferentialMessages]
D1[Diff基础设施<br/>DiffCalculator + Batcher]
S1[(SQLite)]
E1 --> U1
U1 --> R1
U1 --> D1
R1 --> S1
M1 --> R1
D1 --> H1
H1 --> S1
end
subgraph "Post模块 [当前架构]"
E2[PostMapper]
SV2[postService<br/>直接操作Store]
ST2[useUserStore<br/>Zustand]
H2[Hook<br/>useCursorPagination]
PM2[postManager<br/>内存缓存]
E2 --> SV2
SV2 --> ST2
H2 --> SV2
PM2 --> ST2
end
style E1 fill:#90EE90
style U1 fill:#90EE90
style R1 fill:#90EE90
style D1 fill:#90EE90
style H1 fill:#90EE90
style S1 fill:#90EE90
style SV2 fill:#FFB6C1
style ST2 fill:#FFB6C1
```
---
## 六、结论
Messages模块是一个**架构完善**的模块,具备:
1. 清晰的分层架构
2. 独立的业务逻辑编排层UseCase
3. 完整的数据持久化SQLite
4. 高效的差异更新机制
5. 实时事件推送能力
Post模块当前存在以下问题
1. **违反分层原则**Service直接操作Store
2. **缺少UseCase层**:业务逻辑分散
3. **无数据抽象**缺少Repository接口
4. **无差异更新**:每次全量更新导致性能问题
5. **无本地持久化**:无法离线使用
建议按照对齐建议逐步重构Post模块使其架构与Messages模块对齐。

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -0,0 +1,243 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import {
View,
StyleSheet,
ScrollView,
TouchableOpacity,
Text,
Platform,
} from 'react-native';
import { useSafeAreaInsets, SafeAreaView } from 'react-native-safe-area-context';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { usePathname, useRouter } from 'expo-router';
import { useAppColors, shadows, type AppColors } from '../theme';
import { useTotalUnreadCount } from '../stores';
import { AppRouteStack } from './AppRouteStack';
type TabName = 'HomeTab' | 'MessageTab' | 'AppsTab' | 'ProfileTab';
const SIDEBAR_WIDTH_DESKTOP = 240;
const SIDEBAR_WIDTH_TABLET = 200;
const SIDEBAR_COLLAPSED_WIDTH = 72;
const NAV_ITEMS: { name: TabName; label: string; href: string; icon: string; iconOutline: string }[] = [
{ name: 'HomeTab', label: '首页', href: '/home', icon: 'home', iconOutline: 'home-outline' },
{ name: 'AppsTab', label: '应用', href: '/apps', icon: 'view-grid', iconOutline: 'view-grid-outline' },
{ name: 'MessageTab', label: '消息', href: '/messages', icon: 'message-text', iconOutline: 'message-text-outline' },
{ name: 'ProfileTab', label: '我的', href: '/profile', icon: 'account', iconOutline: 'account-outline' },
];
function pathToTab(pathname: string): TabName {
if (pathname.startsWith('/messages')) return 'MessageTab';
if (pathname.startsWith('/apps')) return 'AppsTab';
if (pathname.startsWith('/profile')) return 'ProfileTab';
if (pathname.startsWith('/home')) return 'HomeTab';
return 'HomeTab';
}
function createDesktopShellStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
flexDirection: 'row',
backgroundColor: colors.background.default,
},
sidebar: {
backgroundColor: colors.background.paper,
borderRightWidth: 1,
borderRightColor: colors.divider,
flexDirection: 'column',
...shadows.md,
},
sidebarHeader: {
paddingHorizontal: 16,
paddingVertical: 20,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
},
logoText: {
fontSize: 18,
fontWeight: '700',
color: colors.primary.main,
marginLeft: 8,
},
sidebarContent: {
flex: 1,
paddingTop: 8,
},
sidebarItem: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: 16,
paddingVertical: 12,
marginHorizontal: 8,
marginVertical: 4,
borderRadius: 12,
},
sidebarItemActive: {
backgroundColor: `${colors.primary.main}15`,
},
sidebarItemCollapsed: {
justifyContent: 'center',
paddingHorizontal: 0,
},
sidebarIconContainer: {
position: 'relative',
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 12,
},
sidebarLabel: {
fontSize: 15,
fontWeight: '500',
color: colors.text.secondary,
marginLeft: 12,
flex: 1,
},
sidebarLabelActive: {
color: colors.primary.main,
fontWeight: '600',
},
collapseButton: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
paddingHorizontal: 16,
paddingVertical: 12,
borderTopWidth: 1,
borderTopColor: colors.divider,
},
collapseButtonCollapsed: {
justifyContent: 'center',
paddingHorizontal: 0,
},
badge: {
position: 'absolute',
top: 2,
right: 2,
backgroundColor: colors.error.main,
borderRadius: 10,
minWidth: 18,
height: 18,
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 4,
},
badgeText: {
color: colors.primary.contrast,
fontSize: 10,
fontWeight: '600',
},
mainContent: {
flex: 1,
backgroundColor: colors.background.default,
},
});
}
export function AppDesktopShell() {
const colors = useAppColors();
const styles = useMemo(() => createDesktopShellStyles(colors), [colors]);
const insets = useSafeAreaInsets();
const router = useRouter();
const pathname = usePathname();
const unreadCount = useTotalUnreadCount();
const [isCollapsed, setIsCollapsed] = useState(false);
const [isDesktop, setIsDesktop] = useState(false);
useEffect(() => {
if (Platform.OS !== 'web') return;
const check = () => {
const w = window.innerWidth || document.documentElement.clientWidth;
setIsDesktop(w >= SIDEBAR_WIDTH_DESKTOP);
};
check();
window.addEventListener('resize', check);
return () => window.removeEventListener('resize', check);
}, []);
const sidebarWidth = isCollapsed
? SIDEBAR_COLLAPSED_WIDTH
: isDesktop
? SIDEBAR_WIDTH_DESKTOP
: SIDEBAR_WIDTH_TABLET;
const currentTab = pathToTab(pathname || '/home');
const handleTabChange = useCallback(
(href: string) => {
router.replace(href);
},
[router]
);
return (
<View style={styles.container}>
<SafeAreaView
style={[
styles.sidebar,
{ width: sidebarWidth, paddingTop: insets.top, paddingBottom: insets.bottom },
]}
>
<View style={styles.sidebarHeader}>
<MaterialCommunityIcons name="carrot" size={32} color={colors.primary.main} />
{!isCollapsed && <Text style={styles.logoText}>BBS</Text>}
</View>
<ScrollView style={styles.sidebarContent} showsVerticalScrollIndicator={false}>
{NAV_ITEMS.map((item) => {
const isActive = currentTab === item.name;
const showBadge = item.name === 'MessageTab' && unreadCount > 0;
return (
<TouchableOpacity
key={item.name}
style={[
styles.sidebarItem,
isActive && styles.sidebarItemActive,
isCollapsed && styles.sidebarItemCollapsed,
]}
onPress={() => handleTabChange(item.href)}
activeOpacity={0.7}
>
<View style={styles.sidebarIconContainer}>
<MaterialCommunityIcons
name={(isActive ? item.icon : item.iconOutline) as any}
size={24}
color={isActive ? colors.primary.main : colors.text.secondary}
/>
{showBadge ? (
<View style={styles.badge}>
<Text style={styles.badgeText}>{unreadCount > 99 ? '99+' : unreadCount}</Text>
</View>
) : null}
</View>
{!isCollapsed ? (
<Text style={[styles.sidebarLabel, isActive && styles.sidebarLabelActive]}>{item.label}</Text>
) : null}
</TouchableOpacity>
);
})}
</ScrollView>
<TouchableOpacity
style={[styles.collapseButton, isCollapsed && styles.collapseButtonCollapsed]}
onPress={() => setIsCollapsed((c) => !c)}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name={isCollapsed ? 'chevron-right' : 'chevron-left'}
size={24}
color={colors.text.secondary}
/>
</TouchableOpacity>
</SafeAreaView>
<View style={styles.mainContent}>
<AppRouteStack />
</View>
</View>
);
}

View File

@@ -0,0 +1,8 @@
import { Stack } from 'expo-router';
/**
* 已登录区内栈:由 app/(app) 下文件系统自动注册子路由
*/
export function AppRouteStack() {
return <Stack screenOptions={{ headerShown: false }} />;
}

View File

@@ -1,21 +1,19 @@
/**
* CommentItem 评论项组件 - QQ频道风格
* CommentItem 评论项组件 - 小红书 / 贴吧式平铺列表
* 支持嵌套回复显示、楼层号、身份标识、删除评论、图片显示
*/
import React, { useState } from 'react';
import { View, TouchableOpacity, StyleSheet, Alert, Dimensions } from 'react-native';
import React, { useMemo, useState } from 'react';
import { View, TouchableOpacity, StyleSheet, Alert } 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 { spacing, borderRadius, fontSizes, useAppColors, type AppColors } 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;
@@ -33,6 +31,174 @@ interface CommentItemProps {
currentUserId?: string; // 当前用户ID用于判断子评论作者
}
function createCommentItemStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flexDirection: 'row',
paddingTop: spacing.md,
paddingBottom: spacing.md,
paddingHorizontal: spacing.md,
backgroundColor: colors.background.paper,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.divider,
},
content: {
flex: 1,
marginLeft: spacing.sm,
minWidth: 0,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.xs,
gap: spacing.xs,
},
userInfo: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
flex: 1,
minWidth: 0,
},
metaDot: {
fontSize: fontSizes.xs,
color: colors.text.hint,
marginHorizontal: 2,
},
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,
flexShrink: 0,
},
floorPlain: {
fontSize: fontSizes.xs,
flexShrink: 0,
},
replyReference: {
marginBottom: spacing.xs,
},
commentContent: {
marginBottom: spacing.xs,
},
text: {
lineHeight: 22,
fontSize: fontSizes.md,
color: colors.text.primary,
},
actions: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.xs,
flexWrap: 'wrap',
},
actionButton: {
flexDirection: 'row',
alignItems: 'center',
marginRight: spacing.lg,
paddingVertical: 4,
paddingRight: spacing.xs,
},
actionText: {
marginLeft: 4,
fontSize: fontSizes.xs,
},
subRepliesContainer: {
marginTop: spacing.sm,
marginLeft: 0,
paddingLeft: spacing.sm,
paddingVertical: spacing.xs,
borderLeftWidth: 2,
borderLeftColor: colors.divider,
backgroundColor: colors.background.default,
},
subReplyItem: {
flexDirection: 'row',
alignItems: 'flex-start',
marginBottom: spacing.md,
},
subReplyItemLast: {
marginBottom: 0,
},
subReplyBody: {
fontSize: fontSizes.sm,
lineHeight: 20,
marginTop: 2,
},
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,
},
subActionButton: {
flexDirection: 'row',
alignItems: 'center',
marginRight: spacing.sm,
paddingVertical: 2,
},
subActionText: {
marginLeft: 2,
fontSize: fontSizes.xs,
},
});
}
const CommentItem: React.FC<CommentItemProps> = ({
comment,
onUserPress,
@@ -49,6 +215,8 @@ const CommentItem: React.FC<CommentItemProps> = ({
onImagePress,
currentUserId,
}) => {
const colors = useAppColors();
const styles = useMemo(() => createCommentItemStyles(colors), [colors]);
const [isDeleting, setIsDeleting] = useState(false);
// 格式化时间
const formatTime = (dateString: string): string => {
@@ -181,11 +349,9 @@ const CommentItem: React.FC<CommentItemProps> = ({
};
return (
<View style={styles.floorTag}>
<Text variant="caption" color={colors.text.hint} style={styles.floorText}>
{getFloorText(floorNumber)}
</Text>
</View>
<Text variant="caption" color={colors.text.hint} style={styles.floorPlain}>
{getFloorText(floorNumber)}
</Text>
);
};
@@ -276,8 +442,9 @@ const CommentItem: React.FC<CommentItemProps> = ({
return (
<View style={styles.subRepliesContainer}>
{comment.replies.map((reply) => {
{comment.replies.map((reply, replyIndex) => {
const replyAuthorId = reply.author?.id || '';
const isLastReply = replyIndex === comment.replies!.length - 1;
// 根据 target_id 获取被回复的用户昵称
const targetId = reply.target_id;
const targetNickname = targetId ? getTargetUserNickname(targetId) : null;
@@ -287,7 +454,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
return (
<TouchableOpacity
key={reply.id}
style={styles.subReplyItem}
style={[styles.subReplyItem, isLastReply && styles.subReplyItemLast]}
onPress={() => onReplyPress?.(reply)}
activeOpacity={0.7}
>
@@ -320,7 +487,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
</View>
{/* 显示回复内容(如果有文字) */}
{reply.content ? (
<Text variant="caption" color={colors.text.secondary} numberOfLines={2}>
<Text variant="body" color={colors.text.primary} style={styles.subReplyBody}>
{reply.content}
</Text>
) : null}
@@ -329,18 +496,18 @@ const CommentItem: React.FC<CommentItemProps> = ({
{/* 子评论操作按钮 */}
<View style={styles.subReplyActions}>
<TouchableOpacity
style={styles.actionButton}
style={styles.subActionButton}
onPress={() => onReplyPress?.(reply)}
>
<MaterialCommunityIcons name="reply" size={12} color={colors.text.hint} />
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
</Text>
</TouchableOpacity>
{/* 删除按钮 - 子评论作者可见 */}
{isSubReplyAuthor && (
<TouchableOpacity
style={styles.actionButton}
style={styles.subActionButton}
onPress={() => handleSubReplyDelete(reply)}
disabled={isDeleting}
>
@@ -349,7 +516,7 @@ const CommentItem: React.FC<CommentItemProps> = ({
size={12}
color={colors.text.hint}
/>
<Text variant="caption" color={colors.text.hint} style={styles.actionText}>
<Text variant="caption" color={colors.text.hint} style={styles.subActionText}>
{isDeleting ? '删除中' : '删除'}
</Text>
</TouchableOpacity>
@@ -375,11 +542,11 @@ const CommentItem: React.FC<CommentItemProps> = ({
return (
<View style={styles.container}>
{/* 用户头像 */}
<TouchableOpacity onPress={onUserPress}>
{/* 用户头像 - 略大便于点击,与正文左对齐 */}
<TouchableOpacity onPress={onUserPress} activeOpacity={0.7}>
<Avatar
source={comment.author?.avatar}
size={36}
size={40}
name={comment.author?.nickname}
/>
</TouchableOpacity>
@@ -389,12 +556,13 @@ const CommentItem: React.FC<CommentItemProps> = ({
{/* 用户信息行 - QQ频道风格 */}
<View style={styles.header}>
<View style={styles.userInfo}>
<TouchableOpacity onPress={onUserPress}>
<Text variant="body" style={styles.username}>
{comment.author?.nickname}
<TouchableOpacity onPress={onUserPress} activeOpacity={0.7}>
<Text variant="body" style={styles.username} numberOfLines={1}>
{comment.author?.nickname || '用户'}
</Text>
</TouchableOpacity>
{renderBadges()}
<Text style={styles.metaDot}>·</Text>
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
{formatTime(comment.created_at || '')}
</Text>
@@ -473,140 +641,4 @@ const CommentItem: React.FC<CommentItemProps> = ({
);
};
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;

View File

@@ -1,147 +0,0 @@
/**
* 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;

View File

@@ -1,977 +0,0 @@
/**
* 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 { 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';
import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper';
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 formatDateTime = (dateString?: string | null): string => {
if (!dateString) return '';
const date = new Date(dateString);
if (Number.isNaN(date.getTime())) return '';
const pad = (num: number) => String(num).padStart(2, '0');
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
};
const isPostEdited = (createdAt?: string, updatedAt?: string): boolean => {
if (!createdAt || !updatedAt) return false;
const created = new Date(createdAt).getTime();
const updated = new Date(updatedAt).getTime();
if (Number.isNaN(created) || Number.isNaN(updated)) return false;
return updated - created > 1000;
};
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,
preview_url: img.preview_url,
preview_url_large: img.preview_url_large,
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}
displayMode={variant === 'grid' ? 'grid' : 'list'}
usePreview={true}
/>
</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];
// 获取封面图预览 URL
const coverUrl = hasImage
? getPreviewImageUrl(coverImage, 'grid')
: '';
// 获取正文预览(无图时显示)
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;
const handleContainerPress = () => {
onPress();
};
const handleImagePress = (e: any) => {
e.stopPropagation();
onImagePress?.(post.images || [], 0);
};
const handleUserPress = (e: any) => {
e.stopPropagation();
onUserPress();
};
return (
<TouchableOpacity
style={[
styles.gridContainer,
!hasImage && styles.gridContainerNoImage,
isDesktop && styles.gridContainerDesktop
]}
onPress={handleContainerPress}
activeOpacity={0.9}
>
{/* 封面图 - 只有有图片时才渲染,无图时不显示占位区域 */}
{hasImage && (
<TouchableOpacity
activeOpacity={0.8}
onPress={handleImagePress}
>
<SmartImage
source={{ uri: coverUrl }}
style={[styles.gridCoverImage, { aspectRatio }]}
resizeMode="cover"
usePreview={true}
/>
</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={handleUserPress}>
<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]);
const handleContainerPress = () => {
onPress();
};
const handleUserPress = (e: any) => {
e.stopPropagation();
onUserPress();
};
const handleLikePress = (e: any) => {
e.stopPropagation();
onLike();
};
const handleCommentPress = (e: any) => {
e.stopPropagation();
onComment();
};
const handleSharePress = (e: any) => {
e.stopPropagation();
onShare();
};
const handleBookmarkPress = (e: any) => {
e.stopPropagation();
onBookmark();
};
const handleDeletePress = (e: any) => {
e.stopPropagation();
handleDelete();
};
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={handleContainerPress}
activeOpacity={0.9}
>
{/* 用户信息 */}
<View style={styles.userSection}>
<TouchableOpacity onPress={handleUserPress}>
<Avatar
source={author.avatar}
size={avatarSize}
name={author.nickname}
/>
</TouchableOpacity>
<View style={styles.userInfo}>
<View style={styles.userNameRow}>
<TouchableOpacity onPress={handleUserPress}>
<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}>
{formatDateTime(post.created_at)}
</Text>
{isPostEdited(post.created_at, post.updated_at) && (
<Text variant="caption" color={colors.text.hint} style={styles.timeText}>
{' · 修改 '}{formatDateTime(post.updated_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={handleDeletePress}
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={handleLikePress}>
<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={handleCommentPress}>
<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={handleSharePress}>
<MaterialCommunityIcons
name="share-outline"
size={isDesktop ? 22 : 19}
color={colors.text.secondary}
/>
</TouchableOpacity>
<TouchableOpacity style={[styles.actionButton, isDesktop && styles.actionButtonWide]} onPress={handleBookmarkPress}>
<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;

View File

@@ -0,0 +1,713 @@
/**
* PostCard 主组件
* 帖子卡片组件(新实现)
*
* @example
* // 新 API 使用方式
* <PostCard
* post={post}
* onAction={(action) => handleAction(post, action)}
* variant="list"
* features="full"
* />
*/
import React, { useMemo, useState, memo, useEffect } from 'react';
import { View, TouchableOpacity, StyleSheet, Alert, TextStyle } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { PostCardProps, PostCardAction } from './types';
import { ImageGridItem, SmartImage } from '../../common';
import Text from '../../common/Text';
import Avatar from '../../common/Avatar';
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../../theme';
import { getPreviewImageUrl } from '../../../utils/imageHelper';
import PostImages from './components/PostImages';
import { useResponsive } from '../../../hooks/useResponsive';
import { formatPostCreatedAtString } from '../../../core/entities/Post';
/** 列表相对时间:独立定时刷新,避免外层 PostCard.memo 挡住「分钟前」更新 */
const PostCardRelativeTime: React.FC<{ createdAt?: string | null; style?: TextStyle | TextStyle[] }> = ({
createdAt,
style: timeStyle,
}) => {
const [label, setLabel] = useState(() => formatPostCreatedAtString(createdAt));
useEffect(() => {
setLabel(formatPostCreatedAtString(createdAt));
const tick = () => setLabel(formatPostCreatedAtString(createdAt));
const id = setInterval(tick, 60_000);
return () => clearInterval(id);
}, [createdAt]);
return (
<Text style={timeStyle} numberOfLines={1}>
{label}
</Text>
);
};
function imagesSignature(
images: PostCardProps['post']['images'] | undefined
): string {
if (!images?.length) return '';
return images.map((i) => i.id).join('\u001f');
}
function authorSignature(author: PostCardProps['post']['author']): string {
if (!author) return '';
return [author.id, author.nickname ?? '', author.avatar ?? ''].join('\u001f');
}
function topCommentSignature(tc: PostCardProps['post']['top_comment']): string {
if (!tc) return '';
return [tc.id, tc.content ?? '', tc.author?.id ?? ''].join('\u001f');
}
function channelSignature(ch: PostCardProps['post']['channel']): string {
if (!ch) return '';
return [ch.id, ch.name ?? ''].join('\u001f');
}
function featuresComparable(f: PostCardProps['features']): string {
if (f == null) return '';
if (typeof f === 'string') return f;
return JSON.stringify(f);
}
/**
* 自定义比较:忽略 onAction 引用(父组件应用 postId + ref 解析最新 post避免 Tab 切换时全列表因回调重建而重绘)。
* index 未参与 UI不比较以免列表重排时多余更新。
*/
function arePostCardPropsEqual(prev: PostCardProps, next: PostCardProps): boolean {
if (prev.variant !== next.variant) return false;
if (prev.isPostAuthor !== next.isPostAuthor) return false;
if (prev.isAuthor !== next.isAuthor) return false;
if (prev.style !== next.style) return false;
if (featuresComparable(prev.features) !== featuresComparable(next.features)) return false;
const a = prev.post;
const b = next.post;
if (a === b) return true;
if (a.id !== b.id) return false;
if (a.title !== b.title) return false;
if (a.content !== b.content) return false;
if ((a.likes_count ?? 0) !== (b.likes_count ?? 0)) return false;
if ((a.comments_count ?? 0) !== (b.comments_count ?? 0)) return false;
if ((a.favorites_count ?? 0) !== (b.favorites_count ?? 0)) return false;
if ((a.shares_count ?? 0) !== (b.shares_count ?? 0)) return false;
if ((a.views_count ?? 0) !== (b.views_count ?? 0)) return false;
if (!!a.is_pinned !== !!b.is_pinned) return false;
if (!!a.is_locked !== !!b.is_locked) return false;
if (!!a.is_vote !== !!b.is_vote) return false;
if (a.created_at !== b.created_at) return false;
if (a.updated_at !== b.updated_at) return false;
if (a.content_edited_at !== b.content_edited_at) return false;
if (!!a.is_liked !== !!b.is_liked) return false;
if (!!a.is_favorited !== !!b.is_favorited) return false;
if (authorSignature(a.author) !== authorSignature(b.author)) return false;
if (imagesSignature(a.images) !== imagesSignature(b.images)) return false;
if (topCommentSignature(a.top_comment) !== topCommentSignature(b.top_comment)) return false;
if (channelSignature(a.channel) !== channelSignature(b.channel)) return false;
return true;
}
function createPostCardStyles(colors: AppColors) {
return StyleSheet.create({
card: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
padding: spacing.md,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.divider,
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: spacing.sm,
},
authorWrap: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
minWidth: 0,
},
authorMeta: {
marginLeft: spacing.sm,
flex: 1,
minWidth: 0,
},
authorName: {
color: colors.text.primary,
fontWeight: '600',
fontSize: fontSizes.md,
},
timeText: {
color: colors.text.hint,
fontSize: fontSizes.xs,
marginTop: 2,
},
metaRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
marginTop: 2,
flexWrap: 'wrap',
},
channelTag: {
flexDirection: 'row',
alignItems: 'center',
maxWidth: 140,
paddingHorizontal: 6,
paddingVertical: 2,
borderRadius: borderRadius.sm,
backgroundColor: `${colors.primary.main}12`,
gap: 4,
},
channelTagText: {
fontSize: fontSizes.xs,
color: colors.primary.main,
fontWeight: '600',
flexShrink: 1,
},
pinnedTag: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: `${colors.warning.light}22`,
borderRadius: borderRadius.sm,
paddingHorizontal: 4,
paddingVertical: 1,
gap: 2,
},
pinnedText: {
color: colors.warning.main,
fontSize: fontSizes.xs,
},
badgeText: {
color: colors.primary.main,
fontSize: fontSizes.xs,
fontWeight: '600',
},
deleteButton: {
padding: spacing.xs,
marginLeft: spacing.sm,
},
title: {
color: colors.text.primary,
fontSize: fontSizes.lg,
fontWeight: '600',
marginBottom: spacing.xs,
},
content: {
color: colors.text.secondary,
fontSize: fontSizes.md,
marginBottom: spacing.xs,
lineHeight: fontSizes.md * 1.45,
},
expandBtn: {
marginBottom: spacing.sm,
},
expandText: {
color: colors.primary.main,
fontSize: fontSizes.sm,
},
topCommentBox: {
backgroundColor: colors.background.default,
borderRadius: borderRadius.sm,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
marginBottom: spacing.sm,
flexDirection: 'row',
alignItems: 'center',
},
topCommentAuthor: {
color: colors.text.secondary,
fontSize: fontSizes.sm,
fontWeight: '600',
marginRight: 4,
},
topCommentText: {
color: colors.text.secondary,
fontSize: fontSizes.sm,
flex: 1,
},
gridCover: {
width: '100%',
aspectRatio: 0.78,
backgroundColor: colors.background.default,
},
actions: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
},
actionsLeading: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
minWidth: 0,
marginRight: spacing.sm,
gap: spacing.sm,
},
channelTagAfterViews: {
maxWidth: 130,
flexShrink: 1,
},
viewsWrap: {
flexDirection: 'row',
alignItems: 'center',
flexShrink: 0,
},
viewsText: {
color: colors.text.hint,
fontSize: fontSizes.sm,
marginLeft: 4,
},
actionButtons: {
flexDirection: 'row',
alignItems: 'center',
},
actionBtn: {
flexDirection: 'row',
alignItems: 'center',
marginLeft: spacing.md,
},
actionText: {
color: colors.text.secondary,
fontSize: fontSizes.sm,
marginLeft: 4,
},
activeActionText: {
color: colors.error.main,
},
activeActionTextMerged: {
color: colors.error.main,
fontSize: fontSizes.sm,
marginLeft: 4,
},
gridRootCard: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
overflow: 'hidden',
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.divider,
},
gridNoImagePreview: {
backgroundColor: colors.background.default,
margin: 6,
borderRadius: borderRadius.md,
minHeight: 120,
padding: 8,
},
gridNoImageText: {
color: colors.text.secondary,
fontSize: fontSizes.sm,
lineHeight: 20,
},
gridVoteTag: {
position: 'absolute',
top: 8,
right: 8,
backgroundColor: `${colors.primary.main}CC`,
borderRadius: borderRadius.full,
paddingHorizontal: 8,
paddingVertical: 4,
flexDirection: 'row',
alignItems: 'center',
gap: 4,
},
gridVoteTagText: {
color: colors.primary.contrast,
fontSize: 10,
fontWeight: '600',
},
gridTitleMain: {
color: colors.text.primary,
fontSize: fontSizes.md,
lineHeight: 20,
paddingHorizontal: 8,
paddingTop: 8,
minHeight: 44,
},
gridChannelRow: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingHorizontal: 8,
paddingTop: 4,
paddingBottom: 2,
},
gridChannelText: {
fontSize: fontSizes.xs,
color: colors.text.secondary,
fontWeight: '600',
flex: 1,
minWidth: 0,
},
gridFooter: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 8,
paddingVertical: 10,
},
gridUserArea: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
minWidth: 0,
},
gridUsername: {
color: colors.text.secondary,
fontSize: fontSizes.sm,
marginLeft: 6,
flex: 1,
},
gridLikeArea: {
flexDirection: 'row',
alignItems: 'center',
},
gridLikeCount: {
color: colors.text.secondary,
fontSize: fontSizes.sm,
marginLeft: 4,
},
});
}
/**
* PostCard 主组件
* 仅支持新 API
*/
const PostCardInner: React.FC<PostCardProps> = (normalizedProps) => {
const {
post,
onAction,
variant = 'list',
features,
isPostAuthor = false,
isAuthor = false,
index,
style,
} = normalizedProps;
const colors = useAppColors();
const styles = useMemo(() => createPostCardStyles(colors), [colors]);
const [isExpanded, setIsExpanded] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const isCompact =
features === 'compact' ||
(typeof features === 'object' && features?.showContent === false);
const emit = (action: PostCardAction) => {
onAction(action);
};
const handleImagePress = (images: ImageGridItem[], imageIndex: number) => {
emit({ type: 'imagePress', payload: { images, imageIndex } });
};
const showGrid = variant === 'grid';
const author = post.author;
const rawContent = post.content ?? '';
const { isDesktop, isTablet, isWideScreen, isLandscape } = useResponsive();
const handleCardPress = () => emit({ type: 'press' });
const handleUserPress = () => emit({ type: 'userPress' });
const handleLike = () => emit({ type: post.is_liked ? 'unlike' : 'like' });
const handleComment = () => emit({ type: 'comment' });
const handleBookmark = () => emit({ type: post.is_favorited ? 'unbookmark' : 'bookmark' });
const handleShare = () => emit({ type: 'share' });
const handleDelete = () => {
if (isDeleting) return;
Alert.alert(
'删除帖子',
'确定要删除这篇帖子吗?删除后将无法恢复。',
[
{ text: '取消', style: 'cancel' },
{
text: '删除',
style: 'destructive',
onPress: async () => {
setIsDeleting(true);
try {
emit({ type: 'delete' });
} finally {
setIsDeleting(false);
}
},
},
],
);
};
const images: ImageGridItem[] = Array.isArray(post.images)
? post.images.map((img) => ({
id: img.id,
url: img.url,
thumbnail_url: img.thumbnail_url,
preview_url: img.preview_url,
preview_url_large: img.preview_url_large,
width: img.width,
height: img.height,
}))
: [];
const formatNumber = (num: number): string => {
if (num >= 10000) return `${(num / 10000).toFixed(1)}w`;
if (num >= 1000) return `${(num / 1000).toFixed(1)}k`;
return String(num);
};
const getResponsiveMaxLength = () => {
if (isWideScreen) return 300;
if (isDesktop) return 250;
if (isTablet) return 200;
return 100;
};
const contentNumberOfLines = useMemo(() => {
if (isWideScreen) return 8;
if (isDesktop) return 6;
if (isTablet) return 5;
return 3;
}, [isWideScreen, isDesktop, isTablet]);
const getTruncatedContent = (value: string): string => {
const maxLength = getResponsiveMaxLength();
if (value.length <= maxLength || isExpanded) return value;
return `${value.substring(0, maxLength)}...`;
};
const shouldTruncate = !showGrid && !isCompact && rawContent.length > getResponsiveMaxLength();
const content = useMemo(
() => (showGrid || isCompact ? rawContent : getTruncatedContent(rawContent)),
[rawContent, showGrid, isCompact, isExpanded, isWideScreen, isDesktop, isTablet]
);
const renderImages = () => {
if (images.length === 0) return null;
if (showGrid) {
const cover = images[0];
const coverUrl = getPreviewImageUrl(cover as any, 'grid');
return (
<TouchableOpacity activeOpacity={0.9} onPress={() => handleImagePress(images, 0)}>
<SmartImage
source={{ uri: coverUrl || cover.url || '' }}
style={styles.gridCover}
resizeMode="cover"
usePreview={true}
/>
</TouchableOpacity>
);
}
return (
<PostImages
images={post.images || []}
displayMode="list"
onImagePress={handleImagePress}
/>
);
};
const renderTopComment = () => {
if (showGrid || !post.top_comment) return null;
return (
<TouchableOpacity style={styles.topCommentBox} activeOpacity={0.85} onPress={handleComment}>
<Text style={styles.topCommentAuthor} numberOfLines={1}>
{post.top_comment.author?.nickname || '匿名用户'}:
</Text>
<Text style={styles.topCommentText} numberOfLines={1}>
{post.top_comment.content}
</Text>
</TouchableOpacity>
);
};
const renderGridCard = () => {
const cover = images[0];
const hasImage = !!cover;
const coverUrl = hasImage ? getPreviewImageUrl(cover as any, 'grid') : '';
const contentPreview = rawContent
.replace(/[#*_~`\[\]()]/g, '')
.replace(/\n+/g, ' ')
.trim();
return (
<TouchableOpacity
activeOpacity={0.9}
onPress={handleCardPress}
style={[styles.gridRootCard, style]}
>
{hasImage ? (
<TouchableOpacity activeOpacity={0.9} onPress={() => handleImagePress(images, 0)}>
<SmartImage
source={{ uri: coverUrl || cover.url || '' }}
style={styles.gridCover}
resizeMode="cover"
usePreview={true}
/>
</TouchableOpacity>
) : (
<View style={styles.gridNoImagePreview}>
<Text style={styles.gridNoImageText} numberOfLines={6}>
{contentPreview}
</Text>
</View>
)}
{post.is_vote && (
<View style={styles.gridVoteTag}>
<MaterialCommunityIcons name="vote" size={11} color={colors.primary.contrast} />
<Text style={styles.gridVoteTagText}></Text>
</View>
)}
{!!post.title && (
<Text style={styles.gridTitleMain} numberOfLines={hasImage ? 2 : 3}>
{post.title}
</Text>
)}
{!!post.channel?.name && (
<View style={styles.gridChannelRow}>
<MaterialCommunityIcons name="tag-outline" size={12} color={colors.text.hint} />
<Text style={styles.gridChannelText} numberOfLines={1}>
{post.channel.name}
</Text>
</View>
)}
<View style={styles.gridFooter}>
<TouchableOpacity onPress={handleUserPress} style={styles.gridUserArea}>
<Avatar source={author?.avatar} size={20} name={author?.nickname || '匿名用户'} />
<Text style={styles.gridUsername} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text>
</TouchableOpacity>
<View style={styles.gridLikeArea}>
<MaterialCommunityIcons name="heart-outline" size={14} color={colors.text.secondary} />
<Text style={styles.gridLikeCount}>{formatNumber(post.likes_count || 0)}</Text>
</View>
</View>
</TouchableOpacity>
);
};
return (
showGrid ? renderGridCard() : (
<TouchableOpacity
activeOpacity={0.9}
onPress={handleCardPress}
style={[styles.card, style]}
>
<View style={styles.header}>
<TouchableOpacity onPress={handleUserPress} activeOpacity={0.8} style={styles.authorWrap}>
<Avatar source={author?.avatar} size={showGrid ? 22 : 36} name={author?.nickname || '匿名用户'} />
<View style={styles.authorMeta}>
<Text style={styles.authorName} numberOfLines={1}>{author?.nickname || '匿名用户'}</Text>
{!showGrid && (
<View style={styles.metaRow}>
<PostCardRelativeTime createdAt={post.created_at} style={styles.timeText} />
{post.is_pinned && (
<View style={styles.pinnedTag}>
<MaterialCommunityIcons name="pin" size={10} color={colors.warning.main} />
<Text style={styles.pinnedText}></Text>
</View>
)}
{isAuthor && <Text style={styles.badgeText}></Text>}
</View>
)}
</View>
</TouchableOpacity>
{isPostAuthor && (
<TouchableOpacity onPress={handleDelete} hitSlop={8} style={styles.deleteButton} disabled={isDeleting}>
<MaterialCommunityIcons
name={isDeleting ? 'loading' : 'delete-outline'}
size={isDesktop ? 20 : 18}
color={colors.text.hint}
/>
</TouchableOpacity>
)}
</View>
{!!post.title && (
<Text style={styles.title} numberOfLines={isCompact ? 2 : undefined}>
{post.title}
</Text>
)}
{!isCompact && !!content && (
<>
<Text
style={styles.content}
numberOfLines={isExpanded ? undefined : contentNumberOfLines}
>
{content}
</Text>
{shouldTruncate && (
<TouchableOpacity onPress={() => setIsExpanded((prev) => !prev)} style={styles.expandBtn}>
<Text style={styles.expandText}>{isExpanded ? '收起' : '展开全文'}</Text>
</TouchableOpacity>
)}
</>
)}
{renderImages()}
{renderTopComment()}
<View style={styles.actions}>
<View style={styles.actionsLeading}>
<View style={styles.viewsWrap}>
<MaterialCommunityIcons name="eye-outline" size={16} color={colors.text.hint} />
<Text style={styles.viewsText}>{post.views_count || 0}</Text>
</View>
{!!post.channel?.name && (
<View style={[styles.channelTag, styles.channelTagAfterViews]}>
<MaterialCommunityIcons name="tag-outline" size={11} color={colors.primary.main} />
<Text style={styles.channelTagText} numberOfLines={1}>
{post.channel.name}
</Text>
</View>
)}
</View>
<View style={styles.actionButtons}>
<TouchableOpacity style={styles.actionBtn} onPress={handleLike}>
<MaterialCommunityIcons
name={post.is_liked ? 'heart' : 'heart-outline'}
size={18}
color={post.is_liked ? colors.error.main : colors.text.secondary}
/>
<Text style={post.is_liked ? styles.activeActionTextMerged : styles.actionText}>
{post.likes_count > 0 ? formatNumber(post.likes_count) : '赞'}
</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionBtn} onPress={handleComment}>
<MaterialCommunityIcons name="comment-outline" size={18} color={colors.text.secondary} />
<Text style={styles.actionText}>{post.comments_count > 0 ? formatNumber(post.comments_count) : '评论'}</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.actionBtn} onPress={handleShare}>
<MaterialCommunityIcons name="share-outline" size={18} color={colors.text.secondary} />
</TouchableOpacity>
<TouchableOpacity style={styles.actionBtn} onPress={handleBookmark}>
<MaterialCommunityIcons
name={post.is_favorited ? 'bookmark' : 'bookmark-outline'}
size={18}
color={post.is_favorited ? colors.warning.main : colors.text.secondary}
/>
</TouchableOpacity>
</View>
</View>
</TouchableOpacity>
)
);
};
PostCardInner.displayName = 'PostCard';
const PostCard = memo(PostCardInner, arePostCardPropsEqual);
export default PostCard;

View File

@@ -0,0 +1,53 @@
/**
* PostImages 子组件
* 显示帖子图片网格
*/
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { spacing, borderRadius } from '../../../../theme';
import { useResponsive } from '../../../../hooks/useResponsive';
import { ImageGrid, ImageGridItem } from '../../../common';
import { PostImagesProps } from '../types';
const PostImages: React.FC<PostImagesProps> = ({ images, displayMode, onImagePress }) => {
const { isDesktop, isWideScreen } = useResponsive();
if (!images || images.length === 0) return null;
// 计算图片间距和圆角
const imageGap = isDesktop ? 4 : 2;
const imageBorderRadius = isDesktop ? borderRadius.xl : borderRadius.md;
return (
<View style={styles.container}>
<ImageGrid
images={images.map((img) => ({
id: img.id,
url: img.url,
thumbnail_url: img.thumbnail_url,
preview_url: img.preview_url,
preview_url_large: img.preview_url_large,
width: img.width,
height: img.height,
}))}
maxDisplayCount={isWideScreen ? 12 : 9}
mode="auto"
gap={imageGap}
borderRadius={imageBorderRadius}
showMoreOverlay={true}
onImagePress={onImagePress}
displayMode={displayMode}
usePreview={true}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
marginBottom: spacing.md,
},
});
export default PostImages;

View File

@@ -0,0 +1,5 @@
/**
* PostCard 子组件导出(仅导出仓库内已存在的实现)
*/
export { default as PostImages } from './PostImages';

View File

@@ -0,0 +1,5 @@
/**
* PostCard Hooks预留入口实现文件尚未加入仓库时勿导出避免 TS 无法解析)
*/
export {};

View File

@@ -0,0 +1,30 @@
/**
* PostCard 组件模块导出
*/
// 主组件(默认导出)
export { default } from './PostCard';
// 类型导出
export type {
PostCardActionType,
PostCardActionPayload,
PostCardAction,
PostCardOnAction,
PostCardFeatures,
PostCardFeaturesPreset,
PostCardFeaturesConfig,
PostCardProps,
PostHeaderProps,
PostTitleProps,
PostContentProps,
PostImagesProps,
PostActionsProps,
PostVotePreviewProps,
PostTopCommentProps,
PostGridCoverProps,
PostGridInfoProps,
PostCardListProps,
PostCardGridProps,
Badge,
} from './types';

View File

@@ -0,0 +1,262 @@
/**
* PostCard 组件类型定义
* 统一的帖子卡片组件类型系统
*/
import { StyleProp, ViewStyle } from 'react-native';
import { Post, PostImageDTO, UserDTO, CommentDTO } from '../../../types';
import { ImageGridItem } from '../../common';
// ==================== Action 类型定义 ====================
/**
* PostCard 支持的操作类型
*/
export type PostCardActionType =
| 'press' // 点击帖子主体
| 'userPress' // 点击用户头像/名称
| 'like' // 点赞
| 'unlike' // 取消点赞
| 'comment' // 评论
| 'bookmark' // 收藏
| 'unbookmark' // 取消收藏
| 'share' // 分享
| 'imagePress' // 点击图片
| 'delete'; // 删除
/**
* Action payload 类型
*/
export interface PostCardActionPayload {
images?: ImageGridItem[];
imageIndex?: number;
[key: string]: unknown;
}
/**
* 统一的 Action 对象
*/
export interface PostCardAction {
type: PostCardActionType;
payload?: PostCardActionPayload;
}
/**
* 统一的回调函数类型
*/
export type PostCardOnAction = (action: PostCardAction) => void;
// ==================== Features 配置类型 ====================
/**
* 显示特性配置
*/
export interface PostCardFeatures {
/** 显示用户信息 */
showAuthor?: boolean;
/** 显示标题 */
showTitle?: boolean;
/** 显示内容 */
showContent?: boolean;
/** 显示图片 */
showImages?: boolean;
/** 显示投票预览 */
showVote?: boolean;
/** 显示热门评论 */
showTopComment?: boolean;
/** 显示操作栏 */
showActions?: boolean;
/** 显示浏览数 */
showViews?: boolean;
/** 显示分享按钮 */
showShare?: boolean;
/** 显示收藏按钮 */
showBookmark?: boolean;
/** 显示删除按钮(需配合权限判断) */
showDelete?: boolean;
/** 显示置顶标签 */
showPinned?: boolean;
/** 显示徽章(楼主/管理员) */
showBadges?: boolean;
}
/**
* Features 预设名称
*/
export type PostCardFeaturesPreset = 'full' | 'compact' | 'grid';
/**
* Features 配置(可以是预设名称或自定义对象)
*/
export type PostCardFeaturesConfig = PostCardFeaturesPreset | PostCardFeatures;
// ==================== 主组件 Props ====================
/**
* PostCard 主组件 Props
*/
export interface PostCardProps {
/** 帖子数据 */
post: Post;
/** 统一事件回调 */
onAction: PostCardOnAction;
/** 显示模式:列表模式 | 网格模式 */
variant?: 'list' | 'grid';
/** 预设配置名称,或自定义配置对象 */
features?: PostCardFeaturesConfig;
/** 当前用户是否为帖子作者(用于显示删除按钮) */
isPostAuthor?: boolean;
/** 当前用户是否为楼主(在帖子详情页显示"楼主"徽章) */
isAuthor?: boolean;
/** 索引(用于虚拟化列表优化) */
index?: number;
/** 自定义样式 */
style?: StyleProp<ViewStyle>;
}
// ==================== 子组件 Props ====================
/**
* PostHeader 子组件 Props
*/
export interface PostHeaderProps {
author: UserDTO;
createdAt: string;
editedAt?: string;
isPinned?: boolean;
badges?: Array<{ type: 'author' | 'admin'; label: string }>;
onDelete?: () => void;
onUserPress: () => void;
showDelete?: boolean;
showPinned?: boolean;
showBadges?: boolean;
isDeleting?: boolean;
}
/**
* PostTitle 子组件 Props
*/
export interface PostTitleProps {
title?: string;
numberOfLines?: number;
}
/**
* PostContent 子组件 Props
*/
export interface PostContentProps {
content?: string;
compact?: boolean;
maxLines?: number;
isExpanded?: boolean;
onToggleExpand?: () => void;
}
/**
* PostImages 子组件 Props
*/
export interface PostImagesProps {
images: PostImageDTO[];
displayMode: 'list' | 'grid';
onImagePress: (images: ImageGridItem[], index: number) => void;
}
/**
* PostActions 子组件 Props
*/
export interface PostActionsProps {
viewsCount: number;
likesCount: number;
commentsCount: number;
isLiked: boolean;
isFavorited: boolean;
onLike: () => void;
onComment: () => void;
onShare: () => void;
onBookmark: () => void;
showViews?: boolean;
showShare?: boolean;
showBookmark?: boolean;
}
/**
* PostVotePreview 子组件 Props
*/
export interface PostVotePreviewProps {
isVote: boolean;
totalVotes?: number;
optionsCount?: number;
onPress: () => void;
}
/**
* PostTopComment 子组件 Props
*/
export interface PostTopCommentProps {
comment: CommentDTO | null;
totalComments: number;
onPress: () => void;
}
/**
* PostGridCover 子组件 Props
*/
export interface PostGridCoverProps {
image: PostImageDTO | null;
content?: string;
isVote?: boolean;
aspectRatio: number;
onImagePress: () => void;
}
/**
* PostGridInfo 子组件 Props
*/
export interface PostGridInfoProps {
title?: string;
author: UserDTO;
likesCount: number;
onUserPress: () => void;
}
// ==================== 容器组件 Props ====================
/**
* PostCardList 容器组件 Props
*/
export interface PostCardListProps {
post: Post;
onAction: PostCardOnAction;
features: PostCardFeatures;
isPostAuthor?: boolean;
isAuthor?: boolean;
index?: number;
style?: StyleProp<ViewStyle>;
}
/**
* PostCardGrid 容器组件 Props
*/
export interface PostCardGridProps {
post: Post;
onAction: PostCardOnAction;
features: PostCardFeatures;
style?: StyleProp<ViewStyle>;
}
// ==================== 工具类型 ====================
/**
* 徽章类型
*/
export interface Badge {
type: 'author' | 'admin';
label: string;
}

View File

@@ -0,0 +1,246 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
View,
Text,
StyleSheet,
TouchableOpacity,
Alert,
Modal,
Dimensions,
} from 'react-native';
import { CameraView, useCameraPermissions } from 'expo-camera';
import { useRouter } from 'expo-router';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import * as hrefs from '../../navigation/hrefs';
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
const { width, height } = Dimensions.get('window');
interface QRCodeScannerProps {
visible: boolean;
onClose: () => void;
}
function createQrScannerStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#000',
},
camera: {
flex: 1,
},
overlay: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.5)',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: spacing.md,
paddingTop: spacing.xl,
paddingBottom: spacing.md,
},
closeButton: {
padding: spacing.sm,
},
headerTitle: {
fontSize: fontSizes.lg,
fontWeight: '600',
color: '#fff',
},
placeholder: {
width: 40,
},
scanArea: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
},
scanFrame: {
width: width * 0.7,
height: width * 0.7,
position: 'relative',
},
corner: {
position: 'absolute',
width: 20,
height: 20,
borderColor: colors.primary.main,
borderWidth: 3,
},
topLeft: {
top: 0,
left: 0,
borderRightWidth: 0,
borderBottomWidth: 0,
},
topRight: {
top: 0,
right: 0,
borderLeftWidth: 0,
borderBottomWidth: 0,
},
bottomLeft: {
bottom: 0,
left: 0,
borderRightWidth: 0,
borderTopWidth: 0,
},
bottomRight: {
bottom: 0,
right: 0,
borderLeftWidth: 0,
borderTopWidth: 0,
},
scanText: {
marginTop: spacing.lg,
fontSize: fontSizes.md,
color: '#fff',
textAlign: 'center',
},
footer: {
padding: spacing.xl,
alignItems: 'center',
},
flashButton: {
padding: spacing.md,
backgroundColor: 'rgba(255,255,255,0.2)',
borderRadius: borderRadius.full,
},
permissionContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.xl,
},
permissionText: {
marginTop: spacing.lg,
fontSize: fontSizes.md,
color: 'rgba(255,255,255,0.72)',
textAlign: 'center',
},
permissionButton: {
marginTop: spacing.xl,
paddingVertical: spacing.md,
paddingHorizontal: spacing.xl,
backgroundColor: colors.primary.main,
borderRadius: borderRadius.md,
},
permissionButtonText: {
color: '#fff',
fontSize: fontSizes.md,
fontWeight: '600',
},
});
}
export const QRCodeScanner: React.FC<QRCodeScannerProps> = ({ visible, onClose }) => {
const router = useRouter();
const themeColors = useAppColors();
const styles = useMemo(() => createQrScannerStyles(themeColors), [themeColors]);
const [permission, requestPermission] = useCameraPermissions();
const [scanned, setScanned] = useState(false);
useEffect(() => {
if (visible) {
setScanned(false);
if (!permission?.granted) {
requestPermission();
}
}
}, [visible]);
const handleBarCodeScanned = ({ type, data }: { type: string; data: string }) => {
if (scanned) return;
setScanned(true);
onClose();
if (data.startsWith('carrotbbs://qrcode/login')) {
const sessionId = extractSessionId(data);
if (sessionId) {
router.push(hrefs.hrefQrLoginConfirm(sessionId));
} else {
Alert.alert('无效的二维码', '无法识别该二维码');
}
} else {
Alert.alert('无效的二维码', '请扫描网页端的登录二维码');
}
};
const extractSessionId = (url: string): string | null => {
try {
const sessionIdMatch = url.match(/session_id=([^&]+)/);
return sessionIdMatch ? sessionIdMatch[1] : null;
} catch {
return null;
}
};
if (!permission?.granted) {
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={styles.container}>
<View style={styles.header}>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<MaterialCommunityIcons name="close" size={24} color="#fff" />
</TouchableOpacity>
<Text style={styles.headerTitle}></Text>
<View style={styles.placeholder} />
</View>
<View style={styles.permissionContainer}>
<MaterialCommunityIcons name="camera-off" size={64} color="rgba(255,255,255,0.5)" />
<Text style={styles.permissionText}></Text>
<TouchableOpacity style={styles.permissionButton} onPress={requestPermission}>
<Text style={styles.permissionButtonText}></Text>
</TouchableOpacity>
</View>
</View>
</Modal>
);
}
return (
<Modal visible={visible} animationType="slide" onRequestClose={onClose}>
<View style={styles.container}>
<CameraView
style={styles.camera}
facing="back"
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
barcodeScannerSettings={{
barcodeTypes: ['qr'],
}}
>
<View style={styles.overlay}>
<View style={styles.header}>
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
<MaterialCommunityIcons name="close" size={24} color="#fff" />
</TouchableOpacity>
<Text style={styles.headerTitle}></Text>
<View style={styles.placeholder} />
</View>
<View style={styles.scanArea}>
<View style={styles.scanFrame}>
<View style={[styles.corner, styles.topLeft]} />
<View style={[styles.corner, styles.topRight]} />
<View style={[styles.corner, styles.bottomLeft]} />
<View style={[styles.corner, styles.bottomRight]} />
</View>
<Text style={styles.scanText}></Text>
</View>
<View style={styles.footer}>
<TouchableOpacity style={styles.flashButton} onPress={() => {}}>
<MaterialCommunityIcons name="flashlight" size={24} color="#fff" />
</TouchableOpacity>
</View>
</View>
</CameraView>
</View>
</Modal>
);
};
export default QRCodeScanner;

View File

@@ -3,10 +3,10 @@
* 用于搜索内容
*/
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import { View, TextInput, TouchableOpacity, StyleSheet } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
interface SearchBarProps {
value: string;
@@ -18,6 +18,63 @@ interface SearchBarProps {
autoFocus?: boolean;
}
function createSearchBarStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.paper,
borderRadius: borderRadius.full,
paddingHorizontal: spacing.xs,
height: 46,
borderWidth: 1,
borderColor: colors.divider,
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
containerFocused: {
borderColor: colors.primary.main,
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
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',
},
});
}
const SearchBar: React.FC<SearchBarProps> = ({
value,
onChangeText,
@@ -27,6 +84,8 @@ const SearchBar: React.FC<SearchBarProps> = ({
onBlur,
autoFocus = false,
}) => {
const colors = useAppColors();
const styles = useMemo(() => createSearchBarStyles(colors), [colors]);
const [isFocused, setIsFocused] = useState(false);
const handleFocus = () => {
@@ -59,7 +118,6 @@ const SearchBar: React.FC<SearchBarProps> = ({
onFocus={handleFocus}
onBlur={handleBlur}
autoFocus={autoFocus}
// 确保光标可见
cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`}
/>
@@ -69,73 +127,11 @@ const SearchBar: React.FC<SearchBarProps> = ({
style={styles.clearButton}
activeOpacity={0.7}
>
<MaterialCommunityIcons
name="close"
size={14}
color={colors.text.secondary}
/>
<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: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
containerFocused: {
// 焦点时使用更柔和的边框颜色
borderColor: colors.primary.main,
// 不添加额外的阴影,保持简洁
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
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;

View File

@@ -1,15 +1,15 @@
/**
* SystemMessageItem 系统消息项组件
* SystemMessageItem 系统消息项组件 - 美化版
* 根据系统消息类型显示不同图标和内容
* 参考 QQ 10000 系统消息和微信服务通知样式
* 采用卡片式设计符合胡萝卜BBS整体风格
*/
import React from 'react';
import React, { useMemo } 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 { spacing, borderRadius, shadows, fontSizes, useAppColors, type AppColors } from '../../theme';
import { SystemMessageResponse, SystemMessageType } from '../../types/dto';
import Text from '../common/Text';
import Avatar from '../common/Avatar';
@@ -17,15 +17,16 @@ import Avatar from '../common/Avatar';
interface SystemMessageItemProps {
message: SystemMessageResponse;
onPress?: () => void;
onAvatarPress?: () => void; // 头像点击回调
onAvatarPress?: () => void;
onRequestAction?: (approve: boolean) => void;
requestActionLoading?: boolean;
index?: number; // 用于交错动画
}
// 系统消息类型到图标和颜色的映射
const getSystemMessageIcon = (
systemType: SystemMessageType
): { icon: keyof typeof MaterialCommunityIcons.glyphMap; color: string; bgColor: string } => {
systemType: SystemMessageType,
colors: AppColors
): { icon: keyof typeof MaterialCommunityIcons.glyphMap; color: string; bgColor: string; gradient: string[] } => {
switch (systemType) {
case 'like_post':
case 'like_comment':
@@ -33,83 +34,243 @@ const getSystemMessageIcon = (
case 'favorite_post':
return {
icon: 'heart',
color: colors.error.main,
bgColor: colors.error.light + '20',
color: '#FF4757',
bgColor: '#FFE4E6',
gradient: ['#FF4757', '#FF6B7A'],
};
case 'comment':
return {
icon: 'comment',
color: colors.info.main,
bgColor: colors.info.light + '20',
icon: 'comment-text',
color: '#2196F3',
bgColor: '#E3F2FD',
gradient: ['#2196F3', '#64B5F6'],
};
case 'reply':
return {
icon: 'reply',
color: colors.success.main,
bgColor: colors.success.light + '20',
color: '#4CAF50',
bgColor: '#E8F5E9',
gradient: ['#4CAF50', '#81C784'],
};
case 'follow':
return {
icon: 'account-plus',
color: '#9C27B0', // 紫色
bgColor: '#9C27B020',
color: '#9C27B0',
bgColor: '#F3E5F5',
gradient: ['#9C27B0', '#BA68C8'],
};
case 'mention':
return {
icon: 'at',
color: colors.warning.main,
bgColor: colors.warning.light + '20',
color: '#FF9800',
bgColor: '#FFF3E0',
gradient: ['#FF9800', '#FFB74D'],
};
case 'system':
return {
icon: 'cog',
color: colors.text.secondary,
bgColor: colors.background.disabled,
color: '#607D8B',
bgColor: '#ECEFF1',
gradient: ['#607D8B', '#90A4AE'],
};
case 'announcement':
case 'announce':
return {
icon: 'bullhorn',
color: colors.primary.main,
bgColor: colors.primary.light + '20',
bgColor: colors.primary.light + '30',
gradient: [colors.primary.main, colors.primary.light],
};
case 'group_invite':
return {
icon: 'account-multiple-plus',
color: colors.primary.main,
bgColor: colors.primary.light + '20',
color: '#00BCD4',
bgColor: '#E0F7FA',
gradient: ['#00BCD4', '#4DD0E1'],
};
case 'group_join_apply':
return {
icon: 'account-clock',
color: colors.warning.main,
bgColor: colors.warning.light + '20',
color: '#FF9800',
bgColor: '#FFF3E0',
gradient: ['#FF9800', '#FFB74D'],
};
case 'group_join_approved':
return {
icon: 'check-circle',
color: colors.success.main,
bgColor: colors.success.light + '20',
color: '#4CAF50',
bgColor: '#E8F5E9',
gradient: ['#4CAF50', '#81C784'],
};
case 'group_join_rejected':
return {
icon: 'close-circle',
color: colors.error.main,
bgColor: colors.error.light + '20',
color: '#F44336',
bgColor: '#FFEBEE',
gradient: ['#F44336', '#E57373'],
};
default:
return {
icon: 'bell',
color: colors.text.secondary,
bgColor: colors.background.disabled,
color: '#757575',
bgColor: '#F5F5F5',
gradient: ['#757575', '#9E9E9E'],
};
}
};
function createSystemMessageStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'flex-start',
padding: spacing.md,
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
marginHorizontal: spacing.md,
marginBottom: spacing.sm,
...shadows.sm,
},
unreadContainer: {
backgroundColor: colors.primary.light + '08',
borderLeftWidth: 3,
borderLeftColor: colors.primary.main,
},
unreadIndicator: {
position: 'absolute',
left: 6,
top: '50%',
marginTop: -4,
width: 8,
height: 8,
borderRadius: borderRadius.full,
backgroundColor: colors.primary.main,
},
iconContainer: {
marginRight: spacing.md,
},
avatarWrapper: {
position: 'relative',
},
iconWrapper: {
width: 48,
height: 48,
borderRadius: borderRadius.lg,
justifyContent: 'center',
alignItems: 'center',
},
typeIconBadge: {
position: 'absolute',
bottom: -2,
right: -2,
width: 20,
height: 20,
borderRadius: borderRadius.full,
justifyContent: 'center',
alignItems: 'center',
borderWidth: 2,
borderColor: colors.background.paper,
},
content: {
flex: 1,
justifyContent: 'center',
minWidth: 0,
},
titleRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.xs,
},
titleLeft: {
flexDirection: 'row',
alignItems: 'center',
flex: 1,
marginRight: spacing.sm,
},
title: {
fontWeight: '500',
fontSize: fontSizes.md,
color: colors.text.primary,
},
unreadTitle: {
fontWeight: '600',
color: colors.text.primary,
},
statusBadge: {
flexDirection: 'row',
alignItems: 'center',
paddingHorizontal: spacing.xs,
paddingVertical: 2,
borderRadius: borderRadius.sm,
marginLeft: spacing.xs,
},
statusText: {
fontSize: 10,
fontWeight: '500',
marginLeft: 2,
},
time: {
fontSize: fontSizes.sm,
flexShrink: 0,
},
messageContent: {
lineHeight: 20,
fontSize: fontSizes.sm,
},
extraInfo: {
flexDirection: 'row',
alignItems: 'center',
marginTop: spacing.xs,
backgroundColor: colors.primary.light + '12',
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderRadius: borderRadius.md,
alignSelf: 'flex-start',
},
extraText: {
marginLeft: spacing.xs,
flex: 1,
fontWeight: '500',
},
actionsRow: {
flexDirection: 'row',
marginTop: spacing.sm,
gap: spacing.sm,
},
actionBtn: {
flexDirection: 'row',
alignItems: 'center',
paddingVertical: spacing.xs,
paddingHorizontal: spacing.md,
borderRadius: borderRadius.md,
borderWidth: 1,
gap: spacing.xs,
},
rejectBtn: {
borderColor: `${colors.error.main}55`,
backgroundColor: `${colors.error.main}18`,
},
approveBtn: {
borderColor: `${colors.success.main}55`,
backgroundColor: `${colors.success.main}18`,
},
actionBtnText: {
fontWeight: '500',
},
arrowContainer: {
marginLeft: spacing.sm,
justifyContent: 'center',
alignItems: 'center',
height: 20,
width: 20,
},
});
}
// 获取系统消息标题
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;
@@ -163,7 +324,11 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
onAvatarPress,
onRequestAction,
requestActionLoading = false,
index = 0,
}) => {
const colors = useAppColors();
const styles = useMemo(() => createSystemMessageStyles(colors), [colors]);
// 格式化时间
const formatTime = (dateString: string): string => {
try {
@@ -176,10 +341,9 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
}
};
const { icon, color, bgColor } = getSystemMessageIcon(message.system_type);
const { icon, color, bgColor } = getSystemMessageIcon(message.system_type, colors);
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;
@@ -188,99 +352,7 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
(message.system_type === 'group_invite' || message.system_type === 'group_join_apply') &&
requestStatus === 'pending' &&
!!onRequestAction;
// 使用固定的响应式值兼容移动端和Web端
const containerPadding = spacing.md;
const avatarSize = 44;
const iconSize = 22;
const contentFontSize = 14;
const titleFontSize = 14;
const timeFontSize = 12;
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'flex-start',
padding: containerPadding,
backgroundColor: colors.background.paper,
borderBottomWidth: 1,
borderBottomColor: colors.divider,
},
iconContainer: {
marginRight: spacing.md,
},
iconWrapper: {
width: avatarSize,
height: avatarSize,
borderRadius: avatarSize / 2,
justifyContent: 'center',
alignItems: 'center',
},
content: {
flex: 1,
justifyContent: 'center',
minWidth: 0,
},
titleRow: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: spacing.xs,
},
title: {
fontWeight: '600',
flex: 1,
marginRight: spacing.sm,
fontSize: titleFontSize,
},
time: {
fontSize: timeFontSize,
},
messageContent: {
lineHeight: 20,
fontSize: contentFontSize,
},
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,
width: 24,
},
});
const isUnread = message.is_read !== true;
// 判断是否显示操作者头像
const showOperatorAvatar = ['follow', 'like_post', 'like_comment', 'like_reply', 'favorite_post', 'comment', 'reply', 'mention', 'group_join_apply'].includes(
@@ -288,31 +360,68 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
);
const showGroupAvatar = message.system_type === 'group_invite';
// 获取状态标签
const getStatusBadge = () => {
if (requestStatus === 'accepted') {
return (
<View style={[styles.statusBadge, { backgroundColor: `${colors.success.main}22` }]}>
<MaterialCommunityIcons name="check" size={10} color={colors.success.main} />
<Text variant="caption" color={colors.success.main} style={styles.statusText}></Text>
</View>
);
}
if (requestStatus === 'rejected') {
return (
<View style={[styles.statusBadge, { backgroundColor: `${colors.error.main}22` }]}>
<MaterialCommunityIcons name="close" size={10} color={colors.error.main} />
<Text variant="caption" color={colors.error.main} style={styles.statusText}></Text>
</View>
);
}
return null;
};
return (
<TouchableOpacity
style={styles.container}
style={[
styles.container,
isUnread && styles.unreadContainer,
]}
onPress={onPress}
activeOpacity={onPress ? 0.7 : 1}
activeOpacity={onPress ? 0.85 : 1}
disabled={!onPress}
>
{/* 左侧未读指示器 */}
{isUnread && <View style={styles.unreadIndicator} />}
{/* 图标/头像区域 */}
<View style={styles.iconContainer}>
{showGroupAvatar ? (
<Avatar
source={groupAvatar || ''}
size={avatarSize}
name={extra_data?.group_name || '群聊'}
/>
<View style={styles.avatarWrapper}>
<Avatar
source={groupAvatar || ''}
size={48}
name={extra_data?.group_name || '群聊'}
/>
<View style={[styles.typeIconBadge, { backgroundColor: bgColor }]}>
<MaterialCommunityIcons name={icon} size={12} color={color} />
</View>
</View>
) : showOperatorAvatar ? (
<Avatar
source={operatorAvatar || ''}
size={avatarSize}
name={operatorName}
onPress={onAvatarPress}
/>
<View style={styles.avatarWrapper}>
<Avatar
source={operatorAvatar || ''}
size={48}
name={operatorName}
onPress={onAvatarPress}
/>
<View style={[styles.typeIconBadge, { backgroundColor: bgColor }]}>
<MaterialCommunityIcons name={icon} size={12} color={color} />
</View>
</View>
) : (
<View style={[styles.iconWrapper, { backgroundColor: bgColor }]}>
<MaterialCommunityIcons name={icon} size={iconSize} color={color} />
<MaterialCommunityIcons name={icon} size={24} color={color} />
</View>
)}
</View>
@@ -321,36 +430,49 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
<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} style={styles.time}>
<View style={styles.titleLeft}>
<Text
variant="body"
style={[styles.title, ...(isUnread ? [styles.unreadTitle] : [])]}
numberOfLines={1}
>
{title}
</Text>
{getStatusBadge()}
</View>
<Text variant="caption" color={colors.text.hint} style={styles.time}>
{formatTime(message.created_at)}
</Text>
</View>
{/* 消息内容 */}
<Text variant="caption" color={colors.text.secondary} numberOfLines={2} style={styles.messageContent}>
<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>
);
})()}
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-text-outline' : 'file-document-outline';
return (
<View style={styles.extraInfo}>
<MaterialCommunityIcons name={iconName} size={12} color={colors.primary.main} />
<Text variant="caption" color={colors.primary.main} numberOfLines={1} style={styles.extraText}>
{previewText}
</Text>
</View>
);
})()}
{/* 操作按钮 */}
{isActionable && (
<View style={styles.actionsRow}>
<TouchableOpacity
@@ -358,20 +480,22 @@ const SystemMessageItem: React.FC<SystemMessageItemProps> = ({
onPress={() => onRequestAction?.(false)}
disabled={requestActionLoading}
>
<Text variant="caption" color={colors.error.main}></Text>
<MaterialCommunityIcons name="close" size={14} color={colors.error.main} />
<Text variant="caption" color={colors.error.main} style={styles.actionBtnText}></Text>
</TouchableOpacity>
<TouchableOpacity
style={[styles.actionBtn, styles.approveBtn]}
onPress={() => onRequestAction?.(true)}
disabled={requestActionLoading}
>
<Text variant="caption" color={colors.success.main}></Text>
<MaterialCommunityIcons name="check" size={14} color={colors.success.main} />
<Text variant="caption" color={colors.success.main} style={styles.actionBtnText}></Text>
</TouchableOpacity>
</View>
)}
</View>
{/* 右侧箭头(如果有跳转) */}
{/* 右侧箭头 */}
{onPress && (
<View style={styles.arrowContainer}>
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />

View File

@@ -4,10 +4,10 @@
* 新增胶囊式、分段式等现代设计风格
*/
import React, { ReactNode } from 'react';
import { View, TouchableOpacity, StyleSheet, ScrollView, Animated } from 'react-native';
import React, { ReactNode, useMemo } from 'react';
import { View, TouchableOpacity, StyleSheet, ScrollView, ViewStyle, StyleProp } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import Text from '../common/Text';
type TabBarVariant = 'default' | 'pill' | 'segmented' | 'modern';
@@ -20,6 +20,166 @@ interface TabBarProps {
rightContent?: ReactNode;
variant?: TabBarVariant;
icons?: string[];
style?: StyleProp<ViewStyle>;
}
function createTabBarStyles(colors: AppColors) {
return StyleSheet.create({
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,
},
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',
},
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: colors.chat.shadow,
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,
},
modernContainer: {
flexDirection: 'row',
backgroundColor: colors.background.paper,
borderRadius: borderRadius.xl,
marginHorizontal: spacing.lg,
marginVertical: spacing.md,
padding: spacing.xs,
shadowColor: colors.chat.shadow,
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',
},
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,
},
});
}
const TabBar: React.FC<TabBarProps> = ({
@@ -30,7 +190,11 @@ const TabBar: React.FC<TabBarProps> = ({
rightContent,
variant = 'default',
icons,
style,
}) => {
const colors = useAppColors();
const styles = useMemo(() => createTabBarStyles(colors), [colors]);
const renderTabs = () => {
return tabs.map((tab, index) => {
const isActive = index === activeIndex;
@@ -109,11 +273,7 @@ const TabBar: React.FC<TabBarProps> = ({
style={styles.segmentedTabIcon}
/>
)}
<Text
variant="body"
color={isActive ? colors.primary.main : colors.text.secondary}
style={styles.segmentedTabText}
>
<Text variant="body" color={isActive ? colors.primary.main : colors.text.secondary} style={styles.segmentedTabText}>
{tab}
</Text>
</View>
@@ -121,7 +281,6 @@ const TabBar: React.FC<TabBarProps> = ({
);
}
// default variant
return (
<TouchableOpacity
key={index}
@@ -157,12 +316,8 @@ const TabBar: React.FC<TabBarProps> = ({
if (scrollable) {
return (
<View style={getContainerStyle()}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.scrollableContainer}
>
<View style={[getContainerStyle(), style]}>
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.scrollableContainer}>
{renderTabs()}
</ScrollView>
{rightContent && <View style={styles.rightContent}>{rightContent}</View>}
@@ -171,177 +326,11 @@ const TabBar: React.FC<TabBarProps> = ({
}
return (
<View style={getContainerStyle()}>
<View style={[getContainerStyle(), style]}>
{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;

View File

@@ -6,7 +6,7 @@
* 在宽屏下显示更大的头像和封面
*/
import React from 'react';
import React, { useMemo } from 'react';
import {
View,
Image,
@@ -15,7 +15,7 @@ import {
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { LinearGradient } from 'expo-linear-gradient';
import { colors, spacing, fontSizes, borderRadius, shadows } from '../../theme';
import { spacing, fontSizes, borderRadius, shadows, useAppColors, type AppColors } from '../../theme';
import { User } from '../../types';
import Text from '../common/Text';
import Button from '../common/Button';
@@ -49,7 +49,8 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
onFollowersPress,
onAvatarPress,
}) => {
// 响应式布局
const colors = useAppColors();
const styles = useMemo(() => createUserProfileHeaderStyles(colors), [colors]);
const { isWideScreen, isDesktop, width } = useResponsive();
// 格式化数字
@@ -236,22 +237,22 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
{/* 个人信息标签 */}
<View style={styles.metaInfo}>
{user.location && (
{user.location?.trim() ? (
<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 && (
) : null}
{user.website?.trim() ? (
<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>
)}
) : null}
<View style={styles.metaTag}>
<MaterialCommunityIcons name="calendar-outline" size={12} color={colors.text.secondary} />
<Text variant="caption" color={colors.text.secondary} style={styles.metaTagText}>
@@ -317,7 +318,8 @@ const UserProfileHeader: React.FC<UserProfileHeaderProps> = ({
);
};
const styles = StyleSheet.create({
function createUserProfileHeaderStyles(colors: AppColors) {
return StyleSheet.create({
container: {
backgroundColor: colors.background.default,
},
@@ -533,7 +535,8 @@ const styles = StyleSheet.create({
alignSelf: 'center',
padding: spacing.sm,
},
});
});
}
// 使用 React.memo 避免不必要的重新渲染
const MemoizedUserProfileHeader = React.memo(UserProfileHeader);

View File

@@ -4,7 +4,7 @@
* 风格与现代整体UI保持一致
*/
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import {
View,
TouchableOpacity,
@@ -12,7 +12,7 @@ import {
Animated,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import { VoteOptionDTO } from '../../types';
import Text from '../common/Text';
@@ -28,6 +28,146 @@ interface VoteCardProps {
compact?: boolean;
}
function createVoteCardStyles(colors: AppColors) {
return 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,
},
});
}
const VoteCard: React.FC<VoteCardProps> = ({
options,
totalVotes,
@@ -38,7 +178,8 @@ const VoteCard: React.FC<VoteCardProps> = ({
isLoading = false,
compact = false,
}) => {
// 动画值
const colors = useAppColors();
const styles = useMemo(() => createVoteCardStyles(colors), [colors]);
const progressAnim = React.useRef(new Animated.Value(0)).current;
React.useEffect(() => {
@@ -151,7 +292,7 @@ const VoteCard: React.FC<VoteCardProps> = ({
</TouchableOpacity>
</View>
);
}, [hasVoted, votedOptionId, calculatePercentage, handleVote, isLoading, progressAnim, compact]);
}, [hasVoted, votedOptionId, calculatePercentage, handleVote, isLoading, progressAnim, compact, colors, styles]);
// 排序后的选项(已投票的排在前面)
const sortedOptions = React.useMemo(() => {
@@ -229,142 +370,4 @@ const VoteCard: React.FC<VoteCardProps> = ({
);
};
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;

View File

@@ -3,7 +3,7 @@
* 用于创建帖子时编辑投票选项
*/
import React, { useCallback } from 'react';
import React, { useCallback, useMemo } from 'react';
import {
View,
TouchableOpacity,
@@ -11,7 +11,7 @@ import {
TextInput,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import Text from '../common/Text';
interface VoteEditorProps {
@@ -24,6 +24,86 @@ interface VoteEditorProps {
maxLength?: number;
}
function createVoteEditorStyles(colors: AppColors) {
return 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',
},
});
}
const VoteEditor: React.FC<VoteEditorProps> = ({
options,
onAddOption,
@@ -33,6 +113,8 @@ const VoteEditor: React.FC<VoteEditorProps> = ({
minOptions = 2,
maxLength = 50,
}) => {
const colors = useAppColors();
const styles = useMemo(() => createVoteEditorStyles(colors), [colors]);
const validOptionsCount = options.filter(opt => opt.trim() !== '').length;
const canAddOption = options.length < maxOptions;
const canRemoveOption = options.length > minOptions;
@@ -122,82 +204,4 @@ const VoteEditor: React.FC<VoteEditorProps> = ({
);
};
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;

View File

@@ -3,14 +3,14 @@
* 用于帖子列表中显示投票预览,类似微博风格
*/
import React from 'react';
import React, { useMemo } from 'react';
import {
View,
TouchableOpacity,
StyleSheet,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import Text from '../common/Text';
interface VotePreviewProps {
@@ -19,11 +19,51 @@ interface VotePreviewProps {
onPress?: () => void;
}
function createVotePreviewStyles(colors: AppColors) {
return 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,
},
});
}
const VotePreview: React.FC<VotePreviewProps> = ({
totalVotes = 0,
optionsCount = 0,
onPress,
}) => {
const colors = useAppColors();
const styles = useMemo(() => createVotePreviewStyles(colors), [colors]);
// 格式化票数
const formatVoteCount = (count: number): string => {
if (count >= 10000) {
@@ -71,39 +111,4 @@ const VotePreview: React.FC<VotePreviewProps> = ({
);
};
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;

View File

@@ -2,10 +2,20 @@
* 业务组件导出
*/
// PostCard 新架构(从 PostCard 目录导出)
export { default as PostCard } from './PostCard';
// 导出 PostCard 相关类型和工具
export type {
PostCardActionType,
PostCardAction,
PostCardOnAction,
PostCardFeatures,
PostCardFeaturesPreset,
PostCardFeaturesConfig,
PostCardProps,
} 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';

View File

@@ -0,0 +1,58 @@
import React from 'react';
import { TouchableOpacity, StyleProp, ViewStyle, StyleSheet } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { borderRadius, spacing, useAppColors } from '../../theme';
type AppBackButtonIcon = 'arrow-left' | 'close';
interface AppBackButtonProps {
onPress: () => void;
icon?: AppBackButtonIcon;
style?: StyleProp<ViewStyle>;
iconColor?: string;
backgroundColor?: string;
hitSlop?: number;
testID?: string;
}
const AppBackButton: React.FC<AppBackButtonProps> = ({
onPress,
icon = 'arrow-left',
style,
iconColor: iconColorProp,
backgroundColor: backgroundColorProp,
hitSlop = 8,
testID,
}) => {
const colors = useAppColors();
const iconColor = iconColorProp ?? colors.text.primary;
const backgroundColor = backgroundColorProp ?? colors.background.paper;
return (
<TouchableOpacity
onPress={onPress}
style={[styles.button, { backgroundColor }, style]}
activeOpacity={0.75}
hitSlop={hitSlop}
testID={testID}
accessibilityRole="button"
accessibilityLabel={icon === 'close' ? '关闭' : '返回'}
>
<MaterialCommunityIcons name={icon} size={22} color={iconColor} />
</TouchableOpacity>
);
};
const styles = StyleSheet.create({
button: {
width: 36,
height: 36,
borderRadius: borderRadius.full,
alignItems: 'center',
justifyContent: 'center',
marginLeft: spacing.xs,
},
});
export default AppBackButton;

View File

@@ -5,10 +5,98 @@ 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';
import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme';
function createDialogHostStyles(colors: AppColors) {
return 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: colors.background.paper,
justifyContent: 'center',
alignItems: 'center',
},
primaryButton: {
backgroundColor: colors.primary.main,
borderColor: colors.primary.main,
},
cancelButton: {
backgroundColor: colors.background.paper,
borderColor: colors.divider,
},
destructiveButton: {
backgroundColor: `${colors.error.main}18`,
borderColor: `${colors.error.main}40`,
},
actionText: {
color: colors.text.primary,
fontSize: 15,
fontWeight: '700',
},
primaryText: {
color: colors.text.inverse,
},
cancelText: {
color: colors.text.secondary,
fontWeight: '600',
},
destructiveText: {
color: colors.error.main,
},
});
}
const AppDialogHost: React.FC = () => {
const [dialog, setDialog] = useState<DialogPayload | null>(null);
const colors = useAppColors();
const styles = useMemo(() => createDialogHostStyles(colors), [colors]);
useEffect(() => {
const unbind = bindDialogListener((payload) => {
@@ -19,7 +107,7 @@ const AppDialogHost: React.FC = () => {
const actions = useMemo<AlertButton[]>(() => {
if (!dialog?.actions?.length) return [{ text: '确定' }];
return dialog.actions.slice(0, 3);
return dialog.actions;
}, [dialog]);
const onClose = () => {
@@ -101,88 +189,4 @@ const AppDialogHost: React.FC = () => {
);
};
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;

View File

@@ -1,10 +1,10 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import React, { useCallback, useEffect, useMemo, 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';
import { borderRadius, shadows, spacing, useAppColors, type AppColors } from '../../theme';
interface PromptState extends PromptPayload {
id: number;
@@ -12,29 +12,96 @@ interface PromptState extends PromptPayload {
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,
};
function createPromptBarStyles(colors: AppColors) {
return 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,
},
});
}
const AppPromptBar: React.FC = () => {
const insets = useSafeAreaInsets();
const colors = useAppColors();
const styles = useMemo(() => createPromptBarStyles(colors), [colors]);
const styleMap = useMemo<
Record<PromptType, { backgroundColor: string; icon: React.ComponentProps<typeof MaterialCommunityIcons>['name'] }>
>(
() => ({
info: { backgroundColor: colors.background.paper, icon: 'information-outline' },
success: { backgroundColor: colors.background.paper, icon: 'check-circle-outline' },
warning: { backgroundColor: colors.background.paper, icon: 'alert-outline' },
error: { backgroundColor: colors.background.paper, icon: 'alert-circle-outline' },
}),
[colors.background.paper],
);
const iconColorMap = useMemo(
() => ({
info: colors.primary.main,
success: colors.success.main,
warning: colors.warning.dark,
error: colors.error.main,
}),
[colors.primary.main, colors.success.main, colors.warning.dark, colors.error.main],
);
const accentColorMap = useMemo(
() => ({
info: colors.primary.main,
success: colors.success.main,
warning: colors.warning.main,
error: colors.error.main,
}),
[colors.primary.main, colors.success.main, colors.warning.main, colors.error.main],
);
const [prompt, setPrompt] = useState<PromptState | null>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const animation = useRef(new Animated.Value(0)).current;
@@ -131,55 +198,4 @@ const AppPromptBar: React.FC = () => {
);
};
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;

View File

@@ -4,7 +4,7 @@
* 使用 expo-image 实现内存+磁盘双级缓存,头像秒加载
*/
import React from 'react';
import React, { useMemo } from 'react';
import {
View,
TouchableOpacity,
@@ -12,7 +12,7 @@ import {
ViewStyle,
} from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import { colors, borderRadius } from '../../theme';
import { borderRadius, useAppColors, type AppColors } from '../../theme';
import Text from './Text';
type AvatarSource = string | { uri: string } | number | null;
@@ -27,29 +27,50 @@ interface AvatarProps {
style?: ViewStyle;
}
function createAvatarStyles(colors: AppColors) {
return 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,
},
});
}
const Avatar: React.FC<AvatarProps> = ({
source,
size = 40,
name,
onPress,
showBadge = false,
badgeColor = colors.success.main,
badgeColor,
style,
}) => {
// 获取首字母
const colors = useAppColors();
const styles = useMemo(() => createAvatarStyles(colors), [colors]);
const resolvedBadgeColor = badgeColor ?? colors.success.main;
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;
@@ -72,7 +93,6 @@ const Avatar: React.FC<AvatarProps> = ({
);
}
// 显示首字母
const fontSize = size / 2;
return (
<View
@@ -100,7 +120,7 @@ const Avatar: React.FC<AvatarProps> = ({
style={[
styles.badge,
{
backgroundColor: badgeColor,
backgroundColor: resolvedBadgeColor,
width: size * 0.3,
height: size * 0.3,
borderRadius: (size * 0.3) / 2,
@@ -124,23 +144,4 @@ const Avatar: React.FC<AvatarProps> = ({
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;

View File

@@ -3,7 +3,7 @@
* 支持多种变体、尺寸、加载状态和图标
*/
import React from 'react';
import React, { useMemo } from 'react';
import {
TouchableOpacity,
ActivityIndicator,
@@ -13,7 +13,7 @@ import {
TextStyle,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, borderRadius, spacing, fontSizes, shadows } from '../../theme';
import { borderRadius, spacing, fontSizes, useAppColors, type AppColors } from '../../theme';
import Text from './Text';
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'text' | 'danger';
@@ -27,12 +27,86 @@ interface ButtonProps {
size?: ButtonSize;
disabled?: boolean;
loading?: boolean;
icon?: string; // MaterialCommunityIcons name
icon?: string;
iconPosition?: IconPosition;
fullWidth?: boolean;
style?: ViewStyle;
}
function createButtonStyles(colors: AppColors) {
return 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,
},
});
}
const Button: React.FC<ButtonProps> = ({
title,
onPress,
@@ -45,11 +119,12 @@ const Button: React.FC<ButtonProps> = ({
fullWidth = false,
style,
}) => {
// 获取按钮样式
const colors = useAppColors();
const styles = useMemo(() => createButtonStyles(colors), [colors]);
const getButtonStyle = (): ViewStyle[] => {
const baseStyle: ViewStyle[] = [styles.base, styles[`size_${size}`]];
// 变体样式
switch (variant) {
case 'primary':
baseStyle.push(styles.primary);
@@ -68,12 +143,10 @@ const Button: React.FC<ButtonProps> = ({
break;
}
// 禁用状态
if (disabled || loading) {
baseStyle.push(styles.disabled);
}
// 全宽度
if (fullWidth) {
baseStyle.push(styles.fullWidth);
}
@@ -81,7 +154,6 @@ const Button: React.FC<ButtonProps> = ({
return baseStyle;
};
// 获取文本样式
const getTextStyle = (): TextStyle => {
const baseStyle: TextStyle = {
...styles.textBase,
@@ -109,7 +181,6 @@ const Button: React.FC<ButtonProps> = ({
return baseStyle;
};
// 获取图标大小
const getIconSize = (): number => {
switch (size) {
case 'sm':
@@ -121,7 +192,6 @@ const Button: React.FC<ButtonProps> = ({
}
};
// 获取图标颜色
const getIconColor = (): string => {
if (disabled || loading) {
return colors.text.disabled;
@@ -150,9 +220,9 @@ const Button: React.FC<ButtonProps> = ({
{loading ? (
<ActivityIndicator
size="small"
color={variant === 'outline' || variant === 'text'
? colors.primary.main
: colors.text.inverse}
color={
variant === 'outline' || variant === 'text' ? colors.primary.main : colors.text.inverse
}
/>
) : (
<View style={styles.content}>
@@ -179,82 +249,4 @@ const Button: React.FC<ButtonProps> = ({
);
};
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;

View File

@@ -3,9 +3,9 @@
* 白色背景、圆角、阴影,支持点击
*/
import React from 'react';
import React, { useMemo } from 'react';
import { View, TouchableOpacity, StyleSheet, ViewStyle } from 'react-native';
import { colors, borderRadius, spacing, shadows } from '../../theme';
import { borderRadius, spacing, shadows, useAppColors, type AppColors } from '../../theme';
type ShadowSize = 'sm' | 'md' | 'lg';
@@ -13,10 +13,20 @@ interface CardProps {
children: React.ReactNode;
onPress?: () => void;
padding?: number;
shadow?: ShadowSize;
shadow?: ShadowSize | 'none';
style?: ViewStyle;
}
function createCardStyles(colors: AppColors) {
return StyleSheet.create({
card: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
overflow: 'hidden',
},
});
}
const Card: React.FC<CardProps> = ({
children,
onPress,
@@ -24,13 +34,11 @@ const Card: React.FC<CardProps> = ({
shadow = 'none',
style,
}) => {
const colors = useAppColors();
const styles = useMemo(() => createCardStyles(colors), [colors]);
const shadowStyle = shadow !== 'none' ? shadows[shadow as keyof typeof shadows] : undefined;
const cardStyle = [
styles.card,
{ padding },
shadowStyle,
].filter(Boolean);
const cardStyle = [styles.card, { padding }, shadowStyle].filter(Boolean);
if (onPress) {
return (
@@ -47,12 +55,4 @@ const Card: React.FC<CardProps> = ({
return <View style={[...cardStyle, style]}>{children}</View>;
};
const styles = StyleSheet.create({
card: {
backgroundColor: colors.background.paper,
borderRadius: borderRadius.lg,
overflow: 'hidden',
},
});
export default Card;

View File

@@ -5,7 +5,7 @@
import React from 'react';
import { View, StyleSheet, ViewStyle } from 'react-native';
import { colors, spacing } from '../../theme';
import { spacing, useAppColors } from '../../theme';
interface DividerProps {
margin?: number;
@@ -13,16 +13,14 @@ interface DividerProps {
style?: ViewStyle;
}
const Divider: React.FC<DividerProps> = ({
margin = spacing.lg,
color = colors.divider,
style,
}) => {
const Divider: React.FC<DividerProps> = ({ margin = spacing.lg, color, style }) => {
const colors = useAppColors();
const lineColor = color ?? colors.divider;
return (
<View
style={[
styles.divider,
{ marginVertical: margin, backgroundColor: color },
{ marginVertical: margin, backgroundColor: lineColor },
style,
]}
/>

View File

@@ -3,10 +3,10 @@
* 显示空数据时的占位界面,采用现代插图风格设计
*/
import React from 'react';
import { View, StyleSheet, ViewStyle, Dimensions } from 'react-native';
import React, { useMemo } from 'react';
import { View, StyleSheet, ViewStyle } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
import Text from './Text';
import Button from './Button';
@@ -20,6 +20,109 @@ interface EmptyStateProps {
variant?: 'default' | 'modern' | 'compact';
}
function createEmptyStateStyles(colors: AppColors) {
return 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,
},
});
}
const EmptyState: React.FC<EmptyStateProps> = ({
icon = 'folder-open-outline',
title,
@@ -29,7 +132,9 @@ const EmptyState: React.FC<EmptyStateProps> = ({
style,
variant = 'modern',
}) => {
// 现代风格空状态
const colors = useAppColors();
const styles = useMemo(() => createEmptyStateStyles(colors), [colors]);
if (variant === 'modern') {
return (
<View style={[styles.modernContainer, style]}>
@@ -45,19 +150,11 @@ const EmptyState: React.FC<EmptyStateProps> = ({
<View style={styles.decorativeCircle1} />
<View style={styles.decorativeCircle2} />
</View>
<Text
variant="h3"
color={colors.text.primary}
style={styles.modernTitle}
>
<Text variant="h3" color={colors.text.primary} style={styles.modernTitle}>
{title}
</Text>
{description && (
<Text
variant="body"
color={colors.text.secondary}
style={styles.modernDescription}
>
<Text variant="body" color={colors.text.secondary} style={styles.modernDescription}>
{description}
</Text>
)}
@@ -74,7 +171,6 @@ const EmptyState: React.FC<EmptyStateProps> = ({
);
}
// 紧凑风格
if (variant === 'compact') {
return (
<View style={[styles.compactContainer, style]}>
@@ -84,18 +180,13 @@ const EmptyState: React.FC<EmptyStateProps> = ({
color={colors.text.disabled}
style={styles.compactIcon}
/>
<Text
variant="body"
color={colors.text.secondary}
style={styles.compactTitle}
>
<Text variant="body" color={colors.text.secondary} style={styles.compactTitle}>
{title}
</Text>
</View>
);
}
// 默认风格
return (
<View style={[styles.container, style]}>
<MaterialCommunityIcons
@@ -104,19 +195,11 @@ const EmptyState: React.FC<EmptyStateProps> = ({
color={colors.text.disabled}
style={styles.icon}
/>
<Text
variant="h3"
color={colors.text.secondary}
style={styles.title}
>
<Text variant="h3" color={colors.text.secondary} style={styles.title}>
{title}
</Text>
{description && (
<Text
variant="body"
color={colors.text.secondary}
style={styles.description}
>
<Text variant="body" color={colors.text.secondary} style={styles.description}>
{description}
</Text>
)}
@@ -133,110 +216,4 @@ const EmptyState: React.FC<EmptyStateProps> = ({
);
};
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;

View File

@@ -4,7 +4,7 @@
* 使用 expo-image原生支持 GIF/WebP 动图
*/
import React, { useState, useCallback, useEffect, useMemo } from 'react';
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import {
Modal,
View,
@@ -13,7 +13,6 @@ import {
TouchableOpacity,
Text,
StatusBar,
ActivityIndicator,
Alert,
} from 'react-native';
import { Image as ExpoImage } from 'expo-image';
@@ -25,16 +24,14 @@ import {
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';
import { spacing, borderRadius, fontSizes } from '../../theme';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
@@ -84,9 +81,13 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
onSave,
backgroundOpacity = 1,
}) => {
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const isClosingRef = useRef(false);
const currentIndexRef = useRef(initialIndex);
const pendingSwipeDirectionRef = useRef<-1 | 1 | null>(null);
const [currentIndex, setCurrentIndex] = useState(initialIndex);
currentIndexRef.current = currentIndex;
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);
@@ -127,7 +128,6 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
if (visible) {
setCurrentIndex(initialIndex);
setShowControls(true);
setLoading(true);
setError(false);
resetZoom();
StatusBar.setHidden(true, 'fade');
@@ -136,37 +136,61 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
}
}, [visible, initialIndex, resetZoom]);
// 图片变化时重置加载状态和缩放
useEffect(() => {
setLoading(true);
setError(false);
if (visible) {
isClosingRef.current = false;
}
}, [visible]);
useEffect(() => {
return () => {
if (closeTimerRef.current) {
clearTimeout(closeTimerRef.current);
}
};
}, []);
// 图片变化时重置缩放(加载态在 updateIndex / 打开弹窗时同步设置,避免晚一帧仍显示上一张)
useEffect(() => {
resetZoom();
}, [currentImage?.id, resetZoom]);
const updateIndex = useCallback(
(newIndex: number) => {
const clampedIndex = Math.max(0, Math.min(validImages.length - 1, newIndex));
if (clampedIndex === currentIndexRef.current) {
return;
}
setError(false);
setCurrentIndex(clampedIndex);
onIndexChange?.(clampedIndex);
},
[validImages.length, onIndexChange]
);
const updateIndexFromSwipe = useCallback(
(newIndex: number, direction: -1 | 1) => {
pendingSwipeDirectionRef.current = direction;
updateIndex(newIndex);
},
[updateIndex]
);
const toggleControls = useCallback(() => {
setShowControls(prev => !prev);
}, []);
const goToPrev = useCallback(() => {
if (currentIndex > 0) {
updateIndex(currentIndex - 1);
const requestClose = useCallback(() => {
if (isClosingRef.current) {
return;
}
}, [currentIndex, updateIndex]);
const goToNext = useCallback(() => {
if (currentIndex < validImages.length - 1) {
updateIndex(currentIndex + 1);
}
}, [currentIndex, validImages.length, updateIndex]);
isClosingRef.current = true;
// 延迟一个短时间窗口,避免关闭同一触摸触发到底层组件
closeTimerRef.current = setTimeout(() => {
onClose();
}, 120);
}, [onClose]);
// 显示短暂提示
const showToast = useCallback((type: 'success' | 'error') => {
@@ -236,6 +260,30 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
// 滑动切换图片相关状态
const swipeTranslateX = useSharedValue(0);
useEffect(() => {
pendingSwipeDirectionRef.current = null;
swipeTranslateX.value = 0;
}, [currentIndex, swipeTranslateX]);
const switchWithDirection = useCallback(
(targetIndex: number, direction: -1 | 1) => {
updateIndexFromSwipe(targetIndex, direction);
},
[updateIndexFromSwipe]
);
const goToPrev = useCallback(() => {
if (currentIndex > 0) {
switchWithDirection(currentIndex - 1, 1);
}
}, [currentIndex, switchWithDirection]);
const goToNext = useCallback(() => {
if (currentIndex < validImages.length - 1) {
switchWithDirection(currentIndex + 1, -1);
}
}, [currentIndex, validImages.length, switchWithDirection]);
// 统一的滑动手势:放大时拖动,未放大时切换图片
const panGesture = Gesture.Pan()
.activeOffsetX([-10, 10]) // 水平方向需要移动10pt才激活避免与点击冲突
@@ -248,16 +296,6 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
// 放大状态下:拖动图片
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) => {
@@ -275,19 +313,10 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
if (shouldGoNext && currentIndex < validImages.length - 1) {
// 向左滑动,显示下一张
swipeTranslateX.value = withTiming(-SCREEN_WIDTH, { duration: 200 }, () => {
runOnJS(updateIndex)(currentIndex + 1);
swipeTranslateX.value = 0;
});
runOnJS(updateIndexFromSwipe)(currentIndex + 1, -1);
} 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 });
runOnJS(updateIndexFromSwipe)(currentIndex - 1, 1);
}
}
});
@@ -297,7 +326,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
.numberOfTaps(1)
.maxDistance(10)
.onEnd(() => {
runOnJS(onClose)();
runOnJS(requestClose)();
});
// 组合手势:
@@ -326,7 +355,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
visible={visible}
transparent
animationType="fade"
onRequestClose={onClose}
onRequestClose={requestClose}
statusBarTranslucent
>
<GestureHandlerRootView style={styles.root}>
@@ -334,7 +363,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
{/* 顶部控制栏 */}
{showControls && (
<View style={[styles.header, { paddingTop: insets.top + spacing.md }]}>
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
<TouchableOpacity style={styles.closeButton} onPress={requestClose}>
<MaterialCommunityIcons name="close" size={24} color="#FFF" />
</TouchableOpacity>
@@ -351,7 +380,7 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
disabled={saving}
>
{saving ? (
<ActivityIndicator size="small" color="#FFF" />
<MaterialCommunityIcons name="loading" size={24} color="#FFF" />
) : (
<MaterialCommunityIcons name="download" size={24} color="#FFF" />
)}
@@ -363,12 +392,6 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
{/* 图片显示区域 */}
<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" />
@@ -384,15 +407,13 @@ export const ImageGallery: React.FC<ImageGalleryProps> = ({
contentFit="contain"
cachePolicy="disk"
priority="high"
recyclingKey={currentImage.id}
recyclingKey={currentImage.url}
transition={null}
allowDownscaling
onLoadStart={() => setLoading(true)}
onLoad={() => {
setLoading(false);
setError(false);
}}
onError={() => {
setLoading(false);
setError(true);
}}
/>
@@ -523,16 +544,11 @@ const styles = StyleSheet.create({
width: SCREEN_WIDTH,
height: SCREEN_HEIGHT * 0.8,
},
loadingContainer: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
zIndex: 5,
},
errorContainer: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#000',
},
errorText: {
color: '#999',

View File

@@ -5,7 +5,7 @@
* 支持预览图优化
*/
import React, { useMemo, useCallback, useState, useEffect } from 'react';
import React, { useMemo, useCallback, useState, useEffect, createContext, useContext } from 'react';
import {
View,
StyleSheet,
@@ -15,8 +15,8 @@ import {
Text,
Image,
} from 'react-native';
import { SmartImage, ImageSource } from './SmartImage';
import { colors, spacing, borderRadius } from '../../theme';
import { SmartImage } from './SmartImage';
import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme';
import { getPreviewImageUrl, ImageDisplayMode } from '../../utils/imageHelper';
const { width: SCREEN_WIDTH } = Dimensions.get('window');
@@ -40,6 +40,8 @@ export interface ImageGridItem {
url?: string;
thumbnailUrl?: string;
thumbnail_url?: string;
preview_url?: string;
preview_url_large?: string;
width?: number;
height?: number;
}
@@ -99,6 +101,89 @@ const calculateGridDimensions = (
};
};
function createImageGridStyles(colors: AppColors) {
return 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: '49%',
},
gridItem3: {
width: '32.5%',
},
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,
},
});
}
type ImageGridStyles = ReturnType<typeof createImageGridStyles>;
const ImageGridStylesContext = createContext<ImageGridStyles | null>(null);
function useImageGridStyles(): ImageGridStyles {
const ctx = useContext(ImageGridStylesContext);
if (!ctx) {
throw new Error('useImageGridStyles must be used within ImageGrid or CompactImageGrid');
}
return ctx;
}
// ─── 单张图片子组件 ───────────────────────────────────────────────────────────
// 独立成组件,方便用 useState 管理异步加载到的实际尺寸
@@ -108,6 +193,8 @@ interface SingleImageItemProps {
maxHeight: number;
borderRadiusValue: number;
onPress: () => void;
usePreview: boolean;
displayMode: ImageDisplayMode;
}
const SingleImageItem: React.FC<SingleImageItemProps> = ({
@@ -116,15 +203,27 @@ const SingleImageItem: React.FC<SingleImageItemProps> = ({
maxHeight,
borderRadiusValue,
onPress,
usePreview,
displayMode,
}) => {
const styles = useImageGridStyles();
const [aspectRatio, setAspectRatio] = useState<number | null>(null);
const uri = image.uri || image.url || '';
const mainUri = image.uri || image.url || '';
const thumbnailUri =
image.thumbnail_url ||
image.thumbnailUrl ||
image.preview_url ||
'';
useEffect(() => {
if (!uri) return;
if (image.width && image.height) {
setAspectRatio(image.width / image.height);
return;
}
const probeUri = thumbnailUri || mainUri;
if (!probeUri) return;
let cancelled = false;
// 添加超时处理,防止高分辨率图片加载卡住
const timeoutId = setTimeout(() => {
if (!cancelled && aspectRatio === null) {
setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO);
@@ -132,7 +231,7 @@ const SingleImageItem: React.FC<SingleImageItemProps> = ({
}, 3000);
Image.getSize(
uri,
probeUri,
(w, h) => {
if (!cancelled) {
clearTimeout(timeoutId);
@@ -150,7 +249,7 @@ const SingleImageItem: React.FC<SingleImageItemProps> = ({
cancelled = true;
clearTimeout(timeoutId);
};
}, [uri]);
}, [image.width, image.height, thumbnailUri, mainUri]);
const effectiveContainerWidth = containerWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING;
@@ -189,13 +288,19 @@ const SingleImageItem: React.FC<SingleImageItemProps> = ({
width = Math.min(SINGLE_IMAGE_MIN_HEIGHT * aspectRatio, effectiveContainerWidth);
}
const previewForSmart =
usePreview && displayMode ? getPreviewImageUrl(image as any, displayMode) : '';
const useSmartPreview = !!(usePreview && previewForSmart && mainUri && previewForSmart !== mainUri);
return (
<Pressable
onPress={onPress}
style={[styles.singleContainer, { width, height, borderRadius: borderRadiusValue }]}
>
<SmartImage
source={{ uri }}
source={{ uri: mainUri }}
previewUrl={useSmartPreview ? previewForSmart : undefined}
usePreview={useSmartPreview}
style={styles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
@@ -224,7 +329,8 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
displayMode = 'list',
usePreview = true,
}) => {
// 通过 onLayout 拿到容器实际宽度
const colors = useAppColors();
const gridStyles = useMemo(() => createImageGridStyles(colors), [colors]);
const [containerWidth, setContainerWidth] = useState(0);
// 过滤有效图片 - 支持 uri 或 url 字段
@@ -266,11 +372,6 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
const image = displayImages[0];
if (!image) return null;
// 获取预览图 URL
const previewUrl = usePreview && displayMode
? getPreviewImageUrl(image as any, displayMode)
: (image.uri || image.url || '');
return (
<SingleImageItem
image={image}
@@ -278,6 +379,8 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
maxHeight={singleImageMaxHeight}
borderRadiusValue={borderRadiusValue}
onPress={() => handleImagePress(0)}
usePreview={usePreview}
displayMode={displayMode}
/>
);
};
@@ -285,7 +388,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
// 渲染横向双图
const renderHorizontal = () => {
return (
<View style={[styles.horizontalContainer, { gap }]}>
<View style={[gridStyles.horizontalContainer, { gap }]}>
{displayImages.map((image, index) => {
const previewUrl = usePreview && displayMode
? getPreviewImageUrl(image as any, displayMode)
@@ -296,7 +399,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
key={image.id || index}
onPress={() => handleImagePress(index)}
style={[
styles.horizontalItem,
gridStyles.horizontalItem,
{
flex: 1,
aspectRatio: 1,
@@ -306,7 +409,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
>
<SmartImage
source={{ uri: previewUrl }}
style={styles.fullSize}
style={gridStyles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
@@ -320,7 +423,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
// 渲染网格布局
const renderGrid = () => {
return (
<View style={[styles.gridContainer, { gap }]}>
<View style={[gridStyles.gridContainer, { gap }]}>
{displayImages.map((image, index) => {
const isLastVisible = index === displayImages.length - 1;
const showOverlay = isLastVisible && remainingCount > 0 && showMoreOverlay;
@@ -334,8 +437,8 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
key={image.id || index}
onPress={() => handleImagePress(index)}
style={[
styles.gridItem,
gridColumns === 3 ? styles.gridItem3 : styles.gridItem2,
gridStyles.gridItem,
gridColumns === 3 ? gridStyles.gridItem3 : gridStyles.gridItem2,
{
borderRadius: borderRadiusValue,
},
@@ -343,13 +446,13 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
>
<SmartImage
source={{ uri: previewUrl }}
style={styles.fullSize}
style={gridStyles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
{showOverlay && (
<View style={styles.moreOverlay}>
<Text style={styles.moreText}>+{remainingCount}</Text>
<View style={gridStyles.moreOverlay}>
<Text style={gridStyles.moreText}>+{remainingCount}</Text>
</View>
)}
</Pressable>
@@ -379,7 +482,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
const renderColumn = (columnImages: ImageGridItem[], columnIndex: number) => {
return (
<View style={[styles.masonryColumn, { gap }]}>
<View style={[gridStyles.masonryColumn, { gap }]}>
{columnImages.map((image, index) => {
const actualIndex = columnIndex + index * 2;
const aspectRatio = image.width && image.height
@@ -396,7 +499,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
key={image.id || actualIndex}
onPress={() => handleImagePress(actualIndex)}
style={[
styles.masonryItem,
gridStyles.masonryItem,
{
width: itemWidth,
height: Math.max(height, itemWidth * 0.7),
@@ -406,7 +509,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
>
<SmartImage
source={{ uri: previewUrl }}
style={styles.fullSize}
style={gridStyles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
@@ -418,7 +521,7 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
};
return (
<View style={[styles.masonryContainer, { gap }]}>
<View style={[gridStyles.masonryContainer, { gap }]}>
{renderColumn(leftColumn, 0)}
{renderColumn(rightColumn, 1)}
</View>
@@ -446,13 +549,15 @@ export const ImageGrid: React.FC<ImageGridProps> = ({
}
return (
<View
style={[styles.container, style]}
testID={testID}
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}
>
{renderContent()}
</View>
<ImageGridStylesContext.Provider value={gridStyles}>
<View
style={[gridStyles.container, style]}
testID={testID}
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}
>
{renderContent()}
</View>
</ImageGridStylesContext.Provider>
);
};
@@ -472,6 +577,8 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
borderRadius: borderRadiusValue = borderRadius.sm,
...props
}) => {
const colors = useAppColors();
const gridStyles = useMemo(() => createImageGridStyles(colors), [colors]);
const containerWidth = maxWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING - 36 - spacing.sm; // 36是头像宽度
const renderCompactGrid = () => {
@@ -488,7 +595,7 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
<Pressable
onPress={() => props.onImagePress?.(images, 0)}
style={[
styles.compactItem,
gridStyles.compactItem,
{
width: size,
height: size,
@@ -498,7 +605,7 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
>
<SmartImage
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
style={styles.fullSize}
style={gridStyles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
@@ -511,13 +618,13 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
const { itemSize } = calculateGridDimensions(count, containerWidth, gap, columns);
return (
<View style={[styles.compactGrid, { gap }]}>
<View style={[gridStyles.compactGrid, { gap }]}>
{images.slice(0, 6).map((image, index) => (
<Pressable
key={image.id || index}
onPress={() => props.onImagePress?.(images, index)}
style={[
styles.compactItem,
gridStyles.compactItem,
{
width: itemSize,
height: itemSize,
@@ -527,13 +634,13 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
>
<SmartImage
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
style={styles.fullSize}
style={gridStyles.fullSize}
resizeMode="cover"
borderRadius={borderRadiusValue}
/>
{index === 5 && images.length > 6 && (
<View style={styles.moreOverlay}>
<Text style={styles.moreText}>+{images.length - 6}</Text>
<View style={gridStyles.moreOverlay}>
<Text style={gridStyles.moreText}>+{images.length - 6}</Text>
</View>
)}
</Pressable>
@@ -542,86 +649,11 @@ export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
);
};
return <View style={styles.compactContainer}>{renderCompactGrid()}</View>;
return (
<ImageGridStylesContext.Provider value={gridStyles}>
<View style={gridStyles.compactContainer}>{renderCompactGrid()}</View>
</ImageGridStylesContext.Provider>
);
};
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;

View File

@@ -3,7 +3,7 @@
* 支持标签、错误提示、图标、多行输入等
*/
import React, { useState } from 'react';
import React, { useMemo, useState } from 'react';
import {
View,
TextInput,
@@ -13,7 +13,7 @@ import {
TextStyle,
} from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, borderRadius, spacing, fontSizes } from '../../theme';
import { borderRadius, spacing, fontSizes, useAppColors, type AppColors } from '../../theme';
import Text from './Text';
interface InputProps {
@@ -36,6 +36,46 @@ interface InputProps {
autoCorrect?: boolean;
}
function createInputStyles(colors: AppColors) {
return 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,
borderColor: colors.divider,
},
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,
},
});
}
const Input: React.FC<InputProps> = ({
value,
onChangeText,
@@ -55,6 +95,8 @@ const Input: React.FC<InputProps> = ({
keyboardType = 'default',
autoCorrect = true,
}) => {
const colors = useAppColors();
const styles = useMemo(() => createInputStyles(colors), [colors]);
const [isFocused, setIsFocused] = useState(false);
const getBorderColor = () => {
@@ -87,11 +129,7 @@ const Input: React.FC<InputProps> = ({
/>
)}
<TextInput
style={[
styles.input,
multiline && styles.multilineInput,
inputStyle,
]}
style={[styles.input, multiline && styles.multilineInput, inputStyle]}
value={value}
onChangeText={onChangeText}
placeholder={placeholder}
@@ -105,15 +143,11 @@ const Input: React.FC<InputProps> = ({
autoCapitalize={autoCapitalize}
keyboardType={keyboardType}
autoCorrect={autoCorrect}
// 确保光标可见
cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`}
/>
{rightIcon && (
<TouchableOpacity
onPress={onRightIconPress}
disabled={!onRightIconPress}
>
<TouchableOpacity onPress={onRightIconPress} disabled={!onRightIconPress}>
<MaterialCommunityIcons
name={rightIcon as any}
size={20}
@@ -132,43 +166,4 @@ const Input: React.FC<InputProps> = ({
);
};
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,
// 减少非焦点状态的边框明显度
borderColor: colors.divider,
},
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;

View File

@@ -3,9 +3,9 @@
* 支持不同尺寸、全屏模式
*/
import React from 'react';
import React, { useMemo } from 'react';
import { View, ActivityIndicator, StyleSheet, ViewStyle } from 'react-native';
import { colors } from '../../theme';
import { useAppColors, type AppColors } from '../../theme';
type LoadingSize = 'sm' | 'md' | 'lg';
@@ -16,12 +16,32 @@ interface LoadingProps {
style?: ViewStyle;
}
function createLoadingStyles(colors: AppColors) {
return StyleSheet.create({
container: {
padding: 20,
alignItems: 'center',
justifyContent: 'center',
},
fullScreen: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.background.default,
},
});
}
const Loading: React.FC<LoadingProps> = ({
size = 'md',
color = colors.primary.main,
color,
fullScreen = false,
style,
}) => {
const colors = useAppColors();
const styles = useMemo(() => createLoadingStyles(colors), [colors]);
const indicatorColor = color ?? colors.primary.main;
const getSize = (): 'small' | 'large' | undefined => {
switch (size) {
case 'sm':
@@ -36,30 +56,16 @@ const Loading: React.FC<LoadingProps> = ({
if (fullScreen) {
return (
<View style={[styles.fullScreen, style]}>
<ActivityIndicator size={getSize()} color={color} />
<ActivityIndicator size={getSize()} color={indicatorColor} />
</View>
);
}
return (
<View style={[styles.container, style]}>
<ActivityIndicator size={getSize()} color={color} />
<ActivityIndicator size={getSize()} color={indicatorColor} />
</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;

View File

@@ -0,0 +1,137 @@
/**
* SearchBar 搜索栏组件
* 用于搜索内容
*/
import React, { useMemo, useState } from 'react';
import { View, TextInput, TouchableOpacity, StyleSheet } from 'react-native';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { spacing, fontSizes, borderRadius, useAppColors, type AppColors } from '../../theme';
interface SearchBarProps {
value: string;
onChangeText: (text: string) => void;
onSubmit: () => void;
placeholder?: string;
onFocus?: () => void;
onBlur?: () => void;
autoFocus?: boolean;
}
function createSearchBarStyles(colors: AppColors) {
return StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: colors.background.paper,
borderRadius: borderRadius.full,
paddingHorizontal: spacing.xs,
height: 46,
borderWidth: 1,
borderColor: colors.divider,
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
containerFocused: {
borderColor: colors.primary.main,
shadowColor: 'transparent',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0,
shadowRadius: 0,
elevation: 0,
},
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',
},
});
}
const SearchBar: React.FC<SearchBarProps> = ({
value,
onChangeText,
onSubmit,
placeholder = '搜索...',
onFocus,
onBlur,
autoFocus = false,
}) => {
const colors = useAppColors();
const styles = useMemo(() => createSearchBarStyles(colors), [colors]);
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}
cursorColor={colors.primary.main}
selectionColor={`${colors.primary.main}40`}
/>
{value.length > 0 && (
<TouchableOpacity
onPress={() => onChangeText('')}
style={styles.clearButton}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="close" size={14} color={colors.text.secondary} />
</TouchableOpacity>
)}
</View>
);
};
export default SearchBar;

View File

@@ -4,7 +4,7 @@
* 基于 expo-image 封装,原生支持 GIF/WebP 动图
*/
import React, { useState, useCallback, useRef, useEffect } from 'react';
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import {
View,
StyleSheet,
@@ -13,10 +13,40 @@ import {
ActivityIndicator,
Pressable,
StyleProp,
Platform,
} from 'react-native';
import { Image as ExpoImage } from 'expo-image';
import { MaterialCommunityIcons } from '@expo/vector-icons';
import { colors, borderRadius } from '../../theme';
import { borderRadius, useAppColors, type AppColors } from '../../theme';
function createSmartImageStyles(colors: AppColors) {
return 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: `${colors.background.paper}E6`,
},
errorContainer: {
padding: 12,
borderRadius: borderRadius.md,
backgroundColor: `${colors.text.primary}0D`,
},
});
}
// 图片加载状态
export type ImageLoadState = 'loading' | 'success' | 'error';
@@ -61,6 +91,8 @@ export interface SmartImageProps {
usePreview?: boolean;
/** 是否懒加载 */
lazyLoad?: boolean;
/** Web 端 IntersectionObserver rootMargin略提前加载进入视口附近的图 */
lazyRootMargin?: string;
/** 缓存策略 */
cachePolicy?: 'memory' | 'disk' | 'memory-disk' | 'none';
}
@@ -85,11 +117,15 @@ export const SmartImage: React.FC<SmartImageProps> = ({
previewUrl,
usePreview = false,
lazyLoad = false,
lazyRootMargin = '160px',
cachePolicy = 'memory-disk',
}) => {
const colors = useAppColors();
const styles = useMemo(() => createSmartImageStyles(colors), [colors]);
const [loadState, setLoadState] = useState<ImageLoadState>('loading');
const [isVisible, setIsVisible] = useState(!lazyLoad);
const [isVisible, setIsVisible] = useState(() => !lazyLoad || Platform.OS !== 'web');
const [shouldLoadOriginal, setShouldLoadOriginal] = useState(false);
const lazyTargetRef = useRef<View>(null);
// 解析图片源 - 支持 uri 或 url 字段
const imageUri = typeof source === 'string'
@@ -99,6 +135,39 @@ export const SmartImage: React.FC<SmartImageProps> = ({
// 解析预览图 URL
const previewUri = typeof previewUrl === 'string' ? previewUrl : '';
useEffect(() => {
setShouldLoadOriginal(false);
setLoadState('loading');
}, [imageUri]);
useEffect(() => {
if (!lazyLoad) {
setIsVisible(true);
return;
}
if (Platform.OS !== 'web') {
setIsVisible(true);
return;
}
setIsVisible(false);
const node = lazyTargetRef.current as unknown as Element | null;
if (!node || typeof IntersectionObserver === 'undefined') {
setIsVisible(true);
return;
}
const observer = new IntersectionObserver(
entries => {
if (entries.some(e => e.isIntersecting)) {
setIsVisible(true);
observer.disconnect();
}
},
{ rootMargin: lazyRootMargin }
);
observer.observe(node);
return () => observer.disconnect();
}, [lazyLoad, lazyRootMargin, imageUri]);
// 智能选择图片 URL
const getImageUrl = useCallback((): string => {
// 如果应该加载原图(预览图已加载完成或没有预览图)
@@ -200,11 +269,13 @@ export const SmartImage: React.FC<SmartImageProps> = ({
);
}
// 如果是懒加载且不可见,显示占位
// 如果是懒加载且不可见,显示占位Web 上由 IntersectionObserver 驱动进入视口后再加载)
if (lazyLoad && !isVisible) {
return (
<View
style={[containerStyle, style as ViewStyle, { backgroundColor: '#F5F5F5' }]}
ref={Platform.OS === 'web' ? lazyTargetRef : undefined}
collapsable={false}
style={[containerStyle, style as ViewStyle, { backgroundColor: colors.background.default }]}
testID={testID}
/>
);
@@ -288,36 +359,15 @@ export const VariantImage: React.FC<ImageVariantProps> = ({
<SmartImage
{...props}
style={finalStyle}
imageStyle={[styles.variantImage, imageStyle]}
imageStyle={[variantStyles.variantImage, imageStyle]}
/>
);
};
const styles = StyleSheet.create({
image: {
flex: 1,
width: '100%',
height: '100%',
},
const variantStyles = StyleSheet.create({
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;

View File

@@ -5,7 +5,7 @@
import React from 'react';
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle } from 'react-native';
import { colors, fontSizes } from '../../theme';
import { useAppColors, fontSizes } from '../../theme';
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
@@ -60,6 +60,7 @@ const Text: React.FC<CustomTextProps> = ({
style,
...props
}) => {
const colors = useAppColors();
const textStyle = [
styles.base,
variantStyles[variant],

View File

@@ -16,6 +16,7 @@ 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 AppBackButton } from './AppBackButton';
// 图片相关组件
export { default as SmartImage } from './SmartImage';

290
src/core/entities/Post.ts Normal file
View File

@@ -0,0 +1,290 @@
/**
* Post Entity - 帖子领域实体
* 定义帖子的核心属性和行为,不依赖任何外部框架
*/
// ==================== 帖子状态枚举 ====================
export type PostStatus = 'published' | 'draft' | 'deleted';
// ==================== 值对象 ====================
/**
* 帖子作者信息
*/
export interface PostAuthor {
id: string;
username: string;
nickname?: string;
avatar?: string;
is_following?: boolean;
is_following_me?: boolean;
}
/**
* 帖子图片信息
*/
export interface PostImage {
url: string;
width?: number;
height?: number;
thumbnailUrl?: string;
}
/**
* 帖子标签
*/
export interface PostTag {
id: string;
name: string;
}
// ==================== 主实体 ====================
/**
* 帖子实体
*/
export interface Post {
/** 帖子唯一标识 */
id: string;
/** 作者ID */
authorId: string;
/** 作者信息 */
author?: PostAuthor;
/** 帖子标题 */
title: string;
/** 帖子内容 */
content: string;
/** 图片列表 */
images: PostImage[];
/** 点赞数 (camelCase) */
likesCount: number;
/** 评论数 (camelCase) */
commentsCount: number;
/** 分享数 (camelCase) */
sharesCount: number;
/** 浏览数 (camelCase) */
viewsCount: number;
/** 收藏数 (camelCase) */
favoritesCount: number;
/** 当前用户是否已点赞 (camelCase) */
isLiked: boolean;
/** 当前用户是否已收藏 (camelCase) */
isFavorited: boolean;
/** 是否置顶 (camelCase) */
isPinned: boolean;
/** 帖子状态 */
status: PostStatus;
/** 所属频道ID */
channelId?: string;
/** 频道摘要(列表 API 填充,供 PostCard 等展示) */
channel?: { id: string; name: string } | null;
/** 标签列表 */
tags: string[];
/** 创建时间 */
createdAt: string;
/** 更新时间 */
updatedAt: string;
/** 用户编辑内容的时间(与统计类更新解耦) */
contentEditedAt?: string;
// ==================== 向后兼容字段 (snake_case) ====================
// 这些字段用于与旧代码兼容,新代码应使用 camelCase 版本
/** @deprecated 请使用 likesCount */
likes_count?: number;
/** @deprecated 请使用 commentsCount */
comments_count?: number;
/** @deprecated 请使用 favoritesCount */
favorites_count?: number;
/** @deprecated 请使用 sharesCount */
shares_count?: number;
/** @deprecated 请使用 viewsCount */
views_count?: number;
/** @deprecated 请使用 isLiked */
is_liked?: boolean;
/** @deprecated 请使用 isFavorited */
is_favorited?: boolean;
/** @deprecated 请使用 isPinned */
is_pinned?: boolean;
/** @deprecated 请使用 authorId */
user_id?: string;
/** @deprecated 请使用 createdAt */
created_at?: string;
/** @deprecated 请使用 updatedAt */
updated_at?: string;
/** @deprecated 请使用 contentEditedAt */
content_edited_at?: string;
/** @deprecated 请使用 channelId */
channel_id?: string;
/** @deprecated 请使用 status */
status_str?: string;
/** 投票帖子标识 (部分旧代码使用) */
is_vote?: boolean;
/** 锁定状态 (部分旧代码使用) */
is_locked?: boolean;
/** 置顶评论 (部分旧代码使用) */
top_comment?: any;
}
// ==================== 工厂函数 ====================
/**
* 创建帖子作者信息
*/
export const createPostAuthor = (data: Partial<PostAuthor>): PostAuthor => ({
id: data.id || '',
username: data.username || '',
nickname: data.nickname,
avatar: data.avatar,
is_following: data.is_following,
is_following_me: data.is_following_me,
});
/**
* 创建帖子图片信息
*/
export const createPostImage = (data: Partial<PostImage>): PostImage => ({
url: data.url || '',
width: data.width,
height: data.height,
thumbnailUrl: data.thumbnailUrl,
});
/**
* 创建帖子实体
*/
export const createPost = (data: Partial<Post>): Post => ({
id: data.id || '',
authorId: data.authorId || '',
author: data.author,
title: data.title || '',
content: data.content || '',
images: data.images || [],
likesCount: data.likesCount || 0,
commentsCount: data.commentsCount || 0,
sharesCount: data.sharesCount || 0,
viewsCount: data.viewsCount || 0,
favoritesCount: data.favoritesCount || 0,
isLiked: data.isLiked || false,
isFavorited: data.isFavorited || false,
isPinned: data.isPinned || false,
status: data.status || 'published',
channelId: data.channelId,
tags: data.tags || [],
createdAt: data.createdAt || new Date().toISOString(),
updatedAt: data.updatedAt || new Date().toISOString(),
});
// ==================== 类型守卫 ====================
/**
* 检查是否为有效的帖子状态
*/
export const isValidPostStatus = (status: string): status is PostStatus => {
return ['published', 'draft', 'deleted'].includes(status);
};
/**
* 检查帖子是否已发布
*/
export const isPostPublished = (post: Post): boolean => {
return post.status === 'published';
};
/**
* 检查帖子是否已删除
*/
export const isPostDeleted = (post: Post): boolean => {
return post.status === 'deleted';
};
/**
* 检查帖子是否为草稿
*/
export const isPostDraft = (post: Post): boolean => {
return post.status === 'draft';
};
// ==================== 辅助函数 ====================
/**
* 检查当前用户是否为帖子作者
*/
export const isPostAuthor = (post: Post, userId: string): boolean => {
return post.authorId === userId;
};
/**
* 获取帖子的主要图片(第一张图片)
*/
export const getPostThumbnail = (post: Post): PostImage | undefined => {
return post.images[0];
};
/**
* 检查帖子是否包含图片
*/
export const hasPostImages = (post: Post): boolean => {
return post.images.length > 0;
};
/**
* 获取帖子的显示标题(优先使用标题,否则使用内容摘要)
*/
export const getPostDisplayTitle = (post: Post, maxLength: number = 50): string => {
if (post.title) {
return post.title;
}
if (post.content.length > maxLength) {
return post.content.substring(0, maxLength) + '...';
}
return post.content;
};
/**
* 计算帖子的互动总数
*/
export const getPostTotalEngagement = (post: Post): number => {
return post.likesCount + post.commentsCount + post.sharesCount + post.favoritesCount;
};
/**
* 根据创建时间 ISO 字符串格式化为列表用相对时间(与历史 formatPostTime 行为一致)
*/
export const formatPostCreatedAtString = (createdAt: string | undefined | null): string => {
if (!createdAt) return '';
const createdAtDate = new Date(createdAt);
if (Number.isNaN(createdAtDate.getTime())) return '';
const now = new Date();
const diffMs = now.getTime() - createdAtDate.getTime();
if (diffMs < 0) {
return createdAtDate.toLocaleDateString('zh-CN');
}
const diffSeconds = Math.floor(diffMs / 1000);
const diffMinutes = Math.floor(diffSeconds / 60);
const diffHours = Math.floor(diffMinutes / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffSeconds < 60) {
return '刚刚';
}
if (diffMinutes < 60) {
return `${diffMinutes}分钟前`;
}
if (diffHours < 24) {
return `${diffHours}小时前`;
}
if (diffDays < 7) {
return `${diffDays}天前`;
}
return createdAtDate.toLocaleDateString('zh-CN');
};
/**
* 格式化帖子创建时间为相对时间描述
*/
export const formatPostTime = (post: Post): string => {
return formatPostCreatedAtString(post.createdAt);
};

File diff suppressed because it is too large Load Diff

View File

@@ -72,12 +72,7 @@ export class ApiDataSource implements IApiDataSource {
async delete<T>(url: string, data?: any): Promise<T> {
try {
const fullUrl = this.buildUrl(url);
// 处理带 request body 的 DELETE 请求
if (data) {
const response = await api.request<T>('DELETE', fullUrl, undefined, data);
return response.data;
}
const response = await api.delete<T>(fullUrl);
const response = await api.delete<T>(fullUrl, data);
return response.data;
} catch (error) {
this.handleError(error, 'DELETE');

View File

@@ -20,6 +20,7 @@ export class LocalDataSource implements ILocalDataSource {
private db: SQLite.SQLiteDatabase | null = null;
private dbName: string;
private initialized = false;
private initializingPromise: Promise<void> | null = null;
constructor(config: LocalDataSourceConfig = {}) {
// 如果提供了userId使用用户专属数据库
@@ -34,6 +35,21 @@ export class LocalDataSource implements ILocalDataSource {
return;
}
// 防止并发初始化导致连接状态竞争
if (this.initializingPromise) {
await this.initializingPromise;
return;
}
this.initializingPromise = this.doInitialize();
try {
await this.initializingPromise;
} finally {
this.initializingPromise = null;
}
}
private async doInitialize(): Promise<void> {
try {
// 使用全局实例管理
if (dbInstance && currentDbName === this.dbName) {
@@ -132,6 +148,12 @@ export class LocalDataSource implements ILocalDataSource {
updatedAt TEXT NOT NULL,
PRIMARY KEY (groupId, userId)
)`,
// 帖子缓存表
`CREATE TABLE IF NOT EXISTS posts_cache (
id TEXT PRIMARY KEY NOT NULL,
data TEXT NOT NULL,
updatedAt TEXT NOT NULL
)`,
];
for (const sql of tables) {
@@ -183,7 +205,10 @@ export class LocalDataSource implements ILocalDataSource {
async query<T>(sql: string, params?: any[]): Promise<T[]> {
try {
const db = this.ensureDb();
return await db.getAllAsync<T>(sql, params);
if (params && params.length > 0) {
return await db.getAllAsync<T>(sql, params as any);
}
return await db.getAllAsync<T>(sql);
} catch (error) {
this.handleError(error, 'QUERY');
}
@@ -192,7 +217,10 @@ export class LocalDataSource implements ILocalDataSource {
async getFirst<T>(sql: string, params?: any[]): Promise<T | null> {
try {
const db = this.ensureDb();
return await db.getFirstAsync<T>(sql, params);
if (params && params.length > 0) {
return await db.getFirstAsync<T>(sql, params as any);
}
return await db.getFirstAsync<T>(sql);
} catch (error) {
this.handleError(error, 'GET_FIRST');
}
@@ -210,8 +238,22 @@ export class LocalDataSource implements ILocalDataSource {
async run(sql: string, params?: any[]): Promise<{ lastInsertRowId: number; changes: number }> {
try {
const db = this.ensureDb();
return await db.runAsync(sql, params);
if (params && params.length > 0) {
return await db.runAsync(sql, params as any);
}
return await db.runAsync(sql);
} catch (error) {
if (this.isRecoverableError(error)) {
this.db = null;
this.initialized = false;
dbInstance = null;
await this.initialize();
const db = this.ensureDb();
if (params && params.length > 0) {
return await db.runAsync(sql, params as any);
}
return await db.runAsync(sql);
}
this.handleError(error, 'RUN');
}
}

View File

@@ -212,7 +212,7 @@ export class ConversationMapper {
id: String(user.id || ''),
username: user.username || '',
nickname: user.nickname,
avatar: user.avatar,
avatar: user.avatar ?? undefined,
};
}
@@ -220,19 +220,31 @@ export class ConversationMapper {
* 映射群组 API 响应
*/
private static mapGroupFromApi(group: GroupResponse): GroupModel {
// 将数字类型的 join_type 转换为字符串类型
// JoinType: 0 = 允许任何人, 1 = 需要审批, 2 = 不允许
const mapJoinType = (joinType: number): 'anyone' | 'approval' | 'invite' => {
switch (joinType) {
case 0: return 'anyone';
case 1: return 'approval';
case 2: return 'invite';
default: return 'approval';
}
};
return {
id: String(group.id || ''),
name: group.name || '',
avatar: group.avatar,
description: group.description,
announcement: group.announcement,
// GroupResponse 没有 announcement 和 updated_at 字段,使用默认值
announcement: undefined,
ownerId: String(group.owner_id || ''),
memberCount: group.member_count || 0,
maxMemberCount: group.max_member_count || 500,
joinType: group.join_type || 'approval',
maxMemberCount: group.max_members || 500,
joinType: mapJoinType(group.join_type),
isMuted: group.mute_all || false,
createdAt: new Date(group.created_at || Date.now()),
updatedAt: new Date(group.updated_at || Date.now()),
updatedAt: new Date(group.created_at || Date.now()),
};
}

View File

@@ -32,13 +32,16 @@ export class MessageMapper {
* 将 API 响应转换为应用模型
*/
static fromApiResponse(response: MessageResponse): MessageModel {
const textSegment = response.segments?.find(s => s.type === 'text');
const content = textSegment?.data?.text || textSegment?.data?.content || '';
return {
id: String(response.id || ''),
conversationId: String(response.conversation_id || ''),
senderId: String(response.sender_id || ''),
content: response.content || '',
type: response.message_type || 'text',
isRead: response.is_read || false,
content,
type: 'text',
isRead: false,
createdAt: new Date(response.created_at || Date.now()),
seq: response.seq || 0,
status: response.status || 'normal',
@@ -169,15 +172,12 @@ export class MessageMapper {
}
}
/**
* 映射发送者信息
*/
private static mapSenderFromApi(sender: UserDTO): UserModel {
return {
id: String(sender.id || ''),
username: sender.username || '',
nickname: sender.nickname,
avatar: sender.avatar,
avatar: sender.avatar || undefined,
};
}
}

View File

@@ -4,40 +4,46 @@
*/
import { PostModel, UserModel } from '../models';
import type { Post } from '../../types';
import type { PostDTO } from '../../types/dto';
export class PostMapper {
/**
* 将 API 响应转换为应用模型
*/
static fromApiResponse(response: Post): PostModel {
static fromApiResponse(response: PostDTO): PostModel {
return {
id: String(response.id || ''),
authorId: String(response.author_id || ''),
authorId: String(response.user_id || ''),
author: response.author ? this.mapAuthorFromApi(response.author) : undefined,
title: response.title || '',
content: response.content || '',
images: response.images || [],
likeCount: response.like_count || 0,
commentCount: response.comment_count || 0,
shareCount: response.share_count || 0,
viewCount: response.view_count || 0,
favoriteCount: response.favorite_count || 0,
images: response.images?.map(img => img.url) || [],
likeCount: response.likes_count || 0,
commentCount: response.comments_count || 0,
shareCount: response.shares_count || 0,
viewCount: response.views_count || 0,
favoriteCount: response.favorites_count || 0,
isLiked: response.is_liked || false,
isFavorited: response.is_favorited || false,
isTop: response.is_top || false,
status: response.status || 'published',
communityId: response.community_id,
tags: response.tags || [],
isTop: response.is_pinned || false,
status: (response.status || 'published') as 'published' | 'draft' | 'deleted',
channelId: response.channel_id,
channel:
response.channel && response.channel.id
? { id: String(response.channel.id), name: String(response.channel.name || '') }
: undefined,
tags: [],
createdAt: new Date(response.created_at || Date.now()),
updatedAt: new Date(response.updated_at || Date.now()),
// 空字符串/缺省时用 created_at避免误用 Date.now() 导致全部显示「刚修改」
updatedAt: new Date(
response.updated_at ||
response.created_at ||
Date.now()
),
};
}
/**
* 将 API 响应数组转换为应用模型数组
*/
static fromApiResponseList(responses: Post[]): PostModel[] {
static fromApiResponseList(responses: PostDTO[]): PostModel[] {
return responses.map(r => this.fromApiResponse(r));
}
@@ -56,8 +62,8 @@ export class PostMapper {
if (model.images !== undefined) {
request.images = model.images;
}
if (model.communityId !== undefined) {
request.community_id = model.communityId;
if (model.channelId !== undefined) {
request.channel_id = model.channelId;
}
if (model.tags !== undefined) {
request.tags = model.tags;
@@ -73,7 +79,7 @@ export class PostMapper {
title: string,
content: string,
images?: string[],
communityId?: string
channelId?: string
): Record<string, any> {
const request: Record<string, any> = {
title,
@@ -82,8 +88,8 @@ export class PostMapper {
if (images && images.length > 0) {
request.images = images;
}
if (communityId) {
request.community_id = communityId;
if (channelId) {
request.channel_id = channelId;
}
return request;
}

View File

@@ -4,7 +4,7 @@
*/
import { UserModel } from '../models';
import type { UserDTO, User } from '../../types/dto';
import type { UserDTO } from '../../types/dto';
// 数据库用户记录类型
export interface UserDbRecord {
@@ -14,9 +14,6 @@ export interface UserDbRecord {
}
export class UserMapper {
/**
* 将 API DTO 转换为应用模型
*/
static fromDTO(dto: UserDTO): UserModel {
return {
id: String(dto.id || ''),
@@ -32,39 +29,10 @@ export class UserMapper {
followingCount: dto.following_count,
postsCount: dto.posts_count,
isFollowing: dto.is_following,
isBlocked: dto.is_blocked,
createdAt: dto.created_at ? new Date(dto.created_at) : undefined,
updatedAt: dto.updated_at ? new Date(dto.updated_at) : undefined,
};
}
/**
* 将 User 类型转换为应用模型
*/
static fromUser(user: User): UserModel {
return {
id: String(user.id || ''),
username: user.username || '',
nickname: user.nickname,
avatar: user.avatar,
bio: user.bio,
website: user.website,
location: user.location,
email: user.email,
phone: user.phone,
followersCount: user.followers_count,
followingCount: user.following_count,
postsCount: user.posts_count,
isFollowing: user.is_following,
isBlocked: user.is_blocked,
createdAt: user.created_at ? new Date(user.created_at) : undefined,
updatedAt: user.updated_at ? new Date(user.updated_at) : undefined,
};
}
/**
* 将 DTO 数组转换为应用模型数组
*/
static fromDTOList(dtos: UserDTO[]): UserModel[] {
return dtos.map(dto => this.fromDTO(dto));
}
@@ -88,20 +56,19 @@ export class UserMapper {
const dto: UserDTO = {
id: model.id,
username: model.username,
nickname: model.nickname,
avatar: model.avatar,
bio: model.bio,
website: model.website,
location: model.location,
nickname: model.nickname || '',
avatar: model.avatar || '',
cover_url: '',
bio: model.bio || '',
website: model.website || '',
location: model.location || '',
email: model.email,
phone: model.phone,
followers_count: model.followersCount,
following_count: model.followingCount,
posts_count: model.postsCount,
followers_count: model.followersCount || 0,
following_count: model.followingCount || 0,
posts_count: model.postsCount || 0,
is_following: model.isFollowing,
is_blocked: model.isBlocked,
created_at: model.createdAt?.toISOString(),
updated_at: model.updatedAt?.toISOString(),
created_at: model.createdAt?.toISOString() || '',
};
return {
@@ -149,20 +116,19 @@ export class UserMapper {
return {
id: model.id,
username: model.username,
nickname: model.nickname,
avatar: model.avatar,
bio: model.bio,
website: model.website,
location: model.location,
nickname: model.nickname || '',
avatar: model.avatar || '',
cover_url: '',
bio: model.bio || '',
website: model.website || '',
location: model.location || '',
email: model.email,
phone: model.phone,
followers_count: model.followersCount,
following_count: model.followingCount,
posts_count: model.postsCount,
followers_count: model.followersCount || 0,
following_count: model.followingCount || 0,
posts_count: model.postsCount || 0,
is_following: model.isFollowing,
is_blocked: model.isBlocked,
created_at: model.createdAt?.toISOString(),
updated_at: model.updatedAt?.toISOString(),
created_at: model.createdAt?.toISOString() || '',
};
}
}

View File

@@ -85,7 +85,9 @@ export interface PostModel {
isFavorited: boolean;
isTop: boolean;
status: 'published' | 'draft' | 'deleted';
communityId?: string;
channelId?: string;
/** 频道展示用(与 PostDTO.channel 一致) */
channel?: { id: string; name: string } | null;
tags?: string[];
createdAt: Date;
updatedAt: Date;

View File

@@ -0,0 +1,579 @@
/**
* PostRepository - 帖子仓库实现
* 实现IPostRepository接口封装帖子相关的数据访问逻辑
*/
import { ApiDataSource, apiDataSource } from '../datasources/ApiDataSource';
import { LocalDataSource, localDataSource } from '../datasources/LocalDataSource';
import { PostMapper } from '../mappers/PostMapper';
import type { Post, PostImage, PostStatus } from '../../core/entities/Post';
import { createPost } from '../../core/entities/Post';
import type {
IPostRepository,
GetPostsParams,
CreatePostData,
UpdatePostData,
PostsResult,
SearchPostsParams,
} from './interfaces/IPostRepository';
// ==================== 类型定义 ====================
/**
* API帖子响应类型
*/
interface PostApiResponse {
id: number;
user_id: number;
title: string;
content: string;
images?: Array<{ url: string; width?: number; height?: number; thumbnail_url?: string }>;
likes_count: number;
comments_count: number;
shares_count: number;
views_count: number;
favorites_count: number;
is_liked: boolean;
is_favorited: boolean;
is_pinned: boolean;
status: string;
channel_id?: string;
tags?: string[];
created_at: string;
updated_at: string;
content_edited_at?: string;
channel?: { id: string; name: string } | null;
author?: {
id: number;
username: string;
nickname?: string;
avatar?: string;
is_following?: boolean;
is_following_me?: boolean;
};
}
/**
* API帖子列表响应类型
*/
interface PostsListApiResponse {
list: PostApiResponse[];
has_more?: boolean;
next_cursor?: string;
total?: number;
page?: number;
page_size?: number;
total_pages?: number;
}
/**
* 缓存的帖子数据
*/
interface CachedPost {
id: string;
data: string;
updatedAt: string;
}
// ==================== PostRepository 实现 ====================
export class PostRepository implements IPostRepository {
private api: ApiDataSource;
private localDb: LocalDataSource;
private memoryCache: Map<string, { post: Post; timestamp: number }> = new Map();
private readonly CACHE_TTL = 5 * 60 * 1000; // 5分钟缓存过期时间
constructor(api: ApiDataSource, localDb: LocalDataSource) {
this.api = api;
this.localDb = localDb;
}
// ==================== 私有辅助方法 ====================
/**
* 将API响应转换为领域实体
* 同时设置 camelCase 和 snake_case 字段以保持向后兼容
*/
private mapToPost(response: PostApiResponse): Post {
const model = PostMapper.fromApiResponse(response as any);
return {
// camelCase 字段(新标准)
id: model.id,
authorId: model.authorId,
author: model.author ? {
id: model.author.id,
username: model.author.username,
nickname: model.author.nickname,
avatar: model.author.avatar,
// 关注关系直接透传 API 字段,供详情页关注按钮状态使用
is_following: response.author?.is_following,
is_following_me: response.author?.is_following_me,
} : undefined,
title: model.title,
content: model.content,
images: (model.images || []).map(url => ({
url,
width: undefined,
height: undefined,
})) as PostImage[],
likesCount: model.likeCount,
commentsCount: model.commentCount,
sharesCount: model.shareCount,
viewsCount: model.viewCount,
favoritesCount: model.favoriteCount,
isLiked: model.isLiked,
isFavorited: model.isFavorited,
isPinned: model.isTop,
status: model.status,
channelId: model.channelId,
channel: model.channel,
tags: model.tags || [],
createdAt: model.createdAt.toISOString(),
updatedAt: model.updatedAt.toISOString(),
contentEditedAt: response.content_edited_at,
// snake_case 字段(向后兼容)
likes_count: model.likeCount,
comments_count: model.commentCount,
favorites_count: model.favoriteCount,
shares_count: model.shareCount,
views_count: model.viewCount,
is_liked: model.isLiked,
is_favorited: model.isFavorited,
is_pinned: model.isTop,
user_id: model.authorId,
created_at: model.createdAt.toISOString(),
updated_at: model.updatedAt.toISOString(),
content_edited_at: response.content_edited_at,
channel_id: model.channelId,
};
}
/**
* 从内存缓存获取帖子
*/
private getFromMemoryCache(id: string): Post | null {
const cached = this.memoryCache.get(id);
if (!cached) return null;
// 检查缓存是否过期
if (Date.now() - cached.timestamp > this.CACHE_TTL) {
this.memoryCache.delete(id);
return null;
}
return cached.post;
}
/**
* 保存帖子到内存缓存
*/
private saveToMemoryCache(post: Post): void {
this.memoryCache.set(post.id, {
post,
timestamp: Date.now(),
});
}
/**
* 从本地数据库获取缓存的帖子
*/
private async getFromLocalCache(id: string): Promise<Post | null> {
try {
await this.localDb.initialize();
const result = await this.localDb.getFirst<CachedPost>(
'SELECT * FROM posts_cache WHERE id = ?',
[id]
);
if (!result) return null;
const post = JSON.parse(result.data) as Post;
return post;
} catch (error) {
console.error('[PostRepository] 获取本地缓存失败:', error);
return null;
}
}
/**
* 保存帖子到本地缓存
*/
private async saveToLocalCache(post: Post): Promise<void> {
try {
await this.localDb.initialize();
await this.localDb.enqueueWrite(async () => {
await this.localDb.run(
`INSERT OR REPLACE INTO posts_cache (id, data, updatedAt) VALUES (?, ?, ?)`,
[post.id, JSON.stringify(post), new Date().toISOString()]
);
});
} catch (error) {
console.error('[PostRepository] 保存本地缓存失败:', error);
}
}
/**
* 清除帖子的本地缓存
*/
private async clearLocalCache(id: string): Promise<void> {
try {
await this.localDb.initialize();
await this.localDb.enqueueWrite(async () => {
await this.localDb.run('DELETE FROM posts_cache WHERE id = ?', [id]);
});
} catch (error) {
console.error('[PostRepository] 清除本地缓存失败:', error);
}
}
/**
* 处理错误
*/
private handleError(error: unknown, operation: string): never {
const message = error instanceof Error ? error.message : 'Unknown error';
console.error(`[PostRepository] ${operation} 失败:`, error);
throw new Error(`帖子${operation}失败: ${message}`);
}
// ==================== 帖子查询 ====================
/**
* 获取帖子列表
*/
async getPosts(params?: GetPostsParams): Promise<PostsResult> {
try {
const queryParams: Record<string, any> = {};
if (params?.page !== undefined) queryParams.page = params.page;
if (params?.pageSize) queryParams.page_size = params.pageSize;
// 支持 cursor 参数:空字符串也传递,用于启动 cursor 模式
if (params?.cursor !== undefined && params?.cursor !== null) {
queryParams.cursor = params.cursor;
}
if (params?.post_type) queryParams.tab = params.post_type;
if (params?.channelId) queryParams.channel_id = params.channelId;
if (params?.authorId) queryParams.author_id = params.authorId;
if (params?.tags && params.tags.length > 0) queryParams.tags = params.tags.join(',');
if (params?.status) queryParams.status = params.status;
if (params?.sortBy) queryParams.sort_by = params.sortBy;
if (params?.sortOrder) queryParams.sort_order = params.sortOrder;
if (params?.pinnedOnly) queryParams.pinned_only = true;
const response = await this.api.get<PostsListApiResponse>('/posts', queryParams);
const posts = (response.list || []).map(p => this.mapToPost(p));
// 缓存帖子
for (const post of posts) {
this.saveToMemoryCache(post);
// 不等待本地缓存写入完成,避免阻塞 UI
this.saveToLocalCache(post).catch(err => {
console.warn('[PostRepository] 缓存帖子失败:', post.id, err);
});
}
// 计算 hasMore优先使用 has_more否则通过 page/total_pages 计算
let hasMore = response.has_more;
if (hasMore === undefined && response.page !== undefined && response.total_pages !== undefined) {
hasMore = response.page < response.total_pages;
}
hasMore = hasMore ?? false;
return {
posts,
hasMore,
nextCursor: response.next_cursor,
total: response.total,
};
} catch (error) {
this.handleError(error, '获取帖子列表');
}
}
/**
* 获取单个帖子详情
*/
async getPostById(id: string): Promise<Post | null> {
try {
// 详情页优先取最新服务端数据,避免旧缓存导致关注态等关系字段过期
const response = await this.api.get<PostApiResponse>(`/posts/${id}`);
const post = this.mapToPost(response);
// 更新缓存
this.saveToMemoryCache(post);
await this.saveToLocalCache(post);
return post;
} catch (error) {
// API失败时再回退缓存保证离线/弱网可用
const memoryCached = this.getFromMemoryCache(id);
if (memoryCached) {
console.warn('[PostRepository] API获取失败使用内存缓存:', error);
return memoryCached;
}
const localCached = await this.getFromLocalCache(id);
if (localCached) {
console.warn('[PostRepository] API获取失败使用本地缓存:', error);
this.saveToMemoryCache(localCached);
return localCached;
}
this.handleError(error, '获取帖子详情');
}
}
/**
* 获取用户发布的帖子列表
*/
async getPostsByUser(userId: string, params?: GetPostsParams): Promise<PostsResult> {
try {
const queryParams: Record<string, any> = { author_id: userId };
if (params?.page) queryParams.page = params.page;
if (params?.pageSize) queryParams.page_size = params.pageSize;
if (params?.cursor) queryParams.cursor = params.cursor;
if (params?.status) queryParams.status = params.status;
if (params?.sortBy) queryParams.sort_by = params.sortBy;
if (params?.sortOrder) queryParams.sort_order = params.sortOrder;
const response = await this.api.get<PostsListApiResponse>(`/users/${userId}/posts`, queryParams);
const posts = (response.list || []).map(p => this.mapToPost(p));
// 缓存帖子
for (const post of posts) {
this.saveToMemoryCache(post);
this.saveToLocalCache(post).catch(err => {
console.warn('[PostRepository] 缓存帖子失败:', post.id, err);
});
}
return {
posts,
hasMore: response.has_more ?? false,
nextCursor: response.next_cursor,
total: response.total,
};
} catch (error) {
this.handleError(error, '获取用户帖子列表');
}
}
/**
* 搜索帖子
*/
async searchPosts(params: SearchPostsParams): Promise<PostsResult> {
try {
const queryParams: Record<string, any> = {
keyword: params.keyword,
};
if (params.page) queryParams.page = params.page;
if (params.pageSize) queryParams.page_size = params.pageSize;
if (params.scope) queryParams.scope = params.scope;
if (params.channelId) queryParams.channel_id = params.channelId;
const response = await this.api.get<PostsListApiResponse>('/posts/search', queryParams);
const posts = (response.list || []).map(p => this.mapToPost(p));
// 缓存帖子
for (const post of posts) {
this.saveToMemoryCache(post);
this.saveToLocalCache(post).catch(err => {
console.warn('[PostRepository] 缓存帖子失败:', post.id, err);
});
}
return {
posts,
hasMore: response.has_more ?? false,
nextCursor: response.next_cursor,
total: response.total,
};
} catch (error) {
this.handleError(error, '搜索帖子');
}
}
// ==================== 帖子操作 ====================
/**
* 创建帖子
*/
async createPost(data: CreatePostData): Promise<Post> {
try {
const requestData = PostMapper.createPostRequest(
data.title,
data.content,
data.images?.map(img => img.url),
data.channelId
);
if (data.tags && data.tags.length > 0) {
requestData.tags = data.tags;
}
if (data.status) {
requestData.status = data.status;
}
const response = await this.api.post<PostApiResponse>('/posts', requestData);
const post = this.mapToPost(response);
// 缓存新创建的帖子
this.saveToMemoryCache(post);
await this.saveToLocalCache(post);
return post;
} catch (error) {
this.handleError(error, '创建帖子');
}
}
/**
* 更新帖子
*/
async updatePost(id: string, data: UpdatePostData): Promise<Post> {
try {
const requestData: Record<string, any> = {};
if (data.title !== undefined) requestData.title = data.title;
if (data.content !== undefined) requestData.content = data.content;
if (data.images !== undefined) requestData.images = data.images.map(img => img.url);
if (data.tags !== undefined) requestData.tags = data.tags;
if (data.status !== undefined) requestData.status = data.status;
if (data.isPinned !== undefined) requestData.is_pinned = data.isPinned;
const response = await this.api.put<PostApiResponse>(`/posts/${id}`, requestData);
const post = this.mapToPost(response);
// 更新缓存
this.saveToMemoryCache(post);
await this.saveToLocalCache(post);
return post;
} catch (error) {
this.handleError(error, '更新帖子');
}
}
/**
* 删除帖子
*/
async deletePost(id: string): Promise<void> {
try {
await this.api.delete(`/posts/${id}`);
// 清除缓存
this.memoryCache.delete(id);
await this.clearLocalCache(id);
} catch (error) {
this.handleError(error, '删除帖子');
}
}
// ==================== 互动操作 ====================
/**
* 点赞帖子
*/
async likePost(id: string): Promise<Post> {
try {
const response = await this.api.post<PostApiResponse>(`/posts/${id}/like`);
const post = this.mapToPost(response);
// 更新缓存
this.saveToMemoryCache(post);
await this.saveToLocalCache(post);
return post;
} catch (error) {
this.handleError(error, '点赞帖子');
}
}
/**
* 取消点赞
*/
async unlikePost(id: string): Promise<Post> {
try {
const response = await this.api.delete<PostApiResponse>(`/posts/${id}/like`);
const post = this.mapToPost(response);
// 更新缓存
this.saveToMemoryCache(post);
await this.saveToLocalCache(post);
return post;
} catch (error) {
this.handleError(error, '取消点赞');
}
}
/**
* 收藏帖子
*/
async favoritePost(id: string): Promise<Post> {
try {
const response = await this.api.post<PostApiResponse>(`/posts/${id}/favorite`);
const post = this.mapToPost(response);
// 更新缓存
this.saveToMemoryCache(post);
await this.saveToLocalCache(post);
return post;
} catch (error) {
this.handleError(error, '收藏帖子');
}
}
/**
* 取消收藏
*/
async unfavoritePost(id: string): Promise<Post> {
try {
const response = await this.api.delete<PostApiResponse>(`/posts/${id}/favorite`);
const post = this.mapToPost(response);
// 更新缓存
this.saveToMemoryCache(post);
await this.saveToLocalCache(post);
return post;
} catch (error) {
this.handleError(error, '取消收藏');
}
}
// ==================== 缓存管理 ====================
/**
* 清除所有缓存
*/
async clearAllCache(): Promise<void> {
this.memoryCache.clear();
try {
await this.localDb.initialize();
await this.localDb.enqueueWrite(async () => {
await this.localDb.run('DELETE FROM posts_cache');
});
} catch (error) {
console.error('[PostRepository] 清除所有缓存失败:', error);
}
}
/**
* 使指定帖子的缓存失效
*/
async invalidateCache(id: string): Promise<void> {
this.memoryCache.delete(id);
await this.clearLocalCache(id);
}
}
// ==================== 单例导出 ====================
export const postRepository = new PostRepository(apiDataSource, localDataSource);
export default postRepository;

View File

@@ -3,7 +3,7 @@
* 定义消息数据访问的抽象
*/
import type { Message, Conversation, ConversationType } from '../../types/dto';
import type { Message, Conversation } from '../../../core/entities/Message';
export interface IMessageRepository {
// ==================== 消息操作 ====================

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