feat: 首页更新
This commit is contained in:
296
components/Header/LeftMenu.tsx
Normal file
296
components/Header/LeftMenu.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* 左侧菜单组件
|
||||
* 基于 react-native-paper Drawer 组件重建
|
||||
* 功能包括:
|
||||
* - 用户钱包信息
|
||||
* - 菜单项导航
|
||||
* - 利息宝显示
|
||||
* - 登录状态判断
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import { View, Text, ScrollView, Modal, TouchableOpacity, Dimensions } from 'react-native';
|
||||
import { Drawer, Button, Divider } from 'react-native-paper';
|
||||
import { useColorScheme, useThemeColors } from '@/theme';
|
||||
import { createThemeStyles } from '@/theme';
|
||||
import { useIsLoggedIn, useUser } from '@/stores';
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
import WalletAmount from '../WalletAmount';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
/**
|
||||
* 创建主题样式
|
||||
*/
|
||||
const styles = createThemeStyles((colors) => ({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
},
|
||||
drawerContainer: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
width: width * 0.65,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
container: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.background,
|
||||
},
|
||||
walletSection: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 16,
|
||||
backgroundColor: colors.card,
|
||||
marginHorizontal: 12,
|
||||
marginVertical: 12,
|
||||
borderRadius: 12,
|
||||
},
|
||||
walletLabel: {
|
||||
fontSize: 12,
|
||||
color: colors.text + '80',
|
||||
marginBottom: 8,
|
||||
},
|
||||
walletButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
marginTop: 12,
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
},
|
||||
lixibaoSection: {
|
||||
marginHorizontal: 12,
|
||||
marginVertical: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
borderColor: colors.primary,
|
||||
backgroundColor: colors.card,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
lixibaoLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
lixibaoInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
lixibaoLabel: {
|
||||
fontSize: 12,
|
||||
color: colors.text + '80',
|
||||
},
|
||||
lixibaoValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: colors.primary,
|
||||
marginTop: 4,
|
||||
},
|
||||
emptyText: {
|
||||
textAlign: 'center',
|
||||
fontSize: 14,
|
||||
color: colors.text + '80',
|
||||
paddingVertical: 40,
|
||||
},
|
||||
}));
|
||||
|
||||
interface LeftMenuProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface MenuItem {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
route?: string;
|
||||
requireLogin?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 左侧菜单组件
|
||||
*/
|
||||
export default function LeftMenu({ visible, onClose }: LeftMenuProps) {
|
||||
const colorScheme = useColorScheme();
|
||||
const s = styles[colorScheme];
|
||||
const themeColors = useThemeColors();
|
||||
const isLoggedIn = useIsLoggedIn();
|
||||
const user = useUser();
|
||||
|
||||
const [walletBalance] = useState(0); // 从 store 获取
|
||||
const [interestBalance] = useState(0); // 从 store 获取
|
||||
|
||||
// 菜单项配置
|
||||
const menuItems: MenuItem[] = useMemo(
|
||||
() => [
|
||||
{ id: 'balance', title: '利息宝', icon: 'gift' },
|
||||
{
|
||||
id: 'services',
|
||||
title: '客服',
|
||||
icon: 'headset',
|
||||
route: '/services',
|
||||
},
|
||||
{
|
||||
id: 'collect',
|
||||
title: '收藏',
|
||||
icon: 'star',
|
||||
route: '/collect',
|
||||
},
|
||||
{
|
||||
id: 'recent',
|
||||
title: '最近',
|
||||
icon: 'time',
|
||||
route: '/recent',
|
||||
},
|
||||
{
|
||||
id: 'betRecord',
|
||||
title: '投注记录',
|
||||
icon: 'document-text',
|
||||
route: '/betRecord',
|
||||
requireLogin: true,
|
||||
},
|
||||
{
|
||||
id: 'activity',
|
||||
title: '任务中心',
|
||||
icon: 'checkmark-circle',
|
||||
route: '/activity',
|
||||
},
|
||||
{
|
||||
id: 'bonus',
|
||||
title: '福利中心',
|
||||
icon: 'gift',
|
||||
route: '/bonus',
|
||||
},
|
||||
{
|
||||
id: 'rebate',
|
||||
title: '自助返水',
|
||||
icon: 'cash',
|
||||
route: '/rebate',
|
||||
},
|
||||
{
|
||||
id: 'findBalance',
|
||||
title: '找回余额',
|
||||
icon: 'search',
|
||||
route: '/findBalance',
|
||||
},
|
||||
{
|
||||
id: 'message',
|
||||
title: '消息中心',
|
||||
icon: 'mail',
|
||||
route: '/message',
|
||||
},
|
||||
{
|
||||
id: 'promotion',
|
||||
title: '推广赚钱',
|
||||
icon: 'share-social',
|
||||
route: '/promotion',
|
||||
},
|
||||
{
|
||||
id: 'backup',
|
||||
title: '备用网站',
|
||||
icon: 'link',
|
||||
route: '/backup',
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const handleMenuItemPress = useCallback(
|
||||
(item: MenuItem) => {
|
||||
if (item.requireLogin && !isLoggedIn) {
|
||||
// 显示登录提示
|
||||
return;
|
||||
}
|
||||
// 导航到对应页面
|
||||
console.log('Navigate to:', item.route);
|
||||
onClose();
|
||||
},
|
||||
[isLoggedIn, onClose]
|
||||
);
|
||||
|
||||
const handleDeposit = useCallback(() => {
|
||||
console.log('Navigate to deposit');
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const handleWithdraw = useCallback(() => {
|
||||
console.log('Navigate to withdraw');
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="fade" onRequestClose={onClose}>
|
||||
<View style={s.overlay}>
|
||||
<TouchableOpacity style={{ flex: 1 }} activeOpacity={1} onPress={onClose} />
|
||||
<View style={s.drawerContainer}>
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{/* 用户信息或登录提示 */}
|
||||
{isLoggedIn && user ? (
|
||||
<>
|
||||
{/* 钱包信息 */}
|
||||
<View style={s.walletSection}>
|
||||
<Text style={s.walletLabel}>钱包余额</Text>
|
||||
<WalletAmount balance={walletBalance} />
|
||||
<View style={s.walletButtons}>
|
||||
<Button
|
||||
mode="contained"
|
||||
onPress={handleDeposit}
|
||||
style={{ flex: 1 }}
|
||||
labelStyle={s.buttonText}
|
||||
>
|
||||
充值
|
||||
</Button>
|
||||
<Button
|
||||
mode="outlined"
|
||||
onPress={handleWithdraw}
|
||||
style={{ flex: 1 }}
|
||||
labelStyle={s.buttonText}
|
||||
>
|
||||
提现
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
</>
|
||||
) : (
|
||||
<Text style={s.emptyText}>加入我们,开启畅赢人生!</Text>
|
||||
)}
|
||||
|
||||
<Divider style={{ marginVertical: 8 }} />
|
||||
|
||||
{/* 菜单项 */}
|
||||
<Drawer.Section title="">
|
||||
{menuItems.map((item) => (
|
||||
<Drawer.Item
|
||||
key={item.id}
|
||||
label={item.title}
|
||||
icon={({ color, size }) => (
|
||||
<Ionicons name={item.icon as any} size={size} color={color} />
|
||||
)}
|
||||
onPress={() => handleMenuItemPress(item)}
|
||||
active={false}
|
||||
right={() => (
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={20}
|
||||
color={themeColors.text + '60'}
|
||||
style={{ marginRight: -10 }}
|
||||
/>
|
||||
)}
|
||||
style={{ marginHorizontal: 0 }}
|
||||
/>
|
||||
))}
|
||||
</Drawer.Section>
|
||||
</ScrollView>
|
||||
</View>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
363
components/Header/LoginRegisterModal.tsx
Normal file
363
components/Header/LoginRegisterModal.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* 登录/注册弹窗组件
|
||||
* 支持登录和注册两种模式
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TouchableOpacity,
|
||||
TextInput,
|
||||
Modal,
|
||||
ScrollView,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useColorScheme, useThemeColors } from '@/theme';
|
||||
import { createThemeStyles } from '@/theme';
|
||||
import { useUserStore } from '@/stores';
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
|
||||
/**
|
||||
* 创建主题样式
|
||||
*/
|
||||
const styles = createThemeStyles((colors) => ({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.5)',
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
container: {
|
||||
backgroundColor: colors.background,
|
||||
borderTopLeftRadius: 20,
|
||||
borderTopRightRadius: 20,
|
||||
paddingHorizontal: 20,
|
||||
paddingTop: 20,
|
||||
paddingBottom: 40,
|
||||
maxHeight: '80%',
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
title: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
color: colors.text,
|
||||
},
|
||||
closeButton: {
|
||||
padding: 8,
|
||||
},
|
||||
tabContainer: {
|
||||
flexDirection: 'row',
|
||||
marginBottom: 20,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSecondary,
|
||||
},
|
||||
tab: {
|
||||
flex: 1,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
borderBottomWidth: 2,
|
||||
borderBottomColor: 'transparent',
|
||||
},
|
||||
activeTab: {
|
||||
borderBottomColor: colors.primary,
|
||||
},
|
||||
tabText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: colors.text + '80',
|
||||
},
|
||||
activeTabText: {
|
||||
color: colors.primary,
|
||||
},
|
||||
form: {
|
||||
gap: 12,
|
||||
},
|
||||
inputContainer: {
|
||||
marginBottom: 12,
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
color: colors.text + '80',
|
||||
marginBottom: 6,
|
||||
},
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSecondary,
|
||||
borderRadius: 8,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
fontSize: 14,
|
||||
color: colors.text,
|
||||
backgroundColor: colors.card,
|
||||
},
|
||||
inputFocused: {
|
||||
borderColor: colors.primary,
|
||||
},
|
||||
errorText: {
|
||||
fontSize: 12,
|
||||
color: '#ff4444',
|
||||
marginTop: 4,
|
||||
},
|
||||
submitButton: {
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
marginTop: 20,
|
||||
},
|
||||
submitButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
divider: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginVertical: 16,
|
||||
},
|
||||
dividerLine: {
|
||||
flex: 1,
|
||||
height: 1,
|
||||
backgroundColor: colors.borderSecondary,
|
||||
},
|
||||
dividerText: {
|
||||
marginHorizontal: 12,
|
||||
fontSize: 12,
|
||||
color: colors.text + '80',
|
||||
},
|
||||
socialButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
socialButton: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSecondary,
|
||||
borderRadius: 8,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center',
|
||||
},
|
||||
agreementText: {
|
||||
fontSize: 12,
|
||||
color: colors.text + '80',
|
||||
marginTop: 12,
|
||||
lineHeight: 18,
|
||||
},
|
||||
agreementLink: {
|
||||
color: colors.primary,
|
||||
textDecorationLine: 'underline',
|
||||
},
|
||||
}));
|
||||
|
||||
interface LoginRegisterModalProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录/注册弹窗组件
|
||||
*/
|
||||
export default function LoginRegisterModal({ visible, onClose }: LoginRegisterModalProps) {
|
||||
const colorScheme = useColorScheme();
|
||||
const s = styles[colorScheme];
|
||||
const themeColors = useThemeColors();
|
||||
const { login } = useUserStore();
|
||||
|
||||
const [mode, setMode] = useState<'login' | 'register'>('login');
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const validateEmail = useCallback((email: string) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}, []);
|
||||
|
||||
const validateForm = useCallback(() => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!email.trim()) {
|
||||
newErrors.email = '请输入邮箱';
|
||||
} else if (!validateEmail(email)) {
|
||||
newErrors.email = '邮箱格式不正确';
|
||||
}
|
||||
|
||||
if (!password.trim()) {
|
||||
newErrors.password = '请输入密码';
|
||||
} else if (password.length < 6) {
|
||||
newErrors.password = '密码至少6个字符';
|
||||
}
|
||||
|
||||
if (mode === 'register') {
|
||||
if (!confirmPassword.trim()) {
|
||||
newErrors.confirmPassword = '请确认密码';
|
||||
} else if (password !== confirmPassword) {
|
||||
newErrors.confirmPassword = '两次输入的密码不一致';
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
}, [email, password, confirmPassword, mode, validateEmail]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// 模拟 API 调用
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// 模拟登录/注册成功
|
||||
const mockUser = {
|
||||
id: '1',
|
||||
username: email.split('@')[0],
|
||||
email,
|
||||
avatar: 'https://i.pravatar.cc/150?img=1',
|
||||
nickname: '用户',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
login(mockUser, 'mock-token-' + Date.now());
|
||||
|
||||
Alert.alert('成功', mode === 'login' ? '登录成功!' : '注册成功!');
|
||||
onClose();
|
||||
} catch (error: any) {
|
||||
Alert.alert('失败', error.message || '操作失败,请重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [validateForm, email, mode, login, onClose]);
|
||||
|
||||
const handleSwitchMode = useCallback(() => {
|
||||
setMode(mode === 'login' ? 'register' : 'login');
|
||||
setErrors({});
|
||||
}, [mode]);
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||
<View style={s.overlay}>
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
style={{ flex: 1 }}
|
||||
>
|
||||
<TouchableOpacity style={{ flex: 1 }} activeOpacity={1} onPress={onClose} />
|
||||
<View style={s.container}>
|
||||
<ScrollView showsVerticalScrollIndicator={false}>
|
||||
{/* 头部 */}
|
||||
<View style={s.header}>
|
||||
<Text style={s.title}>{mode === 'login' ? '登录' : '注册'}</Text>
|
||||
<TouchableOpacity style={s.closeButton} onPress={onClose} activeOpacity={0.7}>
|
||||
<Ionicons name="close" size={24} color={themeColors.text} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 标签页 */}
|
||||
<View style={s.tabContainer}>
|
||||
<TouchableOpacity
|
||||
style={[s.tab, mode === 'login' && s.activeTab]}
|
||||
onPress={() => setMode('login')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[s.tabText, mode === 'login' && s.activeTabText]}>登录</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[s.tab, mode === 'register' && s.activeTab]}
|
||||
onPress={() => setMode('register')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[s.tabText, mode === 'register' && s.activeTabText]}>注册</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 表单 */}
|
||||
<View style={s.form}>
|
||||
{/* 邮箱 */}
|
||||
<View style={s.inputContainer}>
|
||||
<Text style={s.label}>邮箱</Text>
|
||||
<TextInput
|
||||
style={[s.input, errors.email && { borderColor: '#ff4444' }]}
|
||||
placeholder="请输入邮箱"
|
||||
placeholderTextColor={themeColors.text + '60'}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
keyboardType="email-address"
|
||||
editable={!loading}
|
||||
/>
|
||||
{errors.email && <Text style={s.errorText}>{errors.email}</Text>}
|
||||
</View>
|
||||
|
||||
{/* 密码 */}
|
||||
<View style={s.inputContainer}>
|
||||
<Text style={s.label}>密码</Text>
|
||||
<TextInput
|
||||
style={[s.input, errors.password && { borderColor: '#ff4444' }]}
|
||||
placeholder="请输入密码"
|
||||
placeholderTextColor={themeColors.text + '60'}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
secureTextEntry
|
||||
editable={!loading}
|
||||
/>
|
||||
{errors.password && <Text style={s.errorText}>{errors.password}</Text>}
|
||||
</View>
|
||||
|
||||
{/* 确认密码(注册模式) */}
|
||||
{mode === 'register' && (
|
||||
<View style={s.inputContainer}>
|
||||
<Text style={s.label}>确认密码</Text>
|
||||
<TextInput
|
||||
style={[s.input, errors.confirmPassword && { borderColor: '#ff4444' }]}
|
||||
placeholder="请再次输入密码"
|
||||
placeholderTextColor={themeColors.text + '60'}
|
||||
value={confirmPassword}
|
||||
onChangeText={setConfirmPassword}
|
||||
secureTextEntry
|
||||
editable={!loading}
|
||||
/>
|
||||
{errors.confirmPassword && (
|
||||
<Text style={s.errorText}>{errors.confirmPassword}</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 提交按钮 */}
|
||||
<TouchableOpacity
|
||||
style={s.submitButton}
|
||||
onPress={handleSubmit}
|
||||
disabled={loading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={s.submitButtonText}>
|
||||
{loading ? '处理中...' : mode === 'login' ? '登录' : '注册'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 用户协议 */}
|
||||
{mode === 'register' && (
|
||||
<Text style={s.agreementText}>
|
||||
注册即表示同意 <Text style={s.agreementLink}>用户协议</Text>和
|
||||
<Text style={s.agreementLink}>隐私政策</Text>
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
/**
|
||||
* 首页 Header 组件
|
||||
* 包含搜索、用户信息、消息等功能
|
||||
* Header 组件 - 增强版
|
||||
* 基于 xinyong-web 的 header 组件重建
|
||||
* 功能包括:
|
||||
* - 顶部导航栏(logo、搜索、用户按钮)
|
||||
* - 左侧菜单(侧边栏)
|
||||
* - 登录/注册弹窗
|
||||
* - 钱包信息显示
|
||||
* - 主题适配
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
@@ -12,154 +18,150 @@ import {
|
||||
TextInput,
|
||||
Image,
|
||||
Dimensions,
|
||||
Modal,
|
||||
ScrollView,
|
||||
SafeAreaView,
|
||||
} from 'react-native';
|
||||
import { createThemeStyles, useColorScheme, useThemeInfo } from '@/theme';
|
||||
|
||||
import { useColorScheme, useThemeColors } from '@/theme';
|
||||
import { useIsLoggedIn, useUser } from '@/stores/userStore';
|
||||
import { useLogo } from '@/stores/tenantStore';
|
||||
import { Ionicons, AntDesign } from '@expo/vector-icons';
|
||||
import LeftMenu from './LeftMenu';
|
||||
import LoginRegisterModal from './LoginRegisterModal';
|
||||
import WalletAmount from '../WalletAmount';
|
||||
import { styles } from './styles';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
|
||||
/**
|
||||
* 创建主题样式
|
||||
*/
|
||||
const styles = createThemeStyles((colors) => ({
|
||||
container: {
|
||||
backgroundColor: colors.background,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.border,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingVertical: 8,
|
||||
},
|
||||
logo: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
color: colors.primary,
|
||||
},
|
||||
searchContainer: {
|
||||
flex: 1,
|
||||
marginHorizontal: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 12,
|
||||
height: 36,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
marginLeft: 8,
|
||||
fontSize: 14,
|
||||
color: colors.text,
|
||||
},
|
||||
searchPlaceholder: {
|
||||
color: colors.text + '80',
|
||||
},
|
||||
iconButton: {
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginLeft: 8,
|
||||
},
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
right: -4,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
minWidth: 16,
|
||||
height: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
badgeText: {
|
||||
color: '#fff',
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
}));
|
||||
|
||||
interface HeaderProps {
|
||||
onSearch?: (keyword: string) => void;
|
||||
onMessagePress?: () => void;
|
||||
onUserPress?: () => void;
|
||||
onMenuPress?: () => void;
|
||||
unreadCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Header 组件
|
||||
* Header 组件 - 增强版
|
||||
*/
|
||||
export default function Header({
|
||||
onSearch,
|
||||
onMessagePress,
|
||||
onUserPress,
|
||||
onMenuPress,
|
||||
unreadCount = 0,
|
||||
}: HeaderProps) {
|
||||
const theme = useColorScheme();
|
||||
const s = styles[theme];
|
||||
const { colors } = useThemeInfo();
|
||||
const colorScheme = useColorScheme();
|
||||
const s = styles[colorScheme];
|
||||
const themeColors = useThemeColors();
|
||||
const isLoggedIn = useIsLoggedIn();
|
||||
const user = useUser();
|
||||
const logoUrl = useLogo();
|
||||
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [showLeftMenu, setShowLeftMenu] = useState(false);
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
|
||||
const handleSearch = useCallback(() => {
|
||||
if (searchText.trim()) {
|
||||
onSearch?.(searchText);
|
||||
setSearchText('');
|
||||
}
|
||||
}, [searchText, onSearch]);
|
||||
|
||||
const handleClearSearch = useCallback(() => {
|
||||
setSearchText('');
|
||||
const handleMenuPress = useCallback(() => {
|
||||
setShowLeftMenu(true);
|
||||
onMenuPress?.();
|
||||
}, [onMenuPress]);
|
||||
|
||||
const handleLoginPress = useCallback(() => {
|
||||
setShowLoginModal(true);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<View style={s.container}>
|
||||
{/* 顶部栏 */}
|
||||
<View style={s.header}>
|
||||
{/* Logo */}
|
||||
<Text style={s.logo}>🎮 游戏大厅</Text>
|
||||
<>
|
||||
<View style={s.container}>
|
||||
{/* 左侧部分 */}
|
||||
<View style={s.leftSection}>
|
||||
{/* 菜单按钮 */}
|
||||
<TouchableOpacity style={s.menuButton} onPress={handleMenuPress} activeOpacity={0.7}>
|
||||
<AntDesign name="menu-unfold" size={22} color={themeColors.text} />
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 搜索框 */}
|
||||
<View style={s.searchContainer}>
|
||||
<Text style={{ color: colors.text + '60', fontSize: 16 }}>🔍</Text>
|
||||
<TextInput
|
||||
style={[s.searchInput, s.searchPlaceholder]}
|
||||
placeholder="搜索游戏..."
|
||||
placeholderTextColor={colors.text + '60'}
|
||||
value={searchText}
|
||||
onChangeText={setSearchText}
|
||||
onSubmitEditing={handleSearch}
|
||||
returnKeyType="search"
|
||||
/>
|
||||
{searchText ? (
|
||||
<TouchableOpacity onPress={handleClearSearch}>
|
||||
<Text style={{ fontSize: 16 }}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
{/* Logo */}
|
||||
{logoUrl ? <Image source={{ uri: logoUrl }} style={s.logoImage} /> : null}
|
||||
</View>
|
||||
|
||||
{/* 消息按钮 */}
|
||||
<TouchableOpacity style={s.iconButton} onPress={onMessagePress} activeOpacity={0.7}>
|
||||
<Text style={{ fontSize: 18 }}>💬</Text>
|
||||
{unreadCount > 0 && (
|
||||
<View style={s.badge}>
|
||||
<Text style={s.badgeText}>{unreadCount > 99 ? '99+' : unreadCount}</Text>
|
||||
{/* 搜索框 */}
|
||||
{/*<View style={s.searchContainer}>*/}
|
||||
{/* <Ionicons name="search" size={16} color={themeColors.text + '60'} />*/}
|
||||
{/* <TextInput*/}
|
||||
{/* style={s.searchInput}*/}
|
||||
{/* placeholder="搜索游戏..."*/}
|
||||
{/* placeholderTextColor={themeColors.text + '60'}*/}
|
||||
{/* value={searchText}*/}
|
||||
{/* onChangeText={setSearchText}*/}
|
||||
{/* onSubmitEditing={handleSearch}*/}
|
||||
{/* returnKeyType="search"*/}
|
||||
{/* />*/}
|
||||
{/* {searchText ? (*/}
|
||||
{/* <TouchableOpacity onPress={() => setSearchText('')}>*/}
|
||||
{/* <Ionicons name="close" size={16} color={themeColors.text} />*/}
|
||||
{/* </TouchableOpacity>*/}
|
||||
{/* ) : null}*/}
|
||||
{/*</View>*/}
|
||||
|
||||
{/* 右侧部分 */}
|
||||
<View style={s.rightSection}>
|
||||
{isLoggedIn && user ? (
|
||||
<>
|
||||
{/* 消息按钮 */}
|
||||
<TouchableOpacity style={s.iconButton} onPress={onMessagePress} activeOpacity={0.7}>
|
||||
<Ionicons name="chatbubble-outline" size={20} color={themeColors.text} />
|
||||
{unreadCount > 0 && (
|
||||
<View style={s.badge}>
|
||||
<Text style={s.badgeText}>{unreadCount > 99 ? '99+' : unreadCount}</Text>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 用户头像 */}
|
||||
<TouchableOpacity style={s.iconButton} activeOpacity={0.7}>
|
||||
{user.avatar ? (
|
||||
<Image
|
||||
source={{ uri: user.avatar }}
|
||||
style={{ width: 32, height: 32, borderRadius: 16 }}
|
||||
/>
|
||||
) : (
|
||||
<Ionicons name="person-circle" size={24} color={themeColors.primary} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</>
|
||||
) : (
|
||||
/* 登录/注册按钮 */
|
||||
<View style={s.authButtons}>
|
||||
<TouchableOpacity
|
||||
style={[s.authButton, s.registerButton]}
|
||||
onPress={() => setShowLoginModal(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={s.registerButtonText}>注册</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[s.authButton, s.loginButton]}
|
||||
onPress={() => setShowLoginModal(true)}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={s.loginButtonText}>登录</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* 用户按钮 */}
|
||||
<TouchableOpacity style={s.iconButton} onPress={onUserPress} activeOpacity={0.7}>
|
||||
<Text style={{ fontSize: 18 }}>👤</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 左侧菜单 */}
|
||||
<LeftMenu visible={showLeftMenu} onClose={() => setShowLeftMenu(false)} />
|
||||
|
||||
{/* 登录/注册弹窗 */}
|
||||
<LoginRegisterModal visible={showLoginModal} onClose={() => setShowLoginModal(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
116
components/Header/styles.ts
Normal file
116
components/Header/styles.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { createThemeStyles } from '@/theme';
|
||||
|
||||
/**
|
||||
* 创建主题样式
|
||||
*/
|
||||
export const styles = createThemeStyles((colors) => ({
|
||||
container: {
|
||||
backgroundColor: colors.background,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: colors.borderSecondary,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
leftSection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
flex: 1,
|
||||
},
|
||||
menuButton: {
|
||||
width: 24,
|
||||
height: 40,
|
||||
borderRadius: 0,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 6,
|
||||
},
|
||||
logo: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: colors.primary,
|
||||
},
|
||||
logoImage: {
|
||||
width: 125,
|
||||
height: 33,
|
||||
resizeMode: 'contain',
|
||||
},
|
||||
searchContainer: {
|
||||
flex: 1,
|
||||
marginHorizontal: 12,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
backgroundColor: colors.card,
|
||||
borderRadius: 20,
|
||||
paddingHorizontal: 12,
|
||||
height: 40,
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
marginLeft: 8,
|
||||
fontSize: 14,
|
||||
color: colors.text,
|
||||
},
|
||||
rightSection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
iconButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
position: 'relative',
|
||||
},
|
||||
badge: {
|
||||
position: 'absolute',
|
||||
top: -4,
|
||||
right: -4,
|
||||
backgroundColor: colors.primary,
|
||||
borderRadius: 8,
|
||||
minWidth: 18,
|
||||
height: 18,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
badgeText: {
|
||||
color: '#fff',
|
||||
fontSize: 10,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
authButtons: {
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
authButton: {
|
||||
height: 27,
|
||||
paddingHorizontal: 16,
|
||||
// paddingVertical: 8,
|
||||
borderRadius: 6,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
registerButton: {
|
||||
backgroundColor: colors.card,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.tint,
|
||||
},
|
||||
registerButtonText: {
|
||||
color: colors.tint,
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
loginButton: {
|
||||
backgroundColor: colors.tint,
|
||||
},
|
||||
loginButtonText: {
|
||||
color: '#fff',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
}));
|
||||
261
components/Image.tsx
Normal file
261
components/Image.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
import React from 'react';
|
||||
import { Image as RNImage, ImageProps as RNImageProps, View, Text } from 'react-native';
|
||||
import { getImageSourceWithDefault, isLocalPath, convertLocalPathToUri } from '@/utils/image';
|
||||
import { getGameImageSizeFromPath } from '@/constants/gameImages';
|
||||
|
||||
/**
|
||||
* 自定义 Image 组件
|
||||
*
|
||||
* 功能:
|
||||
* - 自动处理图片 URL 验证
|
||||
* - 支持默认图片
|
||||
* - 支持自适应宽度或高度
|
||||
* - 支持自动测量图片尺寸(无需预知 aspectRatio)
|
||||
* - 简化图片加载逻辑
|
||||
*
|
||||
* 使用方式:
|
||||
* ```typescript
|
||||
* // 基础用法
|
||||
* <Image
|
||||
* source="https://example.com/image.png"
|
||||
* defaultSource="https://via.placeholder.com/200"
|
||||
* style={{ width: 100, height: 100 }}
|
||||
* />
|
||||
*
|
||||
* // 设置高度,宽度自适应(需要提供 aspectRatio)
|
||||
* <Image
|
||||
* source="https://example.com/image.png"
|
||||
* style={{ height: 200 }} // 只设置高度
|
||||
* aspectRatio={16 / 9}
|
||||
* adaptiveMode="height"
|
||||
* />
|
||||
*
|
||||
* // 设置宽度,高度自适应(需要提供 aspectRatio)
|
||||
* <Image
|
||||
* source="https://example.com/image.png"
|
||||
* style={{ width: 300 }} // 只设置宽度
|
||||
* aspectRatio={4 / 3}
|
||||
* adaptiveMode="width"
|
||||
* />
|
||||
*
|
||||
* // 自动测量图片尺寸(推荐用于未知尺寸的图片)
|
||||
* <Image
|
||||
* source="https://example.com/image.png"
|
||||
* style={{ height: 50 }}
|
||||
* adaptiveMode="height"
|
||||
* autoMeasure={true}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
|
||||
interface CustomImageProps extends Omit<RNImageProps, 'source' | 'defaultSource'> {
|
||||
/** 图片 URL(HTTP/HTTPS 或 Data URI) */
|
||||
source?: string;
|
||||
/** 默认图片 URL(当主图片无效时使用) */
|
||||
defaultSource?: string;
|
||||
/** 是否显示加载失败的占位符 */
|
||||
showPlaceholder?: boolean;
|
||||
/** 图片宽高比(用于自适应宽度或高度) */
|
||||
aspectRatio?: number;
|
||||
/** 自适应模式:
|
||||
* - 'height': 高度固定,宽度自适应(style 中只需设置 height)
|
||||
* - 'width': 宽度固定,高度自适应(style 中只需设置 width)
|
||||
*/
|
||||
adaptiveMode?: 'width' | 'height';
|
||||
/** 是否自动测量图片尺寸(当 aspectRatio 未提供时) */
|
||||
autoMeasure?: boolean;
|
||||
}
|
||||
|
||||
const Image = React.forwardRef<RNImage, CustomImageProps>(
|
||||
(
|
||||
{
|
||||
source,
|
||||
defaultSource = 'https://via.placeholder.com/200',
|
||||
showPlaceholder = false,
|
||||
style,
|
||||
aspectRatio,
|
||||
adaptiveMode,
|
||||
autoMeasure = false,
|
||||
onLoad,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
// 自动测量的宽高比状态
|
||||
const [measuredAspectRatio, setMeasuredAspectRatio] = React.useState<number | null>(null);
|
||||
|
||||
// 获取有效的图片 URI
|
||||
const imageUri = getImageSourceWithDefault(source, defaultSource);
|
||||
|
||||
// 处理图片加载完成事件
|
||||
const handleImageLoad = React.useCallback(
|
||||
(event: any) => {
|
||||
if (autoMeasure) {
|
||||
try {
|
||||
// 尝试从不同的位置获取图片尺寸
|
||||
let width: number | undefined;
|
||||
let height: number | undefined;
|
||||
|
||||
// 方式 1: event.nativeEvent.source
|
||||
if (event?.nativeEvent?.source) {
|
||||
width = event.nativeEvent.source.width;
|
||||
height = event.nativeEvent.source.height;
|
||||
}
|
||||
|
||||
// 方式 2: event.nativeEvent
|
||||
if (!width || !height) {
|
||||
width = event?.nativeEvent?.width;
|
||||
height = event?.nativeEvent?.height;
|
||||
}
|
||||
|
||||
// 方式 3: 直接从 event
|
||||
if (!width || !height) {
|
||||
width = event?.width;
|
||||
height = event?.height;
|
||||
}
|
||||
|
||||
if (width && height && typeof width === 'number' && typeof height === 'number') {
|
||||
const ratio = width / height;
|
||||
setMeasuredAspectRatio(ratio);
|
||||
if (__DEV__) {
|
||||
console.log(
|
||||
`[Image] Measured dimensions from event: ${width}x${height}, ratio: ${ratio.toFixed(3)}`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 如果从 event 获取失败,尝试从本地路径的映射表获取
|
||||
if (isLocalPath(source)) {
|
||||
const size = getGameImageSizeFromPath(source);
|
||||
if (size && size.width && size.height) {
|
||||
const ratio = size.width / size.height;
|
||||
setMeasuredAspectRatio(ratio);
|
||||
if (__DEV__) {
|
||||
console.log(
|
||||
`[Image] Measured dimensions from mapping: ${size.width}x${size.height}, ratio: ${ratio.toFixed(3)}`
|
||||
);
|
||||
}
|
||||
} else if (__DEV__) {
|
||||
console.warn(
|
||||
`[Image] Failed to get dimensions from event or mapping for: ${source}`
|
||||
);
|
||||
}
|
||||
} else if (__DEV__) {
|
||||
console.warn(
|
||||
`[Image] Failed to get dimensions from event. Event:`,
|
||||
JSON.stringify(event, null, 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// 忽略获取尺寸的错误
|
||||
console.warn('Failed to measure image dimensions:', error);
|
||||
}
|
||||
}
|
||||
onLoad?.(event);
|
||||
},
|
||||
[autoMeasure, onLoad, source]
|
||||
);
|
||||
|
||||
// 计算自适应样式
|
||||
const adaptiveStyle = React.useMemo(() => {
|
||||
// 优先使用提供的 aspectRatio,其次使用测量的 aspectRatio
|
||||
const ratio = aspectRatio || (autoMeasure ? measuredAspectRatio : null);
|
||||
|
||||
if (!ratio || !adaptiveMode) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// 从 style 中提取宽度和高度
|
||||
const styleObj = Array.isArray(style) ? Object.assign({}, ...style) : style || {};
|
||||
const fixedWidth = styleObj.width;
|
||||
const fixedHeight = styleObj.height;
|
||||
|
||||
// adaptiveMode 的含义:
|
||||
// - 'height': 高度固定,宽度自适应
|
||||
// 计算方式:width = height * aspectRatio
|
||||
// - 'width': 宽度固定,高度自适应
|
||||
// 计算方式:height = width / aspectRatio
|
||||
|
||||
if (adaptiveMode === 'height' && fixedHeight) {
|
||||
// 高度固定,计算宽度
|
||||
// 注意:不返回 aspectRatio,让宽高完全由计算值决定
|
||||
// 这样可以避免 resizeMode 对宽高比的影响
|
||||
const calculatedWidth = fixedHeight * ratio;
|
||||
if (__DEV__) {
|
||||
console.log(
|
||||
`[Image] adaptiveMode=height: fixedHeight=${fixedHeight}, ratio=${ratio.toFixed(3)}, calculatedWidth=${calculatedWidth.toFixed(1)}`
|
||||
);
|
||||
}
|
||||
return {
|
||||
width: calculatedWidth,
|
||||
height: fixedHeight,
|
||||
};
|
||||
} else if (adaptiveMode === 'width' && fixedWidth) {
|
||||
// 宽度固定,计算高度
|
||||
// 注意:不返回 aspectRatio,让宽高完全由计算值决定
|
||||
const calculatedHeight = fixedWidth / ratio;
|
||||
if (__DEV__) {
|
||||
console.log(
|
||||
`[Image] adaptiveMode=width: fixedWidth=${fixedWidth}, ratio=${ratio.toFixed(3)}, calculatedHeight=${calculatedHeight.toFixed(1)}`
|
||||
);
|
||||
}
|
||||
return {
|
||||
width: fixedWidth,
|
||||
height: calculatedHeight,
|
||||
};
|
||||
}
|
||||
|
||||
// 如果没有固定值,仅使用 aspectRatio
|
||||
return { aspectRatio: ratio };
|
||||
}, [aspectRatio, adaptiveMode, autoMeasure, measuredAspectRatio, style]);
|
||||
|
||||
// 如果没有有效的图片 URI,显示占位符
|
||||
if (!imageUri) {
|
||||
if (showPlaceholder) {
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
{
|
||||
backgroundColor: '#f0f0f0',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
style,
|
||||
adaptiveStyle,
|
||||
]}
|
||||
>
|
||||
<Text style={{ color: '#999', fontSize: 12 }}>图片加载失败</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 处理本地路径和外部 URL
|
||||
let imageSource: any;
|
||||
|
||||
if (isLocalPath(imageUri)) {
|
||||
// 本地路径:/images/game/chess/color_101.png
|
||||
// 转换为 require 对象
|
||||
const localSource = convertLocalPathToUri(imageUri);
|
||||
imageSource = localSource || { uri: defaultSource };
|
||||
} else {
|
||||
// 外部 URL
|
||||
imageSource = { uri: imageUri };
|
||||
}
|
||||
|
||||
return (
|
||||
<RNImage
|
||||
ref={ref}
|
||||
source={imageSource}
|
||||
style={[style]}
|
||||
onLoad={handleImageLoad}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Image.displayName = 'Image';
|
||||
|
||||
export default Image;
|
||||
@@ -229,4 +229,3 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -103,16 +103,7 @@ export function ThemedText({ style, type = 'default', ...rest }: ThemedTextProps
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Text
|
||||
style={[
|
||||
{ color },
|
||||
typeStyles[type],
|
||||
style,
|
||||
]}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
return <Text style={[{ color }, typeStyles[type], style]} {...rest} />;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
76
components/WalletAmount.tsx
Normal file
76
components/WalletAmount.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 钱包余额组件
|
||||
* 显示用户的钱包余额
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import { useColorScheme, useThemeColors } from '@/theme';
|
||||
import { createThemeStyles } from '@/theme';
|
||||
import Ionicons from '@expo/vector-icons/Ionicons';
|
||||
|
||||
/**
|
||||
* 创建主题样式
|
||||
*/
|
||||
const styles = createThemeStyles((colors) => ({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
color: colors.text + '80',
|
||||
},
|
||||
amount: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
color: colors.primary,
|
||||
},
|
||||
currency: {
|
||||
fontSize: 12,
|
||||
color: colors.text + '80',
|
||||
marginLeft: 4,
|
||||
},
|
||||
refreshButton: {
|
||||
padding: 4,
|
||||
},
|
||||
}));
|
||||
|
||||
interface WalletAmountProps {
|
||||
balance?: number;
|
||||
label?: string;
|
||||
showRefresh?: boolean;
|
||||
onRefresh?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 钱包余额组件
|
||||
*/
|
||||
export default function WalletAmount({
|
||||
balance = 0,
|
||||
label = '余额:',
|
||||
showRefresh = false,
|
||||
onRefresh,
|
||||
}: WalletAmountProps) {
|
||||
const colorScheme = useColorScheme();
|
||||
const s = styles[colorScheme];
|
||||
const themeColors = useThemeColors();
|
||||
|
||||
// 格式化金额
|
||||
const formattedBalance = useMemo(() => {
|
||||
return balance.toFixed(2);
|
||||
}, [balance]);
|
||||
|
||||
return (
|
||||
<View style={s.container}>
|
||||
{label && <Text style={s.label}>{label}</Text>}
|
||||
<Text style={s.amount}>¥ {formattedBalance}</Text>
|
||||
{showRefresh && (
|
||||
<TouchableOpacity style={s.refreshButton} onPress={onRefresh} activeOpacity={0.7}>
|
||||
<Ionicons name="refresh" size={14} color={themeColors.primary} />
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -13,4 +13,11 @@ export { ThemeDemo } from './ThemeDemo';
|
||||
export { ExternalLink } from './ExternalLink';
|
||||
export { MonoText } from './StyledText';
|
||||
|
||||
// 头部
|
||||
export { default as Header } from './Header';
|
||||
|
||||
// 钱包
|
||||
export { default as WalletAmount } from './WalletAmount';
|
||||
|
||||
// 图片
|
||||
export { default as Image } from './Image';
|
||||
|
||||
Reference in New Issue
Block a user