Initial frontend repository commit.
Include app source and update .gitignore to exclude local release artifacts and signing files. Made-with: Cursor
This commit is contained in:
402
src/components/common/AdaptiveLayout.tsx
Normal file
402
src/components/common/AdaptiveLayout.tsx
Normal file
@@ -0,0 +1,402 @@
|
||||
/**
|
||||
* 自适应布局组件
|
||||
* 支持主内容区 + 侧边栏布局,根据屏幕宽度自动调整侧边栏显示/隐藏
|
||||
* 支持移动端抽屉式侧边栏
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
StyleSheet,
|
||||
Animated,
|
||||
TouchableOpacity,
|
||||
Modal,
|
||||
Pressable,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import {
|
||||
useResponsive,
|
||||
useBreakpointGTE,
|
||||
FineBreakpointKey,
|
||||
} from '../../hooks/useResponsive';
|
||||
|
||||
export interface AdaptiveLayoutProps {
|
||||
/** 主内容区 */
|
||||
children: React.ReactNode;
|
||||
/** 侧边栏内容 */
|
||||
sidebar?: React.ReactNode;
|
||||
/** 自定义头部 */
|
||||
header?: React.ReactNode;
|
||||
/** 自定义底部 */
|
||||
footer?: React.ReactNode;
|
||||
/** 布局样式 */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** 主内容区样式 */
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
/** 侧边栏样式 */
|
||||
sidebarStyle?: StyleProp<ViewStyle>;
|
||||
/** 侧边栏宽度(桌面端) */
|
||||
sidebarWidth?: number;
|
||||
/** 移动端抽屉宽度 */
|
||||
drawerWidth?: number;
|
||||
/** 显示侧边栏的断点 */
|
||||
showSidebarBreakpoint?: FineBreakpointKey;
|
||||
/** 侧边栏位置 */
|
||||
sidebarPosition?: 'left' | 'right';
|
||||
/** 是否强制显示侧边栏(覆盖响应式逻辑) */
|
||||
forceShowSidebar?: boolean;
|
||||
/** 是否强制隐藏侧边栏(覆盖响应式逻辑) */
|
||||
forceHideSidebar?: boolean;
|
||||
/** 移动端抽屉是否打开(受控模式) */
|
||||
drawerOpen?: boolean;
|
||||
/** 移动端抽屉状态变化回调 */
|
||||
onDrawerOpenChange?: (open: boolean) => void;
|
||||
/** 渲染移动端抽屉触发按钮 */
|
||||
renderDrawerTrigger?: (props: { onPress: () => void; isOpen: boolean }) => React.ReactNode;
|
||||
/** 抽屉遮罩层颜色 */
|
||||
overlayColor?: string;
|
||||
/** 抽屉动画时长(毫秒) */
|
||||
animationDuration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自适应布局组件
|
||||
*
|
||||
* 支持主内容区 + 侧边栏布局,根据屏幕宽度自动调整:
|
||||
* - 桌面端:并排显示侧边栏
|
||||
* - 移动端:侧边栏变为抽屉式,通过按钮触发
|
||||
*
|
||||
* @example
|
||||
* // 基础用法
|
||||
* <AdaptiveLayout
|
||||
* sidebar={<SidebarContent />}
|
||||
* sidebarWidth={280}
|
||||
* >
|
||||
* <MainContent />
|
||||
* </AdaptiveLayout>
|
||||
*
|
||||
* @example
|
||||
* // 自定义断点和抽屉宽度
|
||||
* <AdaptiveLayout
|
||||
* sidebar={<SidebarContent />}
|
||||
* showSidebarBreakpoint="xl"
|
||||
* sidebarWidth={320}
|
||||
* drawerWidth={280}
|
||||
* sidebarPosition="right"
|
||||
* >
|
||||
* <MainContent />
|
||||
* </AdaptiveLayout>
|
||||
*
|
||||
* @example
|
||||
* // 受控模式
|
||||
* const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
*
|
||||
* <AdaptiveLayout
|
||||
* sidebar={<SidebarContent />}
|
||||
* drawerOpen={drawerOpen}
|
||||
* onDrawerOpenChange={setDrawerOpen}
|
||||
* renderDrawerTrigger={({ onPress, isOpen }) => (
|
||||
* <Button onPress={onPress}>
|
||||
* {isOpen ? '关闭' : '菜单'}
|
||||
* </Button>
|
||||
* )}
|
||||
* >
|
||||
* <MainContent />
|
||||
* </AdaptiveLayout>
|
||||
*/
|
||||
export function AdaptiveLayout({
|
||||
children,
|
||||
sidebar,
|
||||
header,
|
||||
footer,
|
||||
style,
|
||||
contentStyle,
|
||||
sidebarStyle,
|
||||
sidebarWidth = 280,
|
||||
drawerWidth = 280,
|
||||
showSidebarBreakpoint = 'lg',
|
||||
sidebarPosition = 'left',
|
||||
forceShowSidebar,
|
||||
forceHideSidebar,
|
||||
drawerOpen: controlledDrawerOpen,
|
||||
onDrawerOpenChange,
|
||||
renderDrawerTrigger,
|
||||
overlayColor = 'rgba(0, 0, 0, 0.5)',
|
||||
animationDuration = 300,
|
||||
}: AdaptiveLayoutProps) {
|
||||
const { width, isMobile } = useResponsive();
|
||||
const shouldShowSidebar = useBreakpointGTE(showSidebarBreakpoint);
|
||||
|
||||
// 内部抽屉状态(非受控模式)
|
||||
const [internalDrawerOpen, setInternalDrawerOpen] = useState(false);
|
||||
|
||||
// 动画值
|
||||
const [slideAnim] = useState(new Animated.Value(0));
|
||||
const [fadeAnim] = useState(new Animated.Value(0));
|
||||
|
||||
// 确定最终抽屉状态
|
||||
const isDrawerOpen = controlledDrawerOpen ?? internalDrawerOpen;
|
||||
const setDrawerOpen = useCallback((open: boolean) => {
|
||||
if (onDrawerOpenChange) {
|
||||
onDrawerOpenChange(open);
|
||||
} else {
|
||||
setInternalDrawerOpen(open);
|
||||
}
|
||||
}, [onDrawerOpenChange]);
|
||||
|
||||
// 切换抽屉状态
|
||||
const toggleDrawer = useCallback(() => {
|
||||
setDrawerOpen(!isDrawerOpen);
|
||||
}, [isDrawerOpen, setDrawerOpen]);
|
||||
|
||||
// 关闭抽屉
|
||||
const closeDrawer = useCallback(() => {
|
||||
setDrawerOpen(false);
|
||||
}, [setDrawerOpen]);
|
||||
|
||||
// 抽屉动画
|
||||
useEffect(() => {
|
||||
if (isDrawerOpen) {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 1,
|
||||
duration: animationDuration,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: animationDuration,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: animationDuration,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: animationDuration,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
}, [isDrawerOpen, slideAnim, fadeAnim, animationDuration]);
|
||||
|
||||
// 计算侧边栏是否应该显示
|
||||
const isSidebarVisible = forceShowSidebar ?? (shouldShowSidebar && !forceHideSidebar);
|
||||
const isDrawerMode = !isSidebarVisible && !!sidebar;
|
||||
|
||||
// 抽屉滑动动画
|
||||
const drawerTranslateX = slideAnim.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [
|
||||
sidebarPosition === 'left' ? -drawerWidth : drawerWidth,
|
||||
0,
|
||||
],
|
||||
});
|
||||
|
||||
// 渲染桌面端侧边栏
|
||||
const renderDesktopSidebar = () => {
|
||||
if (!sidebar || !isSidebarVisible) return null;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.sidebar,
|
||||
{
|
||||
width: sidebarWidth,
|
||||
[sidebarPosition === 'left' ? 'marginRight' : 'marginLeft']: 16,
|
||||
},
|
||||
sidebarStyle,
|
||||
]}
|
||||
>
|
||||
{sidebar}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染移动端抽屉
|
||||
const renderMobileDrawer = () => {
|
||||
if (!sidebar || !isDrawerMode) return null;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={isDrawerOpen}
|
||||
transparent
|
||||
animationType="none"
|
||||
onRequestClose={closeDrawer}
|
||||
>
|
||||
<View style={styles.modalContainer}>
|
||||
{/* 遮罩层 */}
|
||||
<TouchableOpacity
|
||||
style={styles.overlayTouchable}
|
||||
activeOpacity={1}
|
||||
onPress={closeDrawer}
|
||||
>
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.overlay,
|
||||
{ backgroundColor: overlayColor, opacity: fadeAnim },
|
||||
]}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 抽屉内容 */}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.drawer,
|
||||
{
|
||||
width: drawerWidth,
|
||||
[sidebarPosition]: 0,
|
||||
transform: [{ translateX: drawerTranslateX }],
|
||||
},
|
||||
sidebarStyle,
|
||||
]}
|
||||
>
|
||||
{sidebar}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染抽屉触发按钮
|
||||
const renderTrigger = () => {
|
||||
if (!isDrawerMode || !renderDrawerTrigger) return null;
|
||||
|
||||
return renderDrawerTrigger({
|
||||
onPress: toggleDrawer,
|
||||
isOpen: isDrawerOpen,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
{/* 头部 */}
|
||||
{header && (
|
||||
<View style={styles.header}>
|
||||
{header}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 主布局区域 */}
|
||||
<View style={styles.main}>
|
||||
{/* 左侧布局 */}
|
||||
{sidebarPosition === 'left' && renderDesktopSidebar()}
|
||||
|
||||
{/* 主内容区 */}
|
||||
<View style={[styles.content, contentStyle]}>
|
||||
{/* 抽屉触发按钮(仅在移动端抽屉模式显示) */}
|
||||
{renderTrigger()}
|
||||
{children}
|
||||
</View>
|
||||
|
||||
{/* 右侧布局 */}
|
||||
{sidebarPosition === 'right' && renderDesktopSidebar()}
|
||||
</View>
|
||||
|
||||
{/* 移动端抽屉 */}
|
||||
{renderMobileDrawer()}
|
||||
|
||||
{/* 底部 */}
|
||||
{footer && (
|
||||
<View style={styles.footer}>
|
||||
{footer}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
},
|
||||
header: {
|
||||
width: '100%',
|
||||
},
|
||||
main: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
width: '100%',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
minWidth: 0, // 防止 flex item 溢出
|
||||
},
|
||||
sidebar: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
footer: {
|
||||
width: '100%',
|
||||
},
|
||||
modalContainer: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
},
|
||||
overlayTouchable: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
},
|
||||
overlay: {
|
||||
flex: 1,
|
||||
},
|
||||
drawer: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: '#fff',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 0 },
|
||||
shadowOpacity: 0.25,
|
||||
shadowRadius: 8,
|
||||
elevation: 16,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 简化的侧边栏布局组件
|
||||
* 仅包含主内容和侧边栏,无头部底部
|
||||
*/
|
||||
export interface SidebarLayoutProps {
|
||||
children: React.ReactNode;
|
||||
sidebar: React.ReactNode;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
contentStyle?: StyleProp<ViewStyle>;
|
||||
sidebarStyle?: StyleProp<ViewStyle>;
|
||||
sidebarWidth?: number;
|
||||
showSidebarBreakpoint?: FineBreakpointKey;
|
||||
sidebarPosition?: 'left' | 'right';
|
||||
}
|
||||
|
||||
export function SidebarLayout({
|
||||
children,
|
||||
sidebar,
|
||||
style,
|
||||
contentStyle,
|
||||
sidebarStyle,
|
||||
sidebarWidth = 280,
|
||||
showSidebarBreakpoint = 'lg',
|
||||
sidebarPosition = 'left',
|
||||
}: SidebarLayoutProps) {
|
||||
return (
|
||||
<AdaptiveLayout
|
||||
sidebar={sidebar}
|
||||
style={style}
|
||||
contentStyle={contentStyle}
|
||||
sidebarStyle={sidebarStyle}
|
||||
sidebarWidth={sidebarWidth}
|
||||
showSidebarBreakpoint={showSidebarBreakpoint}
|
||||
sidebarPosition={sidebarPosition}
|
||||
>
|
||||
{children}
|
||||
</AdaptiveLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdaptiveLayout;
|
||||
188
src/components/common/AppDialogHost.tsx
Normal file
188
src/components/common/AppDialogHost.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, Pressable, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import type { AlertButton } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
||||
import { bindDialogListener, DialogPayload } from '../../services/dialogService';
|
||||
import { borderRadius, colors, shadows, spacing } from '../../theme';
|
||||
|
||||
const AppDialogHost: React.FC = () => {
|
||||
const [dialog, setDialog] = useState<DialogPayload | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const unbind = bindDialogListener((payload) => {
|
||||
setDialog(payload);
|
||||
});
|
||||
return unbind;
|
||||
}, []);
|
||||
|
||||
const actions = useMemo<AlertButton[]>(() => {
|
||||
if (!dialog?.actions?.length) return [{ text: '确定' }];
|
||||
return dialog.actions.slice(0, 3);
|
||||
}, [dialog]);
|
||||
|
||||
const onClose = () => {
|
||||
const cancelAction = actions.find((action) => action.style === 'cancel');
|
||||
if (cancelAction?.onPress) {
|
||||
cancelAction.onPress();
|
||||
}
|
||||
setDialog(null);
|
||||
};
|
||||
|
||||
const onActionPress = (action: AlertButton) => {
|
||||
action.onPress?.();
|
||||
setDialog(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={!!dialog}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<Pressable
|
||||
style={styles.backdrop}
|
||||
onPress={() => {
|
||||
if (dialog?.options?.cancelable ?? true) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Pressable style={styles.container}>
|
||||
<View style={styles.iconHeader}>
|
||||
<LinearGradient
|
||||
colors={['#FF6B35', '#FF8F66']}
|
||||
start={{ x: 0, y: 0 }}
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.iconBadge}
|
||||
>
|
||||
<MaterialCommunityIcons name="carrot" size={20} color="#FFFFFF" />
|
||||
</LinearGradient>
|
||||
</View>
|
||||
<Text style={styles.title}>{dialog?.title || '提示'}</Text>
|
||||
{!!dialog?.message && <Text style={styles.message}>{dialog.message}</Text>}
|
||||
|
||||
<View style={styles.actions}>
|
||||
{actions.map((action, index) => {
|
||||
const isDestructive = action.style === 'destructive';
|
||||
const isCancel = action.style === 'cancel';
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={`${action.text || 'action'}-${index}`}
|
||||
style={[
|
||||
styles.actionButton,
|
||||
isCancel && styles.cancelButton,
|
||||
!isCancel && !isDestructive && styles.primaryButton,
|
||||
isDestructive && styles.destructiveButton,
|
||||
]}
|
||||
onPress={() => onActionPress(action)}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
styles.actionText,
|
||||
!isCancel && !isDestructive && styles.primaryText,
|
||||
isCancel && styles.cancelText,
|
||||
isDestructive && styles.destructiveText,
|
||||
]}
|
||||
>
|
||||
{action.text || '确定'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.36)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.xl,
|
||||
},
|
||||
container: {
|
||||
width: '100%',
|
||||
maxWidth: 380,
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius['2xl'],
|
||||
paddingHorizontal: spacing.xl,
|
||||
paddingTop: spacing.lg,
|
||||
paddingBottom: spacing.lg,
|
||||
...shadows.lg,
|
||||
},
|
||||
iconHeader: {
|
||||
alignItems: 'center',
|
||||
marginBottom: spacing.md,
|
||||
},
|
||||
iconBadge: {
|
||||
width: 42,
|
||||
height: 42,
|
||||
borderRadius: borderRadius.full,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
color: colors.text.primary,
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
},
|
||||
message: {
|
||||
marginTop: spacing.md,
|
||||
color: colors.text.secondary,
|
||||
fontSize: 14,
|
||||
lineHeight: 21,
|
||||
textAlign: 'center',
|
||||
},
|
||||
actions: {
|
||||
marginTop: spacing.xl,
|
||||
gap: spacing.sm,
|
||||
},
|
||||
actionButton: {
|
||||
height: 46,
|
||||
borderRadius: borderRadius.lg,
|
||||
borderWidth: 1,
|
||||
borderColor: `${colors.primary.main}28`,
|
||||
backgroundColor: '#FFFFFF',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
primaryButton: {
|
||||
backgroundColor: colors.primary.main,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
cancelButton: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderColor: colors.divider,
|
||||
},
|
||||
destructiveButton: {
|
||||
backgroundColor: '#FEECEC',
|
||||
borderColor: '#FCD4D1',
|
||||
},
|
||||
actionText: {
|
||||
color: colors.text.primary,
|
||||
fontSize: 15,
|
||||
fontWeight: '700',
|
||||
},
|
||||
primaryText: {
|
||||
color: '#FFFFFF',
|
||||
},
|
||||
cancelText: {
|
||||
color: colors.text.secondary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
destructiveText: {
|
||||
color: colors.error.main,
|
||||
},
|
||||
});
|
||||
|
||||
export default AppDialogHost;
|
||||
185
src/components/common/AppPromptBar.tsx
Normal file
185
src/components/common/AppPromptBar.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Animated, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
|
||||
import { bindPromptListener, PromptPayload, PromptType } from '../../services/promptService';
|
||||
import { borderRadius, colors, shadows, spacing } from '../../theme';
|
||||
|
||||
interface PromptState extends PromptPayload {
|
||||
id: number;
|
||||
}
|
||||
|
||||
const DEFAULT_DURATION = 2200;
|
||||
|
||||
const styleMap: Record<PromptType, { backgroundColor: string; icon: React.ComponentProps<typeof MaterialCommunityIcons>['name'] }> = {
|
||||
info: { backgroundColor: '#FFFFFF', icon: 'information-outline' },
|
||||
success: { backgroundColor: '#FFFFFF', icon: 'check-circle-outline' },
|
||||
warning: { backgroundColor: '#FFFFFF', icon: 'alert-outline' },
|
||||
error: { backgroundColor: '#FFFFFF', icon: 'alert-circle-outline' },
|
||||
};
|
||||
|
||||
const iconColorMap: Record<PromptType, string> = {
|
||||
info: colors.primary.main,
|
||||
success: colors.success.main,
|
||||
warning: colors.warning.dark,
|
||||
error: colors.error.main,
|
||||
};
|
||||
|
||||
const accentColorMap: Record<PromptType, string> = {
|
||||
info: colors.primary.main,
|
||||
success: colors.success.main,
|
||||
warning: colors.warning.main,
|
||||
error: colors.error.main,
|
||||
};
|
||||
|
||||
const AppPromptBar: React.FC = () => {
|
||||
const insets = useSafeAreaInsets();
|
||||
const [prompt, setPrompt] = useState<PromptState | null>(null);
|
||||
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const animation = useRef(new Animated.Value(0)).current;
|
||||
|
||||
const hidePrompt = useCallback(() => {
|
||||
Animated.timing(animation, {
|
||||
toValue: 0,
|
||||
duration: 180,
|
||||
useNativeDriver: true,
|
||||
}).start(({ finished }) => {
|
||||
if (finished) {
|
||||
setPrompt(null);
|
||||
}
|
||||
});
|
||||
}, [animation]);
|
||||
|
||||
useEffect(() => {
|
||||
const unbind = bindPromptListener((payload) => {
|
||||
if (hideTimerRef.current) {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
}
|
||||
|
||||
setPrompt({
|
||||
...payload,
|
||||
id: Date.now(),
|
||||
type: payload.type ?? 'info',
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
if (hideTimerRef.current) {
|
||||
clearTimeout(hideTimerRef.current);
|
||||
}
|
||||
unbind();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!prompt) return;
|
||||
|
||||
animation.setValue(0);
|
||||
Animated.timing(animation, {
|
||||
toValue: 1,
|
||||
duration: 220,
|
||||
useNativeDriver: true,
|
||||
}).start();
|
||||
|
||||
hideTimerRef.current = setTimeout(() => {
|
||||
hidePrompt();
|
||||
}, prompt.duration ?? DEFAULT_DURATION);
|
||||
}, [animation, hidePrompt, prompt]);
|
||||
|
||||
if (!prompt) return null;
|
||||
|
||||
const promptType = prompt.type ?? 'info';
|
||||
const promptStyle = styleMap[promptType];
|
||||
const iconColor = iconColorMap[promptType];
|
||||
const accentColor = accentColorMap[promptType];
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
pointerEvents="box-none"
|
||||
style={[
|
||||
styles.wrapper,
|
||||
{
|
||||
top: insets.top + spacing.sm,
|
||||
opacity: animation,
|
||||
transform: [
|
||||
{
|
||||
translateY: animation.interpolate({
|
||||
inputRange: [0, 1],
|
||||
outputRange: [-20, 0],
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.95}
|
||||
onPress={hidePrompt}
|
||||
style={[styles.card, { backgroundColor: promptStyle.backgroundColor }]}
|
||||
>
|
||||
<View style={[styles.accentBar, { backgroundColor: accentColor }]} />
|
||||
<View style={styles.iconWrap}>
|
||||
<MaterialCommunityIcons name={promptStyle.icon} size={20} color={iconColor} />
|
||||
</View>
|
||||
<View style={styles.textWrap}>
|
||||
{!!prompt.title && <Text style={styles.title}>{prompt.title}</Text>}
|
||||
<Text style={styles.message}>{prompt.message}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
position: 'absolute',
|
||||
left: spacing.md,
|
||||
right: spacing.md,
|
||||
zIndex: 9999,
|
||||
},
|
||||
card: {
|
||||
borderRadius: borderRadius.xl,
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
borderWidth: 1,
|
||||
borderColor: `${colors.primary.main}22`,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
overflow: 'hidden',
|
||||
...shadows.lg,
|
||||
},
|
||||
accentBar: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 4,
|
||||
},
|
||||
iconWrap: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: `${colors.primary.main}12`,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: spacing.md,
|
||||
},
|
||||
textWrap: {
|
||||
flex: 1,
|
||||
paddingRight: spacing.sm,
|
||||
},
|
||||
title: {
|
||||
color: colors.text.primary,
|
||||
fontWeight: '700',
|
||||
fontSize: 14,
|
||||
marginBottom: 2,
|
||||
},
|
||||
message: {
|
||||
color: colors.text.primary,
|
||||
fontSize: 13,
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
|
||||
export default AppPromptBar;
|
||||
146
src/components/common/Avatar.tsx
Normal file
146
src/components/common/Avatar.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* Avatar 头像组件
|
||||
* 支持图片URL、本地图片、首字母显示、在线状态徽章
|
||||
* 使用 expo-image 实现内存+磁盘双级缓存,头像秒加载
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
View,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ViewStyle,
|
||||
} from 'react-native';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { colors, borderRadius } from '../../theme';
|
||||
import Text from './Text';
|
||||
|
||||
type AvatarSource = string | { uri: string } | number | null;
|
||||
|
||||
interface AvatarProps {
|
||||
source?: AvatarSource;
|
||||
size?: number; // 默认40
|
||||
name?: string; // 用于显示首字母
|
||||
onPress?: () => void;
|
||||
showBadge?: boolean;
|
||||
badgeColor?: string;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
const Avatar: React.FC<AvatarProps> = ({
|
||||
source,
|
||||
size = 40,
|
||||
name,
|
||||
onPress,
|
||||
showBadge = false,
|
||||
badgeColor = colors.success.main,
|
||||
style,
|
||||
}) => {
|
||||
// 获取首字母
|
||||
const getInitial = (): string => {
|
||||
if (!name) return '?';
|
||||
const firstChar = name.charAt(0).toUpperCase();
|
||||
// 中文字符
|
||||
if (/[\u4e00-\u9fa5]/.test(firstChar)) {
|
||||
return firstChar;
|
||||
}
|
||||
return firstChar;
|
||||
};
|
||||
|
||||
// 渲染头像内容
|
||||
const renderAvatarContent = () => {
|
||||
// 如果有图片源
|
||||
if (source) {
|
||||
const imageSource =
|
||||
typeof source === 'string' ? { uri: source } : source;
|
||||
|
||||
return (
|
||||
<ExpoImage
|
||||
source={imageSource}
|
||||
style={[
|
||||
styles.image,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
},
|
||||
]}
|
||||
contentFit="cover"
|
||||
cachePolicy="memory"
|
||||
transition={150}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 显示首字母
|
||||
const fontSize = size / 2;
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.placeholder,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Text color={colors.text.inverse} style={{ fontSize, lineHeight: fontSize * 1.2 }}>
|
||||
{getInitial()}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const avatarContainer = (
|
||||
<View style={[styles.container, style]}>
|
||||
{renderAvatarContent()}
|
||||
{showBadge && (
|
||||
<View
|
||||
style={[
|
||||
styles.badge,
|
||||
{
|
||||
backgroundColor: badgeColor,
|
||||
width: size * 0.3,
|
||||
height: size * 0.3,
|
||||
borderRadius: (size * 0.3) / 2,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
if (onPress) {
|
||||
return (
|
||||
<TouchableOpacity onPress={onPress} activeOpacity={0.7}>
|
||||
{avatarContainer}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return avatarContainer;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
position: 'relative',
|
||||
},
|
||||
image: {
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
placeholder: {
|
||||
backgroundColor: colors.primary.main,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
borderWidth: 2,
|
||||
borderColor: colors.background.paper,
|
||||
},
|
||||
});
|
||||
|
||||
export default Avatar;
|
||||
260
src/components/common/Button.tsx
Normal file
260
src/components/common/Button.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Button 按钮组件
|
||||
* 支持多种变体、尺寸、加载状态和图标
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
TouchableOpacity,
|
||||
ActivityIndicator,
|
||||
StyleSheet,
|
||||
View,
|
||||
ViewStyle,
|
||||
TextStyle,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, borderRadius, spacing, fontSizes, shadows } from '../../theme';
|
||||
import Text from './Text';
|
||||
|
||||
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'text' | 'danger';
|
||||
type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
type IconPosition = 'left' | 'right';
|
||||
|
||||
interface ButtonProps {
|
||||
title: string;
|
||||
onPress: () => void;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
icon?: string; // MaterialCommunityIcons name
|
||||
iconPosition?: IconPosition;
|
||||
fullWidth?: boolean;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
const Button: React.FC<ButtonProps> = ({
|
||||
title,
|
||||
onPress,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
disabled = false,
|
||||
loading = false,
|
||||
icon,
|
||||
iconPosition = 'left',
|
||||
fullWidth = false,
|
||||
style,
|
||||
}) => {
|
||||
// 获取按钮样式
|
||||
const getButtonStyle = (): ViewStyle[] => {
|
||||
const baseStyle: ViewStyle[] = [styles.base, styles[`size_${size}`]];
|
||||
|
||||
// 变体样式
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
baseStyle.push(styles.primary);
|
||||
break;
|
||||
case 'secondary':
|
||||
baseStyle.push(styles.secondary);
|
||||
break;
|
||||
case 'outline':
|
||||
baseStyle.push(styles.outline);
|
||||
break;
|
||||
case 'text':
|
||||
baseStyle.push(styles.text);
|
||||
break;
|
||||
case 'danger':
|
||||
baseStyle.push(styles.danger);
|
||||
break;
|
||||
}
|
||||
|
||||
// 禁用状态
|
||||
if (disabled || loading) {
|
||||
baseStyle.push(styles.disabled);
|
||||
}
|
||||
|
||||
// 全宽度
|
||||
if (fullWidth) {
|
||||
baseStyle.push(styles.fullWidth);
|
||||
}
|
||||
|
||||
return baseStyle;
|
||||
};
|
||||
|
||||
// 获取文本样式
|
||||
const getTextStyle = (): TextStyle => {
|
||||
const baseStyle: TextStyle = {
|
||||
...styles.textBase,
|
||||
...styles[`textSize_${size}`],
|
||||
};
|
||||
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
case 'danger':
|
||||
baseStyle.color = colors.text.inverse;
|
||||
break;
|
||||
case 'secondary':
|
||||
baseStyle.color = colors.text.inverse;
|
||||
break;
|
||||
case 'outline':
|
||||
case 'text':
|
||||
baseStyle.color = colors.primary.main;
|
||||
break;
|
||||
}
|
||||
|
||||
if (disabled || loading) {
|
||||
baseStyle.color = colors.text.disabled;
|
||||
}
|
||||
|
||||
return baseStyle;
|
||||
};
|
||||
|
||||
// 获取图标大小
|
||||
const getIconSize = (): number => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return 16;
|
||||
case 'md':
|
||||
return 20;
|
||||
case 'lg':
|
||||
return 24;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取图标颜色
|
||||
const getIconColor = (): string => {
|
||||
if (disabled || loading) {
|
||||
return colors.text.disabled;
|
||||
}
|
||||
switch (variant) {
|
||||
case 'primary':
|
||||
case 'danger':
|
||||
return colors.text.inverse;
|
||||
case 'secondary':
|
||||
return colors.text.inverse;
|
||||
case 'outline':
|
||||
case 'text':
|
||||
return colors.primary.main;
|
||||
}
|
||||
};
|
||||
|
||||
const isDisabled = disabled || loading;
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[getButtonStyle(), style]}
|
||||
onPress={onPress}
|
||||
disabled={isDisabled}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator
|
||||
size="small"
|
||||
color={variant === 'outline' || variant === 'text'
|
||||
? colors.primary.main
|
||||
: colors.text.inverse}
|
||||
/>
|
||||
) : (
|
||||
<View style={styles.content}>
|
||||
{icon && iconPosition === 'left' && (
|
||||
<MaterialCommunityIcons
|
||||
name={icon as any}
|
||||
size={getIconSize()}
|
||||
color={getIconColor()}
|
||||
style={styles.iconLeft}
|
||||
/>
|
||||
)}
|
||||
<Text style={getTextStyle()}>{title}</Text>
|
||||
{icon && iconPosition === 'right' && (
|
||||
<MaterialCommunityIcons
|
||||
name={icon as any}
|
||||
size={getIconSize()}
|
||||
color={getIconColor()}
|
||||
style={styles.iconRight}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
content: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
// 尺寸样式
|
||||
size_sm: {
|
||||
paddingVertical: spacing.sm,
|
||||
paddingHorizontal: spacing.md,
|
||||
minHeight: 32,
|
||||
},
|
||||
size_md: {
|
||||
paddingVertical: spacing.md,
|
||||
paddingHorizontal: spacing.lg,
|
||||
minHeight: 40,
|
||||
},
|
||||
size_lg: {
|
||||
paddingVertical: spacing.lg,
|
||||
paddingHorizontal: spacing.xl,
|
||||
minHeight: 48,
|
||||
},
|
||||
// 变体样式
|
||||
primary: {
|
||||
backgroundColor: colors.primary.main,
|
||||
},
|
||||
secondary: {
|
||||
backgroundColor: colors.secondary.main,
|
||||
},
|
||||
outline: {
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary.main,
|
||||
},
|
||||
text: {
|
||||
backgroundColor: 'transparent',
|
||||
shadowColor: 'transparent',
|
||||
elevation: 0,
|
||||
},
|
||||
danger: {
|
||||
backgroundColor: colors.error.main,
|
||||
},
|
||||
// 禁用状态
|
||||
disabled: {
|
||||
backgroundColor: colors.background.disabled,
|
||||
borderColor: colors.background.disabled,
|
||||
},
|
||||
// 全宽度
|
||||
fullWidth: {
|
||||
width: '100%',
|
||||
},
|
||||
// 文本样式
|
||||
textBase: {
|
||||
fontWeight: '600',
|
||||
},
|
||||
textSize_sm: {
|
||||
fontSize: fontSizes.sm,
|
||||
},
|
||||
textSize_md: {
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
textSize_lg: {
|
||||
fontSize: fontSizes.lg,
|
||||
},
|
||||
// 图标样式
|
||||
iconLeft: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
iconRight: {
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
});
|
||||
|
||||
export default Button;
|
||||
58
src/components/common/Card.tsx
Normal file
58
src/components/common/Card.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Card 卡片组件
|
||||
* 白色背景、圆角、阴影,支持点击
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { colors, borderRadius, spacing, shadows } from '../../theme';
|
||||
|
||||
type ShadowSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface CardProps {
|
||||
children: React.ReactNode;
|
||||
onPress?: () => void;
|
||||
padding?: number;
|
||||
shadow?: ShadowSize;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
const Card: React.FC<CardProps> = ({
|
||||
children,
|
||||
onPress,
|
||||
padding = spacing.lg,
|
||||
shadow = 'none',
|
||||
style,
|
||||
}) => {
|
||||
const shadowStyle = shadow !== 'none' ? shadows[shadow as keyof typeof shadows] : undefined;
|
||||
|
||||
const cardStyle = [
|
||||
styles.card,
|
||||
{ padding },
|
||||
shadowStyle,
|
||||
].filter(Boolean);
|
||||
|
||||
if (onPress) {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[...cardStyle, style]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.95}
|
||||
>
|
||||
{children}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return <View style={[...cardStyle, style]}>{children}</View>;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: {
|
||||
backgroundColor: colors.background.paper,
|
||||
borderRadius: borderRadius.lg,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
});
|
||||
|
||||
export default Card;
|
||||
39
src/components/common/Divider.tsx
Normal file
39
src/components/common/Divider.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Divider 分割线组件
|
||||
* 用于分隔内容
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { colors, spacing } from '../../theme';
|
||||
|
||||
interface DividerProps {
|
||||
margin?: number;
|
||||
color?: string;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
const Divider: React.FC<DividerProps> = ({
|
||||
margin = spacing.lg,
|
||||
color = colors.divider,
|
||||
style,
|
||||
}) => {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.divider,
|
||||
{ marginVertical: margin, backgroundColor: color },
|
||||
style,
|
||||
]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
divider: {
|
||||
height: 1,
|
||||
width: '100%',
|
||||
},
|
||||
});
|
||||
|
||||
export default Divider;
|
||||
242
src/components/common/EmptyState.tsx
Normal file
242
src/components/common/EmptyState.tsx
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* EmptyState 空状态组件 - 美化版
|
||||
* 显示空数据时的占位界面,采用现代插图风格设计
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, ViewStyle, Dimensions } from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, spacing, fontSizes, borderRadius } from '../../theme';
|
||||
import Text from './Text';
|
||||
import Button from './Button';
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
actionLabel?: string;
|
||||
onAction?: () => void;
|
||||
style?: ViewStyle;
|
||||
variant?: 'default' | 'modern' | 'compact';
|
||||
}
|
||||
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({
|
||||
icon = 'folder-open-outline',
|
||||
title,
|
||||
description,
|
||||
actionLabel,
|
||||
onAction,
|
||||
style,
|
||||
variant = 'modern',
|
||||
}) => {
|
||||
// 现代风格空状态
|
||||
if (variant === 'modern') {
|
||||
return (
|
||||
<View style={[styles.modernContainer, style]}>
|
||||
<View style={styles.illustrationContainer}>
|
||||
<View style={styles.iconBackground}>
|
||||
<MaterialCommunityIcons
|
||||
name={icon as any}
|
||||
size={48}
|
||||
color={colors.primary.main}
|
||||
style={styles.modernIcon}
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.decorativeCircle1} />
|
||||
<View style={styles.decorativeCircle2} />
|
||||
</View>
|
||||
<Text
|
||||
variant="h3"
|
||||
color={colors.text.primary}
|
||||
style={styles.modernTitle}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text
|
||||
variant="body"
|
||||
color={colors.text.secondary}
|
||||
style={styles.modernDescription}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{actionLabel && onAction && (
|
||||
<Button
|
||||
title={actionLabel}
|
||||
onPress={onAction}
|
||||
variant="primary"
|
||||
size="md"
|
||||
style={styles.modernButton}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 紧凑风格
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<View style={[styles.compactContainer, style]}>
|
||||
<MaterialCommunityIcons
|
||||
name={icon as any}
|
||||
size={40}
|
||||
color={colors.text.disabled}
|
||||
style={styles.compactIcon}
|
||||
/>
|
||||
<Text
|
||||
variant="body"
|
||||
color={colors.text.secondary}
|
||||
style={styles.compactTitle}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 默认风格
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<MaterialCommunityIcons
|
||||
name={icon as any}
|
||||
size={64}
|
||||
color={colors.text.disabled}
|
||||
style={styles.icon}
|
||||
/>
|
||||
<Text
|
||||
variant="h3"
|
||||
color={colors.text.secondary}
|
||||
style={styles.title}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
{description && (
|
||||
<Text
|
||||
variant="body"
|
||||
color={colors.text.secondary}
|
||||
style={styles.description}
|
||||
>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
{actionLabel && onAction && (
|
||||
<Button
|
||||
title={actionLabel}
|
||||
onPress={onAction}
|
||||
variant="outline"
|
||||
size="md"
|
||||
style={styles.button}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
// 默认风格
|
||||
container: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
},
|
||||
icon: {
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
},
|
||||
description: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
},
|
||||
button: {
|
||||
minWidth: 120,
|
||||
},
|
||||
|
||||
// 现代风格
|
||||
modernContainer: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.xl,
|
||||
minHeight: 280,
|
||||
},
|
||||
illustrationContainer: {
|
||||
position: 'relative',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.xl,
|
||||
width: 120,
|
||||
height: 120,
|
||||
},
|
||||
iconBackground: {
|
||||
width: 100,
|
||||
height: 100,
|
||||
borderRadius: borderRadius['2xl'],
|
||||
backgroundColor: colors.primary.main + '15',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
shadowColor: colors.primary.main,
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.2,
|
||||
shadowRadius: 12,
|
||||
elevation: 4,
|
||||
},
|
||||
modernIcon: {
|
||||
opacity: 0.9,
|
||||
},
|
||||
decorativeCircle1: {
|
||||
position: 'absolute',
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.primary.light + '30',
|
||||
top: 5,
|
||||
right: 10,
|
||||
},
|
||||
decorativeCircle2: {
|
||||
position: 'absolute',
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: colors.primary.main + '20',
|
||||
bottom: 15,
|
||||
left: 5,
|
||||
},
|
||||
modernTitle: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.sm,
|
||||
fontWeight: '700',
|
||||
fontSize: fontSizes.xl,
|
||||
},
|
||||
modernDescription: {
|
||||
textAlign: 'center',
|
||||
marginBottom: spacing.lg,
|
||||
fontSize: fontSizes.md,
|
||||
lineHeight: 22,
|
||||
maxWidth: 280,
|
||||
},
|
||||
modernButton: {
|
||||
minWidth: 140,
|
||||
borderRadius: borderRadius.lg,
|
||||
},
|
||||
|
||||
// 紧凑风格
|
||||
compactContainer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: spacing.lg,
|
||||
},
|
||||
compactIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
compactTitle: {
|
||||
fontSize: fontSizes.md,
|
||||
},
|
||||
});
|
||||
|
||||
export default EmptyState;
|
||||
609
src/components/common/ImageGallery.tsx
Normal file
609
src/components/common/ImageGallery.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
/**
|
||||
* ImageGallery 图片画廊组件
|
||||
* 支持手势滑动切换图片、双指放大、点击关闭
|
||||
* 使用 expo-image,原生支持 GIF/WebP 动图
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
StatusBar,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import {
|
||||
Gesture,
|
||||
GestureDetector,
|
||||
GestureHandlerRootView,
|
||||
} from 'react-native-gesture-handler';
|
||||
import Animated, {
|
||||
useSharedValue,
|
||||
useAnimatedStyle,
|
||||
useDerivedValue,
|
||||
runOnJS,
|
||||
withTiming,
|
||||
withSpring,
|
||||
} from 'react-native-reanimated';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
import { File, Paths } from 'expo-file-system';
|
||||
import { colors, spacing, borderRadius, fontSizes } from '../../theme';
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
// 图片项类型
|
||||
export interface GalleryImageItem {
|
||||
id: string;
|
||||
url: string;
|
||||
thumbnailUrl?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// 组件 Props
|
||||
export interface ImageGalleryProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
/** 图片列表 */
|
||||
images: GalleryImageItem[];
|
||||
/** 初始索引 */
|
||||
initialIndex: number;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
/** 索引变化回调 */
|
||||
onIndexChange?: (index: number) => void;
|
||||
/** 是否显示指示器 */
|
||||
showIndicator?: boolean;
|
||||
/** 是否允许保存图片 */
|
||||
enableSave?: boolean;
|
||||
/** 保存图片成功回调 */
|
||||
onSave?: (url: string) => void;
|
||||
/** 背景透明度 */
|
||||
backgroundOpacity?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 图片画廊主组件
|
||||
*/
|
||||
export const ImageGallery: React.FC<ImageGalleryProps> = ({
|
||||
visible,
|
||||
images,
|
||||
initialIndex,
|
||||
onClose,
|
||||
onIndexChange,
|
||||
showIndicator = true,
|
||||
enableSave = false,
|
||||
onSave,
|
||||
backgroundOpacity = 1,
|
||||
}) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [showControls, setShowControls] = useState(true);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveToast, setSaveToast] = useState<'success' | 'error' | null>(null);
|
||||
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
// 缩放相关状态
|
||||
const scale = useSharedValue(1);
|
||||
const savedScale = useSharedValue(1);
|
||||
const translateX = useSharedValue(0);
|
||||
const translateY = useSharedValue(0);
|
||||
const savedTranslateX = useSharedValue(0);
|
||||
const savedTranslateY = useSharedValue(0);
|
||||
|
||||
const validImages = useMemo(() => {
|
||||
return images.filter(img => img.url);
|
||||
}, [images]);
|
||||
|
||||
const currentImage = useMemo(() => {
|
||||
if (currentIndex < 0 || currentIndex >= validImages.length) {
|
||||
return null;
|
||||
}
|
||||
return validImages[currentIndex];
|
||||
}, [validImages, currentIndex]);
|
||||
|
||||
// 重置缩放状态
|
||||
const resetZoom = useCallback(() => {
|
||||
scale.value = withTiming(1, { duration: 200 });
|
||||
savedScale.value = 1;
|
||||
translateX.value = withTiming(0, { duration: 200 });
|
||||
translateY.value = withTiming(0, { duration: 200 });
|
||||
savedTranslateX.value = 0;
|
||||
savedTranslateY.value = 0;
|
||||
}, [scale, savedScale, translateX, translateY, savedTranslateX, savedTranslateY]);
|
||||
|
||||
// 打开/关闭时重置状态
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setCurrentIndex(initialIndex);
|
||||
setShowControls(true);
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
resetZoom();
|
||||
StatusBar.setHidden(true, 'fade');
|
||||
} else {
|
||||
StatusBar.setHidden(false, 'fade');
|
||||
}
|
||||
}, [visible, initialIndex, resetZoom]);
|
||||
|
||||
// 图片变化时重置加载状态和缩放
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
resetZoom();
|
||||
}, [currentImage?.id, resetZoom]);
|
||||
|
||||
const updateIndex = useCallback(
|
||||
(newIndex: number) => {
|
||||
const clampedIndex = Math.max(0, Math.min(validImages.length - 1, newIndex));
|
||||
setCurrentIndex(clampedIndex);
|
||||
onIndexChange?.(clampedIndex);
|
||||
},
|
||||
[validImages.length, onIndexChange]
|
||||
);
|
||||
|
||||
const toggleControls = useCallback(() => {
|
||||
setShowControls(prev => !prev);
|
||||
}, []);
|
||||
|
||||
const goToPrev = useCallback(() => {
|
||||
if (currentIndex > 0) {
|
||||
updateIndex(currentIndex - 1);
|
||||
}
|
||||
}, [currentIndex, updateIndex]);
|
||||
|
||||
const goToNext = useCallback(() => {
|
||||
if (currentIndex < validImages.length - 1) {
|
||||
updateIndex(currentIndex + 1);
|
||||
}
|
||||
}, [currentIndex, validImages.length, updateIndex]);
|
||||
|
||||
// 显示短暂提示
|
||||
const showToast = useCallback((type: 'success' | 'error') => {
|
||||
setSaveToast(type);
|
||||
setTimeout(() => setSaveToast(null), 2500);
|
||||
}, []);
|
||||
|
||||
// 保存图片到本地相册
|
||||
const handleSaveImage = useCallback(async () => {
|
||||
if (!currentImage || saving) return;
|
||||
|
||||
const { status } = await MediaLibrary.requestPermissionsAsync();
|
||||
if (status !== 'granted') {
|
||||
Alert.alert('无法保存', '请在系统设置中允许访问相册权限后重试。');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const urlPath = currentImage.url.split('?')[0];
|
||||
const ext = urlPath.split('.').pop()?.toLowerCase() ?? 'jpg';
|
||||
const allowedExts = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
|
||||
const fileExt = allowedExts.includes(ext) ? ext : 'jpg';
|
||||
|
||||
const fileName = `carrot_${Date.now()}.${fileExt}`;
|
||||
const destination = new File(Paths.cache, fileName);
|
||||
|
||||
// File.downloadFileAsync 是新版 expo-file-system/next 的静态方法
|
||||
const downloaded = await File.downloadFileAsync(currentImage.url, destination);
|
||||
|
||||
await MediaLibrary.saveToLibraryAsync(downloaded.uri);
|
||||
|
||||
// 清理缓存文件
|
||||
downloaded.delete();
|
||||
|
||||
onSave?.(currentImage.url);
|
||||
showToast('success');
|
||||
} catch (err) {
|
||||
console.error('[ImageGallery] 保存图片失败:', err);
|
||||
showToast('error');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [currentImage, saving, onSave, showToast]);
|
||||
|
||||
// 双指缩放手势
|
||||
const pinchGesture = Gesture.Pinch()
|
||||
.onUpdate((e) => {
|
||||
const newScale = savedScale.value * e.scale;
|
||||
// 限制缩放范围
|
||||
scale.value = Math.max(0.5, Math.min(newScale, 4));
|
||||
})
|
||||
.onEnd(() => {
|
||||
// 如果缩放小于1,回弹到1
|
||||
if (scale.value < 1) {
|
||||
scale.value = withTiming(1, { duration: 200 });
|
||||
savedScale.value = 1;
|
||||
translateX.value = withTiming(0, { duration: 200 });
|
||||
translateY.value = withTiming(0, { duration: 200 });
|
||||
savedTranslateX.value = 0;
|
||||
savedTranslateY.value = 0;
|
||||
} else {
|
||||
savedScale.value = scale.value;
|
||||
}
|
||||
});
|
||||
|
||||
// 滑动切换图片相关状态
|
||||
const swipeTranslateX = useSharedValue(0);
|
||||
|
||||
// 统一的滑动手势:放大时拖动,未放大时切换图片
|
||||
const panGesture = Gesture.Pan()
|
||||
.activeOffsetX([-10, 10]) // 水平方向需要移动10pt才激活,避免与点击冲突
|
||||
.activeOffsetY([-10, 10]) // 垂直方向也需要一定偏移才激活
|
||||
.onBegin(() => {
|
||||
swipeTranslateX.value = 0;
|
||||
})
|
||||
.onUpdate((e) => {
|
||||
if (scale.value > 1) {
|
||||
// 放大状态下:拖动图片
|
||||
translateX.value = savedTranslateX.value + e.translationX;
|
||||
translateY.value = savedTranslateY.value + e.translationY;
|
||||
} else if (validImages.length > 1) {
|
||||
// 未放大且有多张图片:切换图片的跟随效果
|
||||
const isFirst = currentIndex === 0 && e.translationX > 0;
|
||||
const isLast = currentIndex === validImages.length - 1 && e.translationX < 0;
|
||||
if (isFirst || isLast) {
|
||||
// 边界阻力效果
|
||||
swipeTranslateX.value = e.translationX * 0.3;
|
||||
} else {
|
||||
swipeTranslateX.value = e.translationX;
|
||||
}
|
||||
}
|
||||
})
|
||||
.onEnd((e) => {
|
||||
if (scale.value > 1) {
|
||||
// 放大状态下:保存拖动位置
|
||||
savedTranslateX.value = translateX.value;
|
||||
savedTranslateY.value = translateY.value;
|
||||
} else if (validImages.length > 1) {
|
||||
// 未放大状态下:判断是否切换图片
|
||||
const threshold = SCREEN_WIDTH * 0.2;
|
||||
const velocity = e.velocityX;
|
||||
|
||||
const shouldGoNext = e.translationX < -threshold || velocity < -500;
|
||||
const shouldGoPrev = e.translationX > threshold || velocity > 500;
|
||||
|
||||
if (shouldGoNext && currentIndex < validImages.length - 1) {
|
||||
// 向左滑动,显示下一张
|
||||
swipeTranslateX.value = withTiming(-SCREEN_WIDTH, { duration: 200 }, () => {
|
||||
runOnJS(updateIndex)(currentIndex + 1);
|
||||
swipeTranslateX.value = 0;
|
||||
});
|
||||
} else if (shouldGoPrev && currentIndex > 0) {
|
||||
// 向右滑动,显示上一张
|
||||
swipeTranslateX.value = withTiming(SCREEN_WIDTH, { duration: 200 }, () => {
|
||||
runOnJS(updateIndex)(currentIndex - 1);
|
||||
swipeTranslateX.value = 0;
|
||||
});
|
||||
} else {
|
||||
// 回弹到原位
|
||||
swipeTranslateX.value = withTiming(0, { duration: 200 });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 点击手势(关闭 gallery)
|
||||
const tapGesture = Gesture.Tap()
|
||||
.numberOfTaps(1)
|
||||
.maxDistance(10)
|
||||
.onEnd(() => {
|
||||
runOnJS(onClose)();
|
||||
});
|
||||
|
||||
// 组合手势:
|
||||
// - pinchGesture 和 (panGesture / tapGesture) 可以同时识别
|
||||
// - panGesture 和 tapGesture 互斥(Race):短按是点击,长按/滑动是拖动
|
||||
const composedGesture = Gesture.Simultaneous(
|
||||
pinchGesture,
|
||||
Gesture.Race(panGesture, tapGesture)
|
||||
);
|
||||
|
||||
// 动画样式
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ translateX: translateX.value + swipeTranslateX.value },
|
||||
{ translateY: translateY.value },
|
||||
{ scale: scale.value },
|
||||
] as const,
|
||||
}));
|
||||
|
||||
if (!visible || validImages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
statusBarTranslucent
|
||||
>
|
||||
<GestureHandlerRootView style={styles.root}>
|
||||
<View style={[styles.container, { backgroundColor: `rgba(0, 0, 0, ${backgroundOpacity})` }]}>
|
||||
{/* 顶部控制栏 */}
|
||||
{showControls && (
|
||||
<View style={[styles.header, { paddingTop: insets.top + spacing.md }]}>
|
||||
<TouchableOpacity style={styles.closeButton} onPress={onClose}>
|
||||
<MaterialCommunityIcons name="close" size={24} color="#FFF" />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.headerCenter}>
|
||||
<Text style={styles.pageIndicator}>
|
||||
{currentIndex + 1} / {validImages.length}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{enableSave && (
|
||||
<TouchableOpacity
|
||||
style={styles.saveButton}
|
||||
onPress={handleSaveImage}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<ActivityIndicator size="small" color="#FFF" />
|
||||
) : (
|
||||
<MaterialCommunityIcons name="download" size={24} color="#FFF" />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 图片显示区域 */}
|
||||
<GestureDetector gesture={composedGesture}>
|
||||
<View style={styles.imageContainer}>
|
||||
{loading && (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color="#FFF" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<View style={styles.errorContainer}>
|
||||
<MaterialCommunityIcons name="image-off" size={48} color="#999" />
|
||||
<Text style={styles.errorText}>加载失败</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{currentImage && (
|
||||
<Animated.View style={[styles.imageWrapper, animatedStyle]}>
|
||||
<ExpoImage
|
||||
source={{ uri: currentImage.url }}
|
||||
style={styles.image}
|
||||
contentFit="contain"
|
||||
cachePolicy="disk"
|
||||
priority="high"
|
||||
recyclingKey={currentImage.id}
|
||||
allowDownscaling
|
||||
onLoadStart={() => setLoading(true)}
|
||||
onLoad={() => {
|
||||
setLoading(false);
|
||||
setError(false);
|
||||
}}
|
||||
onError={() => {
|
||||
setLoading(false);
|
||||
setError(true);
|
||||
}}
|
||||
/>
|
||||
</Animated.View>
|
||||
)}
|
||||
</View>
|
||||
</GestureDetector>
|
||||
|
||||
{/* 左右切换按钮 */}
|
||||
{showControls && validImages.length > 1 && (
|
||||
<>
|
||||
{currentIndex > 0 && (
|
||||
<TouchableOpacity
|
||||
style={[styles.navButton, styles.navButtonLeft]}
|
||||
onPress={goToPrev}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="chevron-left" size={36} color="#FFF" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{currentIndex < validImages.length - 1 && (
|
||||
<TouchableOpacity
|
||||
style={[styles.navButton, styles.navButtonRight]}
|
||||
onPress={goToNext}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialCommunityIcons name="chevron-right" size={36} color="#FFF" />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 底部指示器 */}
|
||||
{showControls && showIndicator && validImages.length > 1 && (
|
||||
<View style={[styles.footer, { paddingBottom: insets.bottom + spacing.lg }]}>
|
||||
<View style={styles.dotsContainer}>
|
||||
{validImages.map((_, index) => (
|
||||
<View
|
||||
key={index}
|
||||
style={[
|
||||
styles.dot,
|
||||
index === currentIndex && styles.activeDot,
|
||||
]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 保存结果 Toast */}
|
||||
{saveToast !== null && (
|
||||
<View style={[styles.toast, saveToast === 'success' ? styles.toastSuccess : styles.toastError]}>
|
||||
<MaterialCommunityIcons
|
||||
name={saveToast === 'success' ? 'check-circle-outline' : 'alert-circle-outline'}
|
||||
size={18}
|
||||
color="#FFF"
|
||||
/>
|
||||
<Text style={styles.toastText}>
|
||||
{saveToast === 'success' ? '已保存到相册' : '保存失败,请重试'}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</GestureHandlerRootView>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
root: {
|
||||
flex: 1,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
header: {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingBottom: spacing.md,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
zIndex: 10,
|
||||
},
|
||||
closeButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerCenter: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
},
|
||||
pageIndicator: {
|
||||
color: '#FFF',
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '500',
|
||||
},
|
||||
saveButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
imageContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
imageWrapper: {
|
||||
width: SCREEN_WIDTH,
|
||||
height: SCREEN_HEIGHT,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
image: {
|
||||
width: SCREEN_WIDTH,
|
||||
height: SCREEN_HEIGHT * 0.8,
|
||||
},
|
||||
loadingContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 5,
|
||||
},
|
||||
errorContainer: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
errorText: {
|
||||
color: '#999',
|
||||
fontSize: fontSizes.md,
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
navButton: {
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
marginTop: -25,
|
||||
width: 50,
|
||||
height: 50,
|
||||
borderRadius: 25,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 10,
|
||||
},
|
||||
navButtonLeft: {
|
||||
left: spacing.sm,
|
||||
},
|
||||
navButtonRight: {
|
||||
right: spacing.sm,
|
||||
},
|
||||
footer: {
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
paddingVertical: spacing.lg,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.3)',
|
||||
},
|
||||
dotsContainer: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
dot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.4)',
|
||||
},
|
||||
activeDot: {
|
||||
width: 20,
|
||||
borderRadius: 4,
|
||||
backgroundColor: '#FFF',
|
||||
},
|
||||
toast: {
|
||||
position: 'absolute',
|
||||
bottom: 100,
|
||||
alignSelf: 'center',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
borderRadius: borderRadius.full,
|
||||
},
|
||||
toastSuccess: {
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.9)',
|
||||
},
|
||||
toastError: {
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.9)',
|
||||
},
|
||||
toastText: {
|
||||
color: '#FFF',
|
||||
fontSize: fontSizes.sm,
|
||||
fontWeight: '500',
|
||||
},
|
||||
});
|
||||
|
||||
export default ImageGallery;
|
||||
598
src/components/common/ImageGrid.tsx
Normal file
598
src/components/common/ImageGrid.tsx
Normal file
@@ -0,0 +1,598 @@
|
||||
/**
|
||||
* ImageGrid 图片网格组件
|
||||
* 支持 1-9 张图片的智能布局
|
||||
* 根据图片数量自动选择最佳展示方式
|
||||
*/
|
||||
|
||||
import React, { useMemo, useCallback, useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
ViewStyle,
|
||||
Pressable,
|
||||
Text,
|
||||
Image,
|
||||
} from 'react-native';
|
||||
import { SmartImage, ImageSource } from './SmartImage';
|
||||
import { colors, spacing, borderRadius } from '../../theme';
|
||||
|
||||
const { width: SCREEN_WIDTH } = Dimensions.get('window');
|
||||
|
||||
// 默认容器内边距(用于计算可用宽度)
|
||||
const DEFAULT_CONTAINER_PADDING = spacing.lg * 2; // 左右各 spacing.lg
|
||||
|
||||
// 单张图片的默认宽高比
|
||||
const SINGLE_IMAGE_DEFAULT_ASPECT_RATIO = 4 / 3; // 默认4:3比例
|
||||
|
||||
// 单张图片的最大高度
|
||||
const SINGLE_IMAGE_MAX_HEIGHT = 400;
|
||||
|
||||
// 单张图片的最小高度
|
||||
const SINGLE_IMAGE_MIN_HEIGHT = 150;
|
||||
|
||||
// 图片项类型 - 兼容 PostImageDTO 和 CommentImage
|
||||
export interface ImageGridItem {
|
||||
id?: string;
|
||||
uri?: string;
|
||||
url?: string;
|
||||
thumbnailUrl?: string;
|
||||
thumbnail_url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
// 布局模式
|
||||
export type GridLayoutMode = 'auto' | 'single' | 'horizontal' | 'grid' | 'masonry';
|
||||
|
||||
// 组件 Props
|
||||
export interface ImageGridProps {
|
||||
/** 图片列表 */
|
||||
images: ImageGridItem[];
|
||||
/** 容器样式 */
|
||||
style?: ViewStyle;
|
||||
/** 最大显示数量 */
|
||||
maxDisplayCount?: number;
|
||||
/** 布局模式 */
|
||||
mode?: GridLayoutMode;
|
||||
/** 图片间距 */
|
||||
gap?: number;
|
||||
/** 圆角大小 */
|
||||
borderRadius?: number;
|
||||
/** 是否显示更多遮罩 */
|
||||
showMoreOverlay?: boolean;
|
||||
/** 单张图片最大高度 */
|
||||
singleImageMaxHeight?: number;
|
||||
/** 网格列数(2或3) */
|
||||
gridColumns?: 2 | 3;
|
||||
/** 图片点击回调 */
|
||||
onImagePress?: (images: ImageGridItem[], index: number) => void;
|
||||
/** 测试ID */
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算图片网格尺寸
|
||||
*/
|
||||
const calculateGridDimensions = (
|
||||
count: number,
|
||||
containerWidth: number,
|
||||
gap: number,
|
||||
columns: number
|
||||
) => {
|
||||
const totalGap = (columns - 1) * gap;
|
||||
const itemSize = (containerWidth - totalGap) / columns;
|
||||
|
||||
// 计算行数
|
||||
const rows = Math.ceil(count / columns);
|
||||
|
||||
return {
|
||||
itemSize,
|
||||
rows,
|
||||
containerHeight: rows * itemSize + (rows - 1) * gap,
|
||||
};
|
||||
};
|
||||
|
||||
// ─── 单张图片子组件 ───────────────────────────────────────────────────────────
|
||||
// 独立成组件,方便用 useState 管理异步加载到的实际尺寸
|
||||
|
||||
interface SingleImageItemProps {
|
||||
image: ImageGridItem;
|
||||
containerWidth: number;
|
||||
maxHeight: number;
|
||||
borderRadiusValue: number;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const SingleImageItem: React.FC<SingleImageItemProps> = ({
|
||||
image,
|
||||
containerWidth,
|
||||
maxHeight,
|
||||
borderRadiusValue,
|
||||
onPress,
|
||||
}) => {
|
||||
const [aspectRatio, setAspectRatio] = useState<number | null>(null);
|
||||
const uri = image.uri || image.url || '';
|
||||
|
||||
useEffect(() => {
|
||||
if (!uri) return;
|
||||
let cancelled = false;
|
||||
|
||||
// 添加超时处理,防止高分辨率图片加载卡住
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (!cancelled && aspectRatio === null) {
|
||||
setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO);
|
||||
}
|
||||
}, 3000);
|
||||
|
||||
Image.getSize(
|
||||
uri,
|
||||
(w, h) => {
|
||||
if (!cancelled) {
|
||||
clearTimeout(timeoutId);
|
||||
setAspectRatio(w / h);
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (!cancelled) {
|
||||
clearTimeout(timeoutId);
|
||||
setAspectRatio(SINGLE_IMAGE_DEFAULT_ASPECT_RATIO);
|
||||
}
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [uri]);
|
||||
|
||||
const effectiveContainerWidth = containerWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING;
|
||||
|
||||
// 尺寸还没拿到时先占位,避免闪烁
|
||||
if (aspectRatio === null) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.singleContainer,
|
||||
{
|
||||
width: effectiveContainerWidth,
|
||||
height: SINGLE_IMAGE_MIN_HEIGHT,
|
||||
borderRadius: borderRadiusValue,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 适配最大边界框,保证宽高比不变
|
||||
let width: number;
|
||||
let height: number;
|
||||
if (aspectRatio > effectiveContainerWidth / maxHeight) {
|
||||
// 宽图:宽度撑满容器
|
||||
width = effectiveContainerWidth;
|
||||
height = effectiveContainerWidth / aspectRatio;
|
||||
} else {
|
||||
// 高图:高度触及上限
|
||||
height = maxHeight;
|
||||
width = maxHeight * aspectRatio;
|
||||
}
|
||||
|
||||
// 最小高度兜底
|
||||
if (height < SINGLE_IMAGE_MIN_HEIGHT) {
|
||||
height = SINGLE_IMAGE_MIN_HEIGHT;
|
||||
width = Math.min(SINGLE_IMAGE_MIN_HEIGHT * aspectRatio, effectiveContainerWidth);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={[styles.singleContainer, { width, height, borderRadius: borderRadiusValue }]}
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri }}
|
||||
style={styles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 图片网格组件
|
||||
* 智能布局:根据图片数量自动选择最佳展示方式
|
||||
*/
|
||||
export const ImageGrid: React.FC<ImageGridProps> = ({
|
||||
images,
|
||||
style,
|
||||
maxDisplayCount = 9,
|
||||
mode = 'auto',
|
||||
gap = 4,
|
||||
borderRadius: borderRadiusValue = borderRadius.md,
|
||||
showMoreOverlay = true,
|
||||
singleImageMaxHeight = 300,
|
||||
gridColumns = 3,
|
||||
onImagePress,
|
||||
testID,
|
||||
}) => {
|
||||
// 通过 onLayout 拿到容器实际宽度
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
|
||||
// 过滤有效图片 - 支持 uri 或 url 字段
|
||||
const validImages = useMemo(() => {
|
||||
const filtered = images.filter(img => img.uri || img.url || typeof img === 'string');
|
||||
return filtered;
|
||||
}, [images]);
|
||||
|
||||
// 实际显示的图片
|
||||
const displayImages = useMemo(() => {
|
||||
return validImages.slice(0, maxDisplayCount);
|
||||
}, [validImages, maxDisplayCount]);
|
||||
|
||||
// 剩余图片数量
|
||||
const remainingCount = useMemo(() => {
|
||||
return Math.max(0, validImages.length - maxDisplayCount);
|
||||
}, [validImages, maxDisplayCount]);
|
||||
|
||||
// 处理图片点击
|
||||
const handleImagePress = useCallback(
|
||||
(index: number) => {
|
||||
onImagePress?.(validImages, index);
|
||||
},
|
||||
[onImagePress, validImages]
|
||||
);
|
||||
|
||||
// 确定布局模式
|
||||
const layoutMode = useMemo(() => {
|
||||
if (mode !== 'auto') return mode;
|
||||
|
||||
const count = displayImages.length;
|
||||
if (count === 1) return 'single';
|
||||
if (count === 2) return 'horizontal';
|
||||
return 'grid';
|
||||
}, [mode, displayImages.length]);
|
||||
|
||||
// 渲染单张图片
|
||||
const renderSingleImage = () => {
|
||||
const image = displayImages[0];
|
||||
if (!image) return null;
|
||||
return (
|
||||
<SingleImageItem
|
||||
image={image}
|
||||
containerWidth={containerWidth}
|
||||
maxHeight={singleImageMaxHeight}
|
||||
borderRadiusValue={borderRadiusValue}
|
||||
onPress={() => handleImagePress(0)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染横向双图
|
||||
const renderHorizontal = () => {
|
||||
return (
|
||||
<View style={[styles.horizontalContainer, { gap }]}>
|
||||
{displayImages.map((image, index) => (
|
||||
<Pressable
|
||||
key={image.id || index}
|
||||
onPress={() => handleImagePress(index)}
|
||||
style={[
|
||||
styles.horizontalItem,
|
||||
{
|
||||
flex: 1,
|
||||
aspectRatio: 1,
|
||||
borderRadius: borderRadiusValue,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
|
||||
style={styles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
/>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染网格布局
|
||||
const renderGrid = () => {
|
||||
return (
|
||||
<View style={[styles.gridContainer, { gap }]}>
|
||||
{displayImages.map((image, index) => {
|
||||
const isLastVisible = index === displayImages.length - 1;
|
||||
const showOverlay = isLastVisible && remainingCount > 0 && showMoreOverlay;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={image.id || index}
|
||||
onPress={() => handleImagePress(index)}
|
||||
style={[
|
||||
styles.gridItem,
|
||||
gridColumns === 3 ? styles.gridItem3 : styles.gridItem2,
|
||||
{
|
||||
borderRadius: borderRadiusValue,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
|
||||
style={styles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
/>
|
||||
{showOverlay && (
|
||||
<View style={styles.moreOverlay}>
|
||||
<Text style={styles.moreText}>+{remainingCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染瀑布流布局
|
||||
const renderMasonry = () => {
|
||||
const containerWidth = SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING;
|
||||
const columns = 2;
|
||||
const itemWidth = (containerWidth - gap) / columns;
|
||||
|
||||
// 将图片分配到两列
|
||||
const leftColumn: ImageGridItem[] = [];
|
||||
const rightColumn: ImageGridItem[] = [];
|
||||
|
||||
displayImages.forEach((image, index) => {
|
||||
if (index % 2 === 0) {
|
||||
leftColumn.push(image);
|
||||
} else {
|
||||
rightColumn.push(image);
|
||||
}
|
||||
});
|
||||
|
||||
const renderColumn = (columnImages: ImageGridItem[], columnIndex: number) => {
|
||||
return (
|
||||
<View style={[styles.masonryColumn, { gap }]}>
|
||||
{columnImages.map((image, index) => {
|
||||
const actualIndex = columnIndex + index * 2;
|
||||
const aspectRatio = image.width && image.height
|
||||
? image.width / image.height
|
||||
: 1;
|
||||
const height = itemWidth / aspectRatio;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={image.id || actualIndex}
|
||||
onPress={() => handleImagePress(actualIndex)}
|
||||
style={[
|
||||
styles.masonryItem,
|
||||
{
|
||||
width: itemWidth,
|
||||
height: Math.max(height, itemWidth * 0.7),
|
||||
borderRadius: borderRadiusValue,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
|
||||
style={styles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View style={[styles.masonryContainer, { gap }]}>
|
||||
{renderColumn(leftColumn, 0)}
|
||||
{renderColumn(rightColumn, 1)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 根据布局模式渲染
|
||||
const renderContent = () => {
|
||||
switch (layoutMode) {
|
||||
case 'single':
|
||||
return renderSingleImage();
|
||||
case 'horizontal':
|
||||
return renderHorizontal();
|
||||
case 'masonry':
|
||||
return renderMasonry();
|
||||
case 'grid':
|
||||
default:
|
||||
return renderGrid();
|
||||
}
|
||||
};
|
||||
|
||||
// 如果没有图片,返回null
|
||||
if (displayImages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[styles.container, style]}
|
||||
testID={testID}
|
||||
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}
|
||||
>
|
||||
{renderContent()}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 紧凑模式 - 用于评论等小空间场景
|
||||
export interface CompactImageGridProps extends Omit<ImageGridProps, 'mode' | 'gridColumns'> {
|
||||
/** 最大尺寸限制 */
|
||||
maxWidth?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 紧凑图片网格
|
||||
* 适用于评论等空间有限的场景
|
||||
*/
|
||||
export const CompactImageGrid: React.FC<CompactImageGridProps> = ({
|
||||
maxWidth,
|
||||
gap = 4,
|
||||
borderRadius: borderRadiusValue = borderRadius.sm,
|
||||
...props
|
||||
}) => {
|
||||
const containerWidth = maxWidth || SCREEN_WIDTH - DEFAULT_CONTAINER_PADDING - 36 - spacing.sm; // 36是头像宽度
|
||||
|
||||
const renderCompactGrid = () => {
|
||||
const { images } = props;
|
||||
const count = images.length;
|
||||
|
||||
if (count === 0) return null;
|
||||
|
||||
if (count === 1) {
|
||||
const image = images[0];
|
||||
const size = Math.min(containerWidth * 0.6, 150);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => props.onImagePress?.(images, 0)}
|
||||
style={[
|
||||
styles.compactItem,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: borderRadiusValue,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
|
||||
style={styles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
// 多张图片使用小网格
|
||||
const columns = count <= 4 ? 2 : 3;
|
||||
const { itemSize } = calculateGridDimensions(count, containerWidth, gap, columns);
|
||||
|
||||
return (
|
||||
<View style={[styles.compactGrid, { gap }]}>
|
||||
{images.slice(0, 6).map((image, index) => (
|
||||
<Pressable
|
||||
key={image.id || index}
|
||||
onPress={() => props.onImagePress?.(images, index)}
|
||||
style={[
|
||||
styles.compactItem,
|
||||
{
|
||||
width: itemSize,
|
||||
height: itemSize,
|
||||
borderRadius: borderRadiusValue,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<SmartImage
|
||||
source={{ uri: image.uri || image.url, width: image.width, height: image.height }}
|
||||
style={styles.fullSize}
|
||||
resizeMode="cover"
|
||||
borderRadius={borderRadiusValue}
|
||||
/>
|
||||
{index === 5 && images.length > 6 && (
|
||||
<View style={styles.moreOverlay}>
|
||||
<Text style={styles.moreText}>+{images.length - 6}</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
return <View style={styles.compactContainer}>{renderCompactGrid()}</View>;
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
marginTop: spacing.sm,
|
||||
},
|
||||
fullSize: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
// 单图样式
|
||||
singleContainer: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
// 横向布局样式
|
||||
horizontalContainer: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
horizontalItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
// 网格布局样式
|
||||
gridContainer: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
gridItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
aspectRatio: 1,
|
||||
},
|
||||
gridItem2: {
|
||||
width: '48%', // 2列布局,每列约48%宽度,留有余量避免换行
|
||||
},
|
||||
gridItem3: {
|
||||
width: '31%', // 3列布局,每列约31%宽度,留有余量避免换行
|
||||
},
|
||||
// 瀑布流样式
|
||||
masonryContainer: {
|
||||
flexDirection: 'row',
|
||||
},
|
||||
masonryColumn: {
|
||||
flex: 1,
|
||||
},
|
||||
masonryItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
// 更多遮罩 - 类似微博的灰色蒙版
|
||||
moreOverlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.4)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: borderRadius.md,
|
||||
},
|
||||
moreText: {
|
||||
color: colors.text.inverse,
|
||||
fontSize: fontSizes.xl,
|
||||
fontWeight: '500',
|
||||
},
|
||||
// 紧凑模式样式
|
||||
compactContainer: {
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
compactGrid: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
},
|
||||
compactItem: {
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
});
|
||||
|
||||
// 导入字体大小
|
||||
import { fontSizes } from '../../theme';
|
||||
|
||||
export default ImageGrid;
|
||||
169
src/components/common/Input.tsx
Normal file
169
src/components/common/Input.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* Input 输入框组件
|
||||
* 支持标签、错误提示、图标、多行输入等
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
View,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
StyleSheet,
|
||||
ViewStyle,
|
||||
TextStyle,
|
||||
} from 'react-native';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, borderRadius, spacing, fontSizes } from '../../theme';
|
||||
import Text from './Text';
|
||||
|
||||
interface InputProps {
|
||||
value: string;
|
||||
onChangeText: (text: string) => void;
|
||||
placeholder?: string;
|
||||
label?: string;
|
||||
error?: string;
|
||||
secureTextEntry?: boolean;
|
||||
multiline?: boolean;
|
||||
numberOfLines?: number;
|
||||
leftIcon?: string;
|
||||
rightIcon?: string;
|
||||
onRightIconPress?: () => void;
|
||||
editable?: boolean;
|
||||
style?: ViewStyle;
|
||||
inputStyle?: TextStyle;
|
||||
autoCapitalize?: 'none' | 'sentences' | 'words' | 'characters';
|
||||
keyboardType?: 'default' | 'email-address' | 'numeric' | 'phone-pad';
|
||||
autoCorrect?: boolean;
|
||||
}
|
||||
|
||||
const Input: React.FC<InputProps> = ({
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
label,
|
||||
error,
|
||||
secureTextEntry = false,
|
||||
multiline = false,
|
||||
numberOfLines = 1,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
onRightIconPress,
|
||||
editable = true,
|
||||
style,
|
||||
inputStyle,
|
||||
autoCapitalize = 'sentences',
|
||||
keyboardType = 'default',
|
||||
autoCorrect = true,
|
||||
}) => {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const getBorderColor = () => {
|
||||
if (error) return colors.error.main;
|
||||
if (isFocused) return colors.primary.main;
|
||||
return colors.divider;
|
||||
};
|
||||
|
||||
const containerStyle = [
|
||||
styles.container,
|
||||
{ borderColor: getBorderColor() },
|
||||
multiline && { minHeight: 100 },
|
||||
style,
|
||||
];
|
||||
|
||||
return (
|
||||
<View style={styles.wrapper}>
|
||||
{label && (
|
||||
<Text variant="label" color={colors.text.secondary} style={styles.label}>
|
||||
{label}
|
||||
</Text>
|
||||
)}
|
||||
<View style={containerStyle}>
|
||||
{leftIcon && (
|
||||
<MaterialCommunityIcons
|
||||
name={leftIcon as any}
|
||||
size={20}
|
||||
color={colors.text.secondary}
|
||||
style={styles.leftIcon}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
multiline && styles.multilineInput,
|
||||
inputStyle,
|
||||
]}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={colors.text.hint}
|
||||
secureTextEntry={secureTextEntry}
|
||||
multiline={multiline}
|
||||
numberOfLines={numberOfLines}
|
||||
editable={editable}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
autoCapitalize={autoCapitalize}
|
||||
keyboardType={keyboardType}
|
||||
autoCorrect={autoCorrect}
|
||||
/>
|
||||
{rightIcon && (
|
||||
<TouchableOpacity
|
||||
onPress={onRightIconPress}
|
||||
disabled={!onRightIconPress}
|
||||
>
|
||||
<MaterialCommunityIcons
|
||||
name={rightIcon as any}
|
||||
size={20}
|
||||
color={colors.text.secondary}
|
||||
style={styles.rightIcon}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
{error && (
|
||||
<Text variant="caption" color={colors.error.main} style={styles.error}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrapper: {
|
||||
width: '100%',
|
||||
},
|
||||
label: {
|
||||
marginBottom: spacing.xs,
|
||||
},
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.paper,
|
||||
borderWidth: 1,
|
||||
borderRadius: borderRadius.md,
|
||||
paddingHorizontal: spacing.md,
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
fontSize: fontSizes.md,
|
||||
color: colors.text.primary,
|
||||
paddingVertical: spacing.md,
|
||||
minHeight: 44,
|
||||
},
|
||||
multilineInput: {
|
||||
textAlignVertical: 'top',
|
||||
minHeight: 100,
|
||||
},
|
||||
leftIcon: {
|
||||
marginRight: spacing.sm,
|
||||
},
|
||||
rightIcon: {
|
||||
marginLeft: spacing.sm,
|
||||
},
|
||||
error: {
|
||||
marginTop: spacing.xs,
|
||||
},
|
||||
});
|
||||
|
||||
export default Input;
|
||||
65
src/components/common/Loading.tsx
Normal file
65
src/components/common/Loading.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Loading 加载组件
|
||||
* 支持不同尺寸、全屏模式
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, ActivityIndicator, StyleSheet, ViewStyle } from 'react-native';
|
||||
import { colors } from '../../theme';
|
||||
|
||||
type LoadingSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface LoadingProps {
|
||||
size?: LoadingSize;
|
||||
color?: string;
|
||||
fullScreen?: boolean;
|
||||
style?: ViewStyle;
|
||||
}
|
||||
|
||||
const Loading: React.FC<LoadingProps> = ({
|
||||
size = 'md',
|
||||
color = colors.primary.main,
|
||||
fullScreen = false,
|
||||
style,
|
||||
}) => {
|
||||
const getSize = (): 'small' | 'large' | undefined => {
|
||||
switch (size) {
|
||||
case 'sm':
|
||||
return 'small';
|
||||
case 'lg':
|
||||
return 'large';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
if (fullScreen) {
|
||||
return (
|
||||
<View style={[styles.fullScreen, style]}>
|
||||
<ActivityIndicator size={getSize()} color={color} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, style]}>
|
||||
<ActivityIndicator size={getSize()} color={color} />
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
padding: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
fullScreen: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: colors.background.default,
|
||||
},
|
||||
});
|
||||
|
||||
export default Loading;
|
||||
62
src/components/common/ResponsiveContainer.tsx
Normal file
62
src/components/common/ResponsiveContainer.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 响应式容器组件
|
||||
* 在宽屏时居中显示并限制最大宽度,在移动端占满宽度
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { View, StyleProp, ViewStyle } from 'react-native';
|
||||
import { useResponsive } from '../../hooks/useResponsive';
|
||||
|
||||
export interface ResponsiveContainerProps {
|
||||
children: React.ReactNode;
|
||||
maxWidth?: number; // 默认 1200
|
||||
style?: StyleProp<ViewStyle>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应式容器组件
|
||||
*
|
||||
* 根据屏幕尺寸自动调整布局:
|
||||
* - 移动端:占满宽度
|
||||
* - 平板及以上:居中显示,限制最大宽度
|
||||
*
|
||||
* @param props - 组件属性
|
||||
* @param props.children - 子元素
|
||||
* @param props.maxWidth - 最大宽度,默认 1200
|
||||
* @param props.style - 自定义样式
|
||||
*
|
||||
* @example
|
||||
* <ResponsiveContainer>
|
||||
* <YourContent />
|
||||
* </ResponsiveContainer>
|
||||
*/
|
||||
export function ResponsiveContainer({
|
||||
children,
|
||||
maxWidth = 1200,
|
||||
style,
|
||||
}: ResponsiveContainerProps) {
|
||||
const { isWideScreen, width } = useResponsive();
|
||||
|
||||
// 在宽屏时限制最大宽度
|
||||
const containerWidth = isWideScreen ? Math.min(width, maxWidth) : width;
|
||||
|
||||
// 计算水平 padding
|
||||
const horizontalPadding = isWideScreen ? (width - containerWidth) / 2 : 0;
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
width: '100%',
|
||||
maxWidth: isWideScreen ? maxWidth : undefined,
|
||||
paddingHorizontal: isWideScreen ? Math.max(horizontalPadding, 16) : 16,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default ResponsiveContainer;
|
||||
168
src/components/common/ResponsiveGrid.tsx
Normal file
168
src/components/common/ResponsiveGrid.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* 响应式网格布局组件
|
||||
* 根据断点自动调整列数,支持间距配置
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import {
|
||||
useResponsive,
|
||||
useColumnCount,
|
||||
useResponsiveSpacing,
|
||||
FineBreakpointKey,
|
||||
} from '../../hooks/useResponsive';
|
||||
|
||||
export interface ResponsiveGridProps {
|
||||
/** 子元素 */
|
||||
children: React.ReactNode[];
|
||||
/** 自定义样式 */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** 容器样式 */
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
/** 项目样式 */
|
||||
itemStyle?: StyleProp<ViewStyle>;
|
||||
/** 列数配置,根据断点设置 */
|
||||
columns?: Partial<Record<FineBreakpointKey, number>>;
|
||||
/** 间距配置 */
|
||||
gap?: Partial<Record<FineBreakpointKey, number>>;
|
||||
/** 行间距(默认等于 gap) */
|
||||
rowGap?: Partial<Record<FineBreakpointKey, number>>;
|
||||
/** 列间距(默认等于 gap) */
|
||||
columnGap?: Partial<Record<FineBreakpointKey, number>>;
|
||||
/** 是否启用等宽列 */
|
||||
equalColumns?: boolean;
|
||||
/** 自定义列宽计算函数 */
|
||||
getColumnWidth?: (containerWidth: number, columns: number, gap: number) => number;
|
||||
/** 渲染空状态 */
|
||||
renderEmpty?: () => React.ReactNode;
|
||||
/** key 提取函数 */
|
||||
keyExtractor?: (item: React.ReactNode, index: number) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应式网格布局组件
|
||||
*
|
||||
* 根据屏幕宽度自动调整列数,支持平板/桌面端的多列布局
|
||||
*
|
||||
* @example
|
||||
* <ResponsiveGrid
|
||||
* columns={{ xs: 1, sm: 2, md: 3, lg: 4 }}
|
||||
* gap={{ xs: 8, md: 16 }}
|
||||
* >
|
||||
* {items.map(item => <Card key={item.id} {...item} />)}
|
||||
* </ResponsiveGrid>
|
||||
*/
|
||||
export function ResponsiveGrid({
|
||||
children,
|
||||
style,
|
||||
containerStyle,
|
||||
itemStyle,
|
||||
columns: columnsConfig,
|
||||
gap: gapConfig,
|
||||
rowGap: rowGapConfig,
|
||||
columnGap: columnGapConfig,
|
||||
equalColumns = true,
|
||||
getColumnWidth,
|
||||
renderEmpty,
|
||||
keyExtractor,
|
||||
}: ResponsiveGridProps) {
|
||||
const { width } = useResponsive();
|
||||
const columns = useColumnCount(columnsConfig);
|
||||
const defaultGap = useResponsiveSpacing(gapConfig);
|
||||
const rowGap = useResponsiveSpacing(rowGapConfig ?? gapConfig);
|
||||
const columnGap = useResponsiveSpacing(columnGapConfig ?? gapConfig);
|
||||
|
||||
// 计算列宽
|
||||
const columnWidth = useMemo(() => {
|
||||
if (getColumnWidth) {
|
||||
return getColumnWidth(width, columns, columnGap);
|
||||
}
|
||||
|
||||
if (equalColumns) {
|
||||
// 等宽列计算:(总宽度 - (列数 - 1) * 列间距) / 列数
|
||||
return (width - (columns - 1) * columnGap) / columns;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}, [width, columns, columnGap, equalColumns, getColumnWidth]);
|
||||
|
||||
// 将子元素分组为行
|
||||
const rows = useMemo(() => {
|
||||
const items = React.Children.toArray(children);
|
||||
if (items.length === 0) return [];
|
||||
|
||||
const result: React.ReactNode[][] = [];
|
||||
for (let i = 0; i < items.length; i += columns) {
|
||||
result.push(items.slice(i, i + columns));
|
||||
}
|
||||
return result;
|
||||
}, [children, columns]);
|
||||
|
||||
// 空状态处理
|
||||
if (rows.length === 0 && renderEmpty) {
|
||||
return (
|
||||
<View style={[styles.container, containerStyle]}>
|
||||
{renderEmpty()}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.container, containerStyle]}>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<View
|
||||
key={`row-${rowIndex}`}
|
||||
style={[
|
||||
styles.row,
|
||||
{
|
||||
marginBottom: rowIndex < rows.length - 1 ? rowGap : 0,
|
||||
},
|
||||
style,
|
||||
]}
|
||||
>
|
||||
{row.map((child, colIndex) => {
|
||||
const index = rowIndex * columns + colIndex;
|
||||
const key = keyExtractor?.(child, index) ?? `grid-item-${index}`;
|
||||
|
||||
return (
|
||||
<View
|
||||
key={key}
|
||||
style={[
|
||||
styles.item,
|
||||
{
|
||||
width: columnWidth,
|
||||
marginRight: colIndex < row.length - 1 ? columnGap : 0,
|
||||
},
|
||||
itemStyle,
|
||||
]}
|
||||
>
|
||||
{child}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
},
|
||||
row: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
},
|
||||
item: {
|
||||
flexShrink: 0,
|
||||
},
|
||||
});
|
||||
|
||||
export default ResponsiveGrid;
|
||||
212
src/components/common/ResponsiveStack.tsx
Normal file
212
src/components/common/ResponsiveStack.tsx
Normal file
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 响应式堆叠布局组件
|
||||
* 移动端垂直堆叠,平板/桌面端水平排列
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleProp,
|
||||
ViewStyle,
|
||||
StyleSheet,
|
||||
FlexAlignType,
|
||||
} from 'react-native';
|
||||
import {
|
||||
useResponsive,
|
||||
useResponsiveSpacing,
|
||||
FineBreakpointKey,
|
||||
useBreakpointGTE,
|
||||
} from '../../hooks/useResponsive';
|
||||
|
||||
export type StackDirection = 'horizontal' | 'vertical' | 'responsive';
|
||||
export type StackAlignment = 'start' | 'center' | 'end' | 'stretch' | 'baseline';
|
||||
export type StackJustify = 'start' | 'center' | 'end' | 'between' | 'around' | 'evenly';
|
||||
|
||||
export interface ResponsiveStackProps {
|
||||
/** 子元素 */
|
||||
children: React.ReactNode;
|
||||
/** 布局方向 */
|
||||
direction?: StackDirection;
|
||||
/** 切换为水平布局的断点(仅在 direction='responsive' 时有效) */
|
||||
horizontalBreakpoint?: FineBreakpointKey;
|
||||
/** 间距 */
|
||||
gap?: Partial<Record<FineBreakpointKey, number>> | number;
|
||||
/** 是否允许换行 */
|
||||
wrap?: boolean;
|
||||
/** 对齐方式(交叉轴) */
|
||||
align?: StackAlignment;
|
||||
/** 分布方式(主轴) */
|
||||
justify?: StackJustify;
|
||||
/** 自定义样式 */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** 子元素样式 */
|
||||
itemStyle?: StyleProp<ViewStyle>;
|
||||
/** 是否反转顺序 */
|
||||
reverse?: boolean;
|
||||
/** 是否等分空间 */
|
||||
equalItem?: boolean;
|
||||
}
|
||||
|
||||
const alignMap: Record<StackAlignment, FlexAlignType> = {
|
||||
start: 'flex-start',
|
||||
center: 'center',
|
||||
end: 'flex-end',
|
||||
stretch: 'stretch',
|
||||
baseline: 'baseline',
|
||||
};
|
||||
|
||||
const justifyMap: Record<StackJustify, 'flex-start' | 'center' | 'flex-end' | 'space-between' | 'space-around' | 'space-evenly'> = {
|
||||
start: 'flex-start',
|
||||
center: 'center',
|
||||
end: 'flex-end',
|
||||
between: 'space-between',
|
||||
around: 'space-around',
|
||||
evenly: 'space-evenly',
|
||||
};
|
||||
|
||||
/**
|
||||
* 响应式堆叠布局组件
|
||||
*
|
||||
* - 移动端垂直堆叠
|
||||
* - 平板/桌面端水平排列
|
||||
* - 支持间距和换行配置
|
||||
*
|
||||
* @example
|
||||
* // 基础用法 - 自动响应式
|
||||
* <ResponsiveStack>
|
||||
* <Item1 />
|
||||
* <Item2 />
|
||||
* <Item3 />
|
||||
* </ResponsiveStack>
|
||||
*
|
||||
* @example
|
||||
* // 自定义断点和间距
|
||||
* <ResponsiveStack
|
||||
* direction="responsive"
|
||||
* horizontalBreakpoint="md"
|
||||
* gap={{ xs: 8, md: 16, lg: 24 }}
|
||||
* align="center"
|
||||
* justify="between"
|
||||
* >
|
||||
* <Item1 />
|
||||
* <Item2 />
|
||||
* </ResponsiveStack>
|
||||
*
|
||||
* @example
|
||||
* // 固定水平方向
|
||||
* <ResponsiveStack direction="horizontal" wrap gap={16}>
|
||||
* {items.map(item => <Tag key={item.id} {...item} />)}
|
||||
* </ResponsiveStack>
|
||||
*/
|
||||
export function ResponsiveStack({
|
||||
children,
|
||||
direction = 'responsive',
|
||||
horizontalBreakpoint = 'lg',
|
||||
gap: gapConfig,
|
||||
wrap = false,
|
||||
align = 'stretch',
|
||||
justify = 'start',
|
||||
style,
|
||||
itemStyle,
|
||||
reverse = false,
|
||||
equalItem = false,
|
||||
}: ResponsiveStackProps) {
|
||||
const { isMobile, isTablet } = useResponsive();
|
||||
const isHorizontalBreakpoint = useBreakpointGTE(horizontalBreakpoint);
|
||||
|
||||
// 计算间距
|
||||
const gap = useMemo(() => {
|
||||
if (typeof gapConfig === 'number') {
|
||||
return gapConfig;
|
||||
}
|
||||
// 使用 hook 获取响应式间距
|
||||
return gapConfig;
|
||||
}, [gapConfig]);
|
||||
|
||||
const responsiveGap = useResponsiveSpacing(typeof gap === 'number' ? undefined : gap);
|
||||
const finalGap = typeof gap === 'number' ? gap : responsiveGap;
|
||||
|
||||
// 确定布局方向
|
||||
const isHorizontal = useMemo(() => {
|
||||
if (direction === 'horizontal') return true;
|
||||
if (direction === 'vertical') return false;
|
||||
// direction === 'responsive'
|
||||
return isHorizontalBreakpoint;
|
||||
}, [direction, isHorizontalBreakpoint]);
|
||||
|
||||
// 构建容器样式
|
||||
const containerStyle = useMemo((): ViewStyle => {
|
||||
const flexDirection = isHorizontal
|
||||
? (reverse ? 'row-reverse' : 'row')
|
||||
: (reverse ? 'column-reverse' : 'column');
|
||||
|
||||
return {
|
||||
flexDirection,
|
||||
flexWrap: wrap ? 'wrap' : 'nowrap',
|
||||
alignItems: alignMap[align],
|
||||
justifyContent: justifyMap[justify],
|
||||
gap: finalGap,
|
||||
};
|
||||
}, [isHorizontal, reverse, wrap, align, justify, finalGap]);
|
||||
|
||||
// 处理子元素
|
||||
const processedChildren = useMemo(() => {
|
||||
const childrenArray = React.Children.toArray(children);
|
||||
|
||||
return childrenArray.map((child, index) => {
|
||||
if (!React.isValidElement(child)) {
|
||||
return child;
|
||||
}
|
||||
|
||||
const childStyle: ViewStyle = {};
|
||||
|
||||
if (equalItem) {
|
||||
childStyle.flex = 1;
|
||||
}
|
||||
|
||||
// 如果不是最后一个元素,添加间距
|
||||
// 注意:使用 gap 后不需要手动添加 margin
|
||||
|
||||
return (
|
||||
<View
|
||||
key={child.key ?? `stack-item-${index}`}
|
||||
style={[equalItem && styles.equalItem, itemStyle, childStyle]}
|
||||
>
|
||||
{child}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
}, [children, equalItem, itemStyle]);
|
||||
|
||||
return (
|
||||
<View style={[styles.container, containerStyle, style]}>
|
||||
{processedChildren}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
width: '100%',
|
||||
},
|
||||
equalItem: {
|
||||
flex: 1,
|
||||
minWidth: 0, // 防止 flex item 溢出
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 水平堆叠组件(快捷方式)
|
||||
*/
|
||||
export function HStack(props: Omit<ResponsiveStackProps, 'direction'>) {
|
||||
return <ResponsiveStack {...props} direction="horizontal" />;
|
||||
}
|
||||
|
||||
/**
|
||||
* 垂直堆叠组件(快捷方式)
|
||||
*/
|
||||
export function VStack(props: Omit<ResponsiveStackProps, 'direction'>) {
|
||||
return <ResponsiveStack {...props} direction="vertical" />;
|
||||
}
|
||||
|
||||
export default ResponsiveStack;
|
||||
277
src/components/common/SmartImage.tsx
Normal file
277
src/components/common/SmartImage.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* SmartImage 智能图片组件
|
||||
* 支持加载状态、错误处理、自适应尺寸
|
||||
* 基于 expo-image 封装,原生支持 GIF/WebP 动图
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
View,
|
||||
StyleSheet,
|
||||
ViewStyle,
|
||||
ImageStyle,
|
||||
ActivityIndicator,
|
||||
Pressable,
|
||||
StyleProp,
|
||||
} from 'react-native';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { colors, borderRadius } from '../../theme';
|
||||
|
||||
// 图片加载状态
|
||||
export type ImageLoadState = 'loading' | 'success' | 'error';
|
||||
|
||||
// 图片源类型 - 兼容多种数据格式
|
||||
export interface ImageSource {
|
||||
uri?: string;
|
||||
url?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
// SmartImage Props
|
||||
export interface SmartImageProps {
|
||||
/** 图片源 */
|
||||
source: ImageSource | string;
|
||||
/** 容器样式 */
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** 图片样式 */
|
||||
imageStyle?: StyleProp<ImageStyle>;
|
||||
/** 图片填充模式 */
|
||||
resizeMode?: 'cover' | 'contain' | 'stretch' | 'repeat' | 'center';
|
||||
/** 是否显示加载指示器 */
|
||||
showLoading?: boolean;
|
||||
/** 是否显示错误占位图 */
|
||||
showError?: boolean;
|
||||
/** 圆角大小 */
|
||||
borderRadius?: number;
|
||||
/** 点击回调 */
|
||||
onPress?: () => void;
|
||||
/** 长按回调 */
|
||||
onLongPress?: () => void;
|
||||
/** 加载完成回调 */
|
||||
onLoad?: () => void;
|
||||
/** 加载失败回调 */
|
||||
onError?: (error: any) => void;
|
||||
/** 测试ID */
|
||||
testID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能图片组件
|
||||
* 自动处理加载状态、错误状态
|
||||
*/
|
||||
export const SmartImage: React.FC<SmartImageProps> = ({
|
||||
source,
|
||||
style,
|
||||
imageStyle,
|
||||
resizeMode = 'cover',
|
||||
showLoading = true,
|
||||
showError = true,
|
||||
borderRadius: borderRadiusValue = 0,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onLoad,
|
||||
onError,
|
||||
testID,
|
||||
}) => {
|
||||
const [loadState, setLoadState] = useState<ImageLoadState>('loading');
|
||||
|
||||
// 解析图片源 - 支持 uri 或 url 字段
|
||||
const imageUri = typeof source === 'string'
|
||||
? source
|
||||
: (source.uri || source.url || '');
|
||||
|
||||
// 处理加载开始
|
||||
const handleLoadStart = useCallback(() => {
|
||||
setLoadState('loading');
|
||||
}, []);
|
||||
|
||||
// 处理加载完成
|
||||
const handleLoad = useCallback(() => {
|
||||
setLoadState('success');
|
||||
onLoad?.();
|
||||
}, [onLoad]);
|
||||
|
||||
// 处理加载错误
|
||||
const handleError = useCallback(
|
||||
(error: any) => {
|
||||
setLoadState('error');
|
||||
onError?.(error);
|
||||
},
|
||||
[onError]
|
||||
);
|
||||
|
||||
// 重试加载
|
||||
const handleRetry = useCallback(() => {
|
||||
setLoadState('loading');
|
||||
}, []);
|
||||
|
||||
// 渲染加载指示器
|
||||
const renderLoading = () => {
|
||||
if (!showLoading || loadState !== 'loading') return null;
|
||||
|
||||
return (
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="small" color={colors.primary.main} />
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
// 渲染错误占位图
|
||||
const renderError = () => {
|
||||
if (!showError || loadState !== 'error') return null;
|
||||
|
||||
return (
|
||||
<Pressable style={styles.overlay} onPress={handleRetry}>
|
||||
<View style={styles.errorContainer}>
|
||||
<MaterialCommunityIcons
|
||||
name="image-off-outline"
|
||||
size={24}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
// 容器样式
|
||||
const containerStyle: ViewStyle = {
|
||||
borderRadius: borderRadiusValue,
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
// 图片源配置
|
||||
const imageSource = imageUri && imageUri.trim() !== '' ? { uri: imageUri } : undefined;
|
||||
|
||||
// 如果没有有效的图片源,显示错误占位
|
||||
if (!imageSource) {
|
||||
return (
|
||||
<Pressable
|
||||
style={[containerStyle, style as ViewStyle, { backgroundColor: colors.background.disabled }]}
|
||||
testID={testID}
|
||||
>
|
||||
<View style={styles.errorContainer}>
|
||||
<MaterialCommunityIcons
|
||||
name="image-off-outline"
|
||||
size={24}
|
||||
color={colors.text.hint}
|
||||
/>
|
||||
</View>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
style={[containerStyle, style as ViewStyle]}
|
||||
onPress={onPress}
|
||||
onLongPress={onLongPress}
|
||||
disabled={!onPress && !onLongPress}
|
||||
testID={testID}
|
||||
>
|
||||
<ExpoImage
|
||||
source={imageSource}
|
||||
style={[styles.image, imageStyle as ImageStyle]}
|
||||
contentFit={
|
||||
resizeMode === 'stretch' ? 'fill' :
|
||||
resizeMode === 'repeat' ? 'cover' :
|
||||
resizeMode === 'center' ? 'scale-down' :
|
||||
resizeMode
|
||||
}
|
||||
cachePolicy="memory-disk"
|
||||
onLoadStart={handleLoadStart}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
/>
|
||||
{renderLoading()}
|
||||
{renderError()}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
// 预定义尺寸变体
|
||||
export interface ImageVariantProps extends Omit<SmartImageProps, 'style'> {
|
||||
/** 尺寸变体 */
|
||||
variant?: 'thumbnail' | 'small' | 'medium' | 'large' | 'full';
|
||||
/** 自定义尺寸 */
|
||||
size?: number;
|
||||
/** 宽高比 */
|
||||
aspectRatio?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 变体图片组件
|
||||
* 提供预定义的尺寸变体
|
||||
*/
|
||||
export const VariantImage: React.FC<ImageVariantProps> = ({
|
||||
variant = 'medium',
|
||||
size,
|
||||
aspectRatio,
|
||||
imageStyle,
|
||||
...props
|
||||
}) => {
|
||||
const getVariantStyle = (): ViewStyle => {
|
||||
switch (variant) {
|
||||
case 'thumbnail':
|
||||
return { width: size || 40, height: size || 40 };
|
||||
case 'small':
|
||||
return { width: size || 80, height: size || 80 };
|
||||
case 'medium':
|
||||
return { width: size || 120, height: size || 120 };
|
||||
case 'large':
|
||||
return { width: size || 200, height: size || 200 };
|
||||
case 'full':
|
||||
return { flex: 1 };
|
||||
default:
|
||||
return { width: size || 120, height: size || 120 };
|
||||
}
|
||||
};
|
||||
|
||||
const variantStyle = getVariantStyle();
|
||||
|
||||
// 如果有宽高比,调整高度
|
||||
const finalStyle: ViewStyle = { ...variantStyle };
|
||||
if (aspectRatio && finalStyle.width && !size) {
|
||||
finalStyle.height = (finalStyle.width as number) / aspectRatio;
|
||||
}
|
||||
|
||||
return (
|
||||
<SmartImage
|
||||
{...props}
|
||||
style={finalStyle}
|
||||
imageStyle={[styles.variantImage, imageStyle]}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
image: {
|
||||
flex: 1,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
variantImage: {
|
||||
flex: 1,
|
||||
},
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.background.disabled,
|
||||
},
|
||||
loadingContainer: {
|
||||
padding: 8,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
},
|
||||
errorContainer: {
|
||||
padding: 12,
|
||||
borderRadius: borderRadius.md,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
});
|
||||
|
||||
export default SmartImage;
|
||||
96
src/components/common/Text.tsx
Normal file
96
src/components/common/Text.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Text 文本组件
|
||||
* 提供统一的文本样式
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Text as RNText, TextProps, StyleSheet, TextStyle, ViewStyle } from 'react-native';
|
||||
import { colors, fontSizes } from '../../theme';
|
||||
|
||||
type TextVariant = 'h1' | 'h2' | 'h3' | 'body' | 'caption' | 'label';
|
||||
|
||||
interface CustomTextProps extends Omit<TextProps, 'style'> {
|
||||
children: React.ReactNode;
|
||||
variant?: TextVariant;
|
||||
color?: string;
|
||||
numberOfLines?: number;
|
||||
onPress?: () => void;
|
||||
style?: TextStyle | TextStyle[];
|
||||
}
|
||||
|
||||
const variantStyles: Record<TextVariant, object> = {
|
||||
h1: {
|
||||
fontSize: fontSizes['4xl'],
|
||||
fontWeight: '700',
|
||||
lineHeight: fontSizes['4xl'] * 1.4,
|
||||
},
|
||||
h2: {
|
||||
fontSize: fontSizes['3xl'],
|
||||
fontWeight: '600',
|
||||
lineHeight: fontSizes['3xl'] * 1.4,
|
||||
},
|
||||
h3: {
|
||||
fontSize: fontSizes['2xl'],
|
||||
fontWeight: '600',
|
||||
lineHeight: fontSizes['2xl'] * 1.3,
|
||||
},
|
||||
body: {
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '400',
|
||||
lineHeight: fontSizes.md * 1.5,
|
||||
},
|
||||
caption: {
|
||||
fontSize: fontSizes.sm,
|
||||
fontWeight: '400',
|
||||
lineHeight: fontSizes.sm * 1.4,
|
||||
},
|
||||
label: {
|
||||
fontSize: fontSizes.xs,
|
||||
fontWeight: '500',
|
||||
lineHeight: fontSizes.xs * 1.4,
|
||||
},
|
||||
};
|
||||
|
||||
const Text: React.FC<CustomTextProps> = ({
|
||||
children,
|
||||
variant = 'body',
|
||||
color,
|
||||
numberOfLines,
|
||||
onPress,
|
||||
style,
|
||||
...props
|
||||
}) => {
|
||||
const textStyle = [
|
||||
styles.base,
|
||||
variantStyles[variant],
|
||||
color ? { color } : { color: colors.text.primary },
|
||||
style,
|
||||
];
|
||||
|
||||
if (onPress) {
|
||||
return (
|
||||
<RNText
|
||||
style={textStyle}
|
||||
numberOfLines={numberOfLines}
|
||||
onPress={onPress}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</RNText>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<RNText style={textStyle} numberOfLines={numberOfLines} {...props}>
|
||||
{children}
|
||||
</RNText>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
base: {
|
||||
fontFamily: undefined, // 使用系统默认字体
|
||||
},
|
||||
});
|
||||
|
||||
export default Text;
|
||||
145
src/components/common/VideoPlayerModal.tsx
Normal file
145
src/components/common/VideoPlayerModal.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* VideoPlayerModal 视频播放弹窗组件
|
||||
* 全屏模态播放视频,基于 expo-video
|
||||
*/
|
||||
|
||||
import React, { useCallback } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
View,
|
||||
StyleSheet,
|
||||
Dimensions,
|
||||
TouchableOpacity,
|
||||
Text,
|
||||
StatusBar,
|
||||
} from 'react-native';
|
||||
import { VideoView, useVideoPlayer } from 'expo-video';
|
||||
import { MaterialCommunityIcons } from '@expo/vector-icons';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { spacing, fontSizes, borderRadius } from '../../theme';
|
||||
|
||||
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
export interface VideoPlayerModalProps {
|
||||
/** 是否可见 */
|
||||
visible: boolean;
|
||||
/** 视频 URL */
|
||||
url: string;
|
||||
/** 关闭回调 */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频播放器内容 - 独立组件,仅在弹窗可见时挂载,避免预加载
|
||||
*/
|
||||
const VideoPlayerContent: React.FC<{ url: string; onClose: () => void }> = ({ url, onClose }) => {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const player = useVideoPlayer(url, (p) => {
|
||||
p.loop = false;
|
||||
p.play();
|
||||
});
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
player.pause();
|
||||
onClose();
|
||||
}, [player, onClose]);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<StatusBar hidden />
|
||||
|
||||
{/* 顶部关闭按钮 */}
|
||||
<View style={[styles.header, { paddingTop: insets.top + spacing.md }]}>
|
||||
<TouchableOpacity style={styles.closeButton} onPress={handleClose}>
|
||||
<MaterialCommunityIcons name="close" size={24} color="#FFF" />
|
||||
</TouchableOpacity>
|
||||
<Text style={styles.title}>视频</Text>
|
||||
<View style={styles.placeholder} />
|
||||
</View>
|
||||
|
||||
{/* 视频播放区域 */}
|
||||
<View style={styles.videoContainer}>
|
||||
<VideoView
|
||||
player={player}
|
||||
style={styles.video}
|
||||
contentFit="contain"
|
||||
nativeControls
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 视频播放弹窗 - 仅在可见时渲染播放器,避免提前加载视频资源
|
||||
*/
|
||||
export const VideoPlayerModal: React.FC<VideoPlayerModalProps> = ({
|
||||
visible,
|
||||
url,
|
||||
onClose,
|
||||
}) => {
|
||||
return (
|
||||
<Modal
|
||||
visible={visible}
|
||||
transparent={false}
|
||||
animationType="fade"
|
||||
onRequestClose={onClose}
|
||||
statusBarTranslucent
|
||||
supportedOrientations={['portrait', 'landscape']}
|
||||
>
|
||||
{visible && url ? (
|
||||
<VideoPlayerContent url={url} onClose={onClose} />
|
||||
) : (
|
||||
<View style={styles.container} />
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingBottom: spacing.md,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 10,
|
||||
},
|
||||
closeButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: borderRadius.full,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.2)',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
title: {
|
||||
color: '#FFF',
|
||||
fontSize: fontSizes.md,
|
||||
fontWeight: '600',
|
||||
},
|
||||
placeholder: {
|
||||
width: 40,
|
||||
},
|
||||
videoContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
video: {
|
||||
width: SCREEN_WIDTH,
|
||||
height: SCREEN_HEIGHT,
|
||||
},
|
||||
});
|
||||
|
||||
export default VideoPlayerModal;
|
||||
33
src/components/common/index.ts
Normal file
33
src/components/common/index.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 通用组件导出
|
||||
*/
|
||||
|
||||
export { default as Avatar } from './Avatar';
|
||||
export { default as Button } from './Button';
|
||||
export { default as Card } from './Card';
|
||||
export { default as Input } from './Input';
|
||||
export { default as Loading } from './Loading';
|
||||
export { default as Text } from './Text';
|
||||
export { default as EmptyState } from './EmptyState';
|
||||
export { default as Divider } from './Divider';
|
||||
export { default as ResponsiveContainer } from './ResponsiveContainer';
|
||||
|
||||
// 响应式布局组件
|
||||
export { default as ResponsiveGrid } from './ResponsiveGrid';
|
||||
export { default as ResponsiveStack, HStack, VStack } from './ResponsiveStack';
|
||||
export { default as AdaptiveLayout, SidebarLayout } from './AdaptiveLayout';
|
||||
|
||||
// 图片相关组件
|
||||
export { default as SmartImage } from './SmartImage';
|
||||
export { default as ImageGrid, CompactImageGrid } from './ImageGrid';
|
||||
export { default as ImageGallery } from './ImageGallery';
|
||||
|
||||
// 类型导出
|
||||
export type { SmartImageProps, ImageLoadState, ImageSource } from './SmartImage';
|
||||
export type { ImageGridProps, ImageGridItem, GridLayoutMode, CompactImageGridProps } from './ImageGrid';
|
||||
export type { ImageGalleryProps, GalleryImageItem } from './ImageGallery';
|
||||
|
||||
// 响应式组件类型导出
|
||||
export type { ResponsiveGridProps } from './ResponsiveGrid';
|
||||
export type { ResponsiveStackProps, StackDirection, StackAlignment, StackJustify } from './ResponsiveStack';
|
||||
export type { AdaptiveLayoutProps, SidebarLayoutProps } from './AdaptiveLayout';
|
||||
Reference in New Issue
Block a user