This commit is contained in:
oneao committed 2025-11-19 17:30:44 +08:00
1 parent 7419021927
commit 49285f1df2
16 files changed
+1184 -283

No files matched your search

@@ -1,125 +1,33 @@
import * as React from 'react'
import { Pressable, StyleSheet, Text, useWindowDimensions, View } from 'react-native'
import { SafeAreaView } from 'react-native-safe-area-context'
import { TabView } from 'react-native-tab-view'
import { Text, View } from 'react-native'
import PagerTabs from '@/components/custom/PagerTabs'
// 两个示例页面
function PageOne() {
export default function Demo() {
return (
<View style={styles.page}>
<Text style={{ fontSize: 18 }}>第一页内容</Text>
</View>
)
}
function PageTwo() {
return (
<View style={styles.page}>
<Text style={{ fontSize: 18 }}>第二页内容</Text>
</View>
)
}
export default function SimpleTabView() {
const layout = useWindowDimensions()
const [index, setIndex] = React.useState(0)
const routes = [
{ key: 'one', title: 'Tab 1' },
{ key: 'two', title: 'Tab 2' },
]
const renderScene = ({ route }: { route: { key: string } }) => {
switch (route.key) {
case 'one':
return <PageOne />
case 'two':
return <PageTwo />
default:
return null
}
}
return (
<SafeAreaView style={{ flex: 1, backgroundColor: '#fff' }}>
{/* Header + Tab */}
<View style={styles.header}>
<Pressable onPress={() => console.log('Left button clicked')}>
<Text style={styles.headerButton}>返回</Text>
</Pressable>
<View style={styles.tabContainer}>
{routes.map((r, i) => {
const focused = i === index
return (
<Pressable key={r.key} onPress={() => setIndex(i)} style={styles.tabButton}>
<Text style={[styles.tabText, focused && styles.tabTextActive]}>{r.title}</Text>
{focused && <View style={styles.underline} />}
</Pressable>
)
})}
</View>
<Pressable onPress={() => console.log('Right button clicked')}>
<Text style={styles.headerButton}>添加</Text>
</Pressable>
</View>
{/* TabView */}
<TabView
navigationState={{ index, routes }}
renderScene={renderScene}
onIndexChange={setIndex}
initialLayout={{ width: layout.width }}
renderTabBar={() => null} // 不使用默认 TabBar
<View className="flex-1">
<PagerTabs
left={(
<View className="w-[20%]">
<Text>Left</Text>
</View>
)}
right={(
<View className="w-[20%]">
<Text>right</Text>
</View>
)}
routes={[
{
key: 'tab1',
title: 'Tab 1',
element: <Text className="text-lg">第一个页面内容</Text>,
},
{
key: 'tab2',
title: 'Tab 2',
element: <Text className="text-lg">第二个页面内容</Text>,
},
]}
/>
<View><Text>123</Text></View>
</SafeAreaView>
</View>
)
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingVertical: 8,
borderBottomWidth: 0.5,
borderColor: '#ddd',
},
headerButton: {
fontSize: 16,
color: '#007AFF',
},
tabContainer: {
flexDirection: 'row',
justifyContent: 'center',
flex: 1,
},
tabButton: {
marginHorizontal: 12,
alignItems: 'center',
paddingVertical: 4,
},
tabText: {
fontSize: 16,
color: '#666',
},
tabTextActive: {
color: '#007AFF',
fontWeight: 'bold',
},
underline: {
height: 2,
backgroundColor: '#007AFF',
width: '100%',
marginTop: 2,
},
page: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
},
})
@@ -110,7 +110,6 @@ export default function AddMoneyDrawer({
moneyCategoryStore.setCategories(res.data)
}
}
console.log(moneyCategoryStore.categories)
}
catch (err) {
console.error('获取分类失败', err)
@@ -1,4 +1,5 @@
import type { MoneyRecordGroup, MoneyRecordItem, QueryMoneyRecordsResult } from '@/types/money/record'
import { useRouter } from 'expo-router'
import React, { useEffect, useState } from 'react'
import { Text, TouchableOpacity, TouchableWithoutFeedback, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
@@ -14,6 +15,7 @@ import AddMoneyDrawer from './components/AddDrawer'
import TransactionsList from './components/TransactionsList'
export default function Money() {
const router = useRouter()
const [showAddDrawer, setShowAddDrawer] = useState(false)
// 安全高度
const insets = useSafeAreaInsets()
@@ -386,7 +388,8 @@ export default function Money() {
<Fab
size="lg"
onPress={() => {
setShowAddDrawer(true)
// setShowAddDrawer(true)
router.navigate('/money/record/editor')
}}
>
<FabIcon as={AddIcon} size="xl" />
+1 -10
View File
@@ -61,7 +61,7 @@ export default function RootLayout() {
if (!userStore.isLoggedIn()) {
// 未登录直接跳到 auth
router.replace('/auth')
router.replace('/auth/index')
return null
}
return (
@@ -74,8 +74,6 @@ export default function RootLayout() {
screenOptions={{
headerTitleAlign: 'center',
animation: 'slide_from_right',
gestureEnabled: true,
gestureDirection: 'horizontal',
contentStyle: {
backgroundColor: '#fff',
},
@@ -88,13 +86,6 @@ export default function RootLayout() {
}}
/>
<Stack.Screen
name="auth"
options={{
headerShown: false,
}}
/>
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="auto" />
@@ -0,0 +1,359 @@
import Big from 'big.js'
import { useEffect, useState } from 'react'
import { Keyboard, Pressable, StyleSheet, Text, TouchableWithoutFeedback, View } from 'react-native'
import { ADatePicker, AInput, LucideIcon } from '@/components/common/ao/index'
import { useKeyboardHeight } from '@/hooks/useKeyboardHeight'
import { getCurrentDate } from '@/utils/time'
interface KeydownItem {
key: string | number
content: React.ReactNode
}
const keys: KeydownItem[] = [
{ key: '1', content: <Text className="text-2xl">1</Text> },
{ key: '2', content: <Text className="text-2xl">2</Text> },
{ key: '3', content: <Text className="text-2xl">3</Text> },
{ key: 'del', content: <LucideIcon icon="Delete" size={22} strokeWidth={1.5} clickable={false} /> },
{ key: '4', content: <Text className="text-2xl">4</Text> },
{ key: '5', content: <Text className="text-2xl">5</Text> },
{ key: '6', content: <Text className="text-2xl">6</Text> },
{ key: '+', content: <LucideIcon icon="Plus" size={22} strokeWidth={1.5} clickable={false} /> },
{ key: '7', content: <Text className="text-2xl">7</Text> },
{ key: '8', content: <Text className="text-2xl">8</Text> },
{ key: '9', content: <Text className="text-2xl">9</Text> },
{ key: '-', content: <LucideIcon icon="Minus" size={22} strokeWidth={1.5} clickable={false} /> },
{ key: 'clear', content: (
<View className="w-full h-full flex items-center justify-center">
<Text className="text-xl text-gray-600">重置</Text>
</View>
) },
{ key: '0', content: <Text className="text-2xl">0</Text> },
{ key: '.', content: <LucideIcon icon="Dot" size={22} strokeWidth={1.5} clickable={false} /> },
{ key: '/', content: (
<View className="w-full h-full flex items-center justify-center">
<Text className="text-2xl">完成</Text>
</View>
) },
]
interface AmountKeyboardProps {
show?: boolean
record_time?: string
amount?: string
remark?: string
// 回调:当键盘输入或修改数据时触发
onChange?: (data: { record_time: string, amount: string, remark: string }) => void
// 可选:点击完成按钮触发
onDone?: () => void
}
export default function AmountKeyboard({
show = false,
record_time = '',
amount = '',
remark = '',
onChange,
onDone,
}: AmountKeyboardProps) {
// 自定义键盘计算过程
const [keydownInput, setKeydownInput] = useState('')
// 日期选择
const [showDatePicker, setShowDatePicker] = useState(false)
// 备注聚焦
const [remarkIsFocused, setRemarkIsFocused] = useState(false)
// 键盘高度
const keyboardHeight = useKeyboardHeight()
// 使用一个对象管理三个字段
const [internalData, setInternalData] = useState({
record_time: record_time || getCurrentDate(),
amount: amount || '0',
remark: remark || '',
})
// 如果父组件 props 变化,需要同步
useEffect(() => {
setInternalData({
record_time: record_time || getCurrentDate(),
amount: amount || '0',
remark: remark || '',
})
}, [record_time, amount, remark])
const handleChange = (newData: Partial<typeof internalData>) => {
const updated = { ...internalData, ...newData }
setInternalData(updated)
onChange?.(updated)
}
// 计算过程
const handlePress = (key: string | number) => {
setKeydownInput((prev) => {
let newInput = prev
if (key === 'del') {
newInput = prev.slice(0, -1)
}
else if (key === 'clear') {
const currentDate = getCurrentDate()
handleChange({ record_time: currentDate, amount: '', remark: '' })
return ''
}
else if (key === '/') {
onDone?.()
return prev
}
else {
const keyStr = key.toString()
// 检查是否要输入运算符(+ 或 -)
if (keyStr === '+' || keyStr === '-') {
// 如果当前输入为空,不允许以运算符开头
if (prev === '') {
return prev
}
// 获取最后一个字符
const lastChar = prev.slice(-1)
// 如果最后一个字符已经是运算符,则替换而不是追加
if (lastChar === '+' || lastChar === '-') {
newInput = prev.slice(0, -1) + keyStr
}
// 如果最后一个字符是小数点,不允许在小数点后直接加运算符
else if (lastChar === '.') {
return prev
}
else {
newInput = prev + keyStr
}
}
// 检查是否要输入小数点(.)
else if (keyStr === '.') {
// 如果当前输入为空,不允许以小数点开头,改为输入"0."
if (prev === '') {
newInput = '0.'
}
else {
// 获取最后一个字符
const lastChar = prev.slice(-1)
// 如果最后一个字符是运算符,则需要在运算符后添加"0."而不是单独的"."
if (lastChar === '+' || lastChar === '-') {
newInput = `${prev}0.`
}
else {
// 检查当前数字是否已经包含小数点
const parts = prev.split(/[+-]/)
const currentNumber = parts[parts.length - 1]
// 如果当前数字中还没有小数点,可以添加
if (!currentNumber.includes('.')) {
newInput = `${prev}.`
}
else {
// 已经有小数点了,不重复添加
newInput = prev
}
}
}
}
// 处理数字输入
else if (/^\d$/.test(keyStr)) {
// 检查当前数字的小数位数
const parts = prev.split(/[+-]/)
const currentNumber = parts[parts.length - 1]
// 如果当前数字包含小数点,检查小数位数
if (currentNumber.includes('.')) {
const decimalParts = currentNumber.split('.')
// 如果小数部分已经达到2位,不允许继续输入
if (decimalParts.length > 1 && decimalParts[1].length >= 2) {
newInput = prev // 不改变输入
}
else {
newInput = prev + keyStr
}
}
else {
// 没有小数点,正常输入数字
newInput = prev + keyStr
}
}
else {
// 对于其他字符,正常追加
newInput = prev + keyStr
}
}
// 计算结果
try {
const computed = calculateWithBig(newInput)
handleChange({ amount: computed })
}
catch (e) {
handleChange({ amount: '0' })
}
return newInput
})
}
function calculateWithBig(expression: string): string {
if (!expression.trim())
return '0'
// 移除表达式末尾的运算符(+或-),避免不完整的表达式
let cleanExpression = expression.trim()
const lastChar = cleanExpression.slice(-1)
if (lastChar === '+' || lastChar === '-') {
cleanExpression = cleanExpression.slice(0, -1)
}
if (!cleanExpression)
return '0'
try {
// 使用更智能的解析方式,支持基本的加减运算
const numbers = cleanExpression.split(/[+-]/).filter(Boolean)
const operators = cleanExpression.split(/[^+-]/).filter(Boolean)
if (numbers.length === 0)
return '0'
let result = new Big(numbers[0])
for (let i = 0; i < operators.length; i++) {
const operator = operators[i]
const nextNumber = new Big(numbers[i + 1])
if (operator === '+') {
result = result.plus(nextNumber)
}
else if (operator === '-') {
result = result.minus(nextNumber)
}
}
// 限制结果为最多两位小数
return result.toFixed(2)
}
catch (error) {
// 如果解析失败,尝试只计算数字部分
const tokens = expression.match(/[+-]?\d+(\.\d+)?/g)
if (!tokens || tokens.length === 0)
return '0'
let total = new Big(0)
tokens.forEach((token) => {
total = total.plus(new Big(token))
})
// 限制结果为最多两位小数
return total.toFixed(2)
}
}
if (!show) {
return null
}
return (
<View style={{
backgroundColor: '#fff',
elevation: 20,
}}
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<View className="h-28 flex pl-4 pr-4 justify-center">
<View className="flex flex-row items-center justify-between">
<Pressable
className="flex flex-row items-center gap-1.5 px-2 py-1.5 rounded-md bg-gray-50 active:opacity-70"
onPress={() => setShowDatePicker(true)}
>
<LucideIcon icon="Calendar" size={16} color="#60a5fa" clickable={false} />
<Text className="text-gray-600 text-sm">{internalData.record_time}</Text>
<LucideIcon icon="ChevronDown" size={12} color="#aaa" clickable={false} />
</Pressable>
<Text className="text-3xl text-blue-700 text-right">{internalData.amount}</Text>
</View>
<Text className="text-gray-400 text-right">{keydownInput}</Text>
<AInput
value={internalData.remark}
placeholder="备注"
className="bg-[#f2f3f5] border-0"
size="sm"
onChangeText={val => handleChange({ remark: val })}
onFocus={() => setRemarkIsFocused(true)}
onBlur={() => setRemarkIsFocused(false)}
/>
</View>
</TouchableWithoutFeedback>
<View className="flex flex-row flex-wrap" style={{ height: 220 }}>
{keys.map((item, idx) => {
const isFirstCol = idx % 4 === 0
const isFirstRow = idx < 4
const isLastCol = (idx + 1) % 4 === 0
const isLastRow = idx >= 12
return (
<Pressable
key={item.key}
onPress={() => handlePress(item.key)}
style={{
width: '25%',
height: '25%',
justifyContent: 'center',
alignItems: 'center',
borderTopWidth: isFirstRow ? 0.3 : 0,
borderLeftWidth: isFirstCol ? 0.3 : 0,
borderRightWidth: !isLastCol ? 0.3 : 0,
borderBottomWidth: !isLastRow ? 0.3 : 0,
borderColor: '#ddd',
}}
>
{({ pressed }) => {
const defaultBg = item.key === '/' ? '#e6d7b8' : '#fff'
const pressedBg = item.key === '/' ? '#f5e9d4' : '#f2f2f2'
return (
<>
<View
style={{
...StyleSheet.absoluteFillObject,
backgroundColor: pressed ? pressedBg : defaultBg,
}}
/>
{item.content}
</>
)
}}
</Pressable>
)
})}
</View>
{remarkIsFocused && keyboardHeight > 220 && (
<View style={{ height: keyboardHeight + 10 - 220 }} />
)}
<ADatePicker
pickerTitle="记账时间"
mode="date"
date={internalData.record_time}
show={showDatePicker}
onClose={() => {
setShowDatePicker(false)
}}
onConfirm={(date) => {
setShowDatePicker(false)
handleChange({ record_time: date })
}}
/>
</View>
)
}
@@ -0,0 +1,83 @@
import type { ViewStyle } from 'react-native'
import type { MoneyCategoryItem } from '@/types/money/category'
import React, { useMemo } from 'react'
import { FlatList, Pressable, Text, View } from 'react-native'
import MoneyCategoryIcon from './CategoryIcon'
interface CategoryGridProps {
data: MoneyCategoryItem[]
selectedCategory?: MoneyCategoryItem | null
onSelectCategory?: (item: MoneyCategoryItem | null) => void
className?: string
style?: ViewStyle
}
export default function CategoryGrid({
data,
selectedCategory,
onSelectCategory,
className,
style,
}: CategoryGridProps) {
// 计算补齐数组,让每行4个
const memoItems = useMemo(() => {
const items = [...data]
const remainder = items.length % 4
if (remainder > 0) {
const emptyCount = 4 - remainder
for (let i = 0; i < emptyCount; i++) {
items.push({ id: `empty-${i}` } as any)
}
}
return items
}, [data])
return (
<FlatList
data={memoItems}
numColumns={4}
keyExtractor={item => item.id.toString()}
showsVerticalScrollIndicator={false}
className={`${className}`}
style={[style]}
renderItem={({ item }) => {
if (!('name' in item)) {
return <View style={{ flex: 1 / 4, margin: 4 }} />
}
const isSelected = selectedCategory?.id === item.id
return (
<Pressable
style={{ flex: 1 / 4, margin: 4, alignItems: 'center' }}
onPress={() => onSelectCategory?.(item)}
>
<View
style={{
width: 46,
height: 46,
borderRadius: 28,
marginTop: 4,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: isSelected ? '#BFDBFE' : '#F3F4F6',
}}
>
<MoneyCategoryIcon name={item.icon} size={24} />
</View>
<Text
style={{
marginBottom: 4,
fontSize: 14,
textAlign: 'center',
}}
>
{item.name}
</Text>
</Pressable>
)
}}
/>
)
}
@@ -0,0 +1,20 @@
import React from 'react'
import IconFont from '@/components/iconfont'
interface CategoryIconProps {
name?: string
size?: number
color?: string
type?: 0 | 1
}
/**
* 分类图标组件
*/
export default function MoneyCategoryIcon({
name,
size = 24,
color,
}: CategoryIconProps) {
return <IconFont name={name as any} size={size} color={color} />
}
@@ -0,0 +1,141 @@
import type { MoneyCategoryItem } from '@/types/money/category'
import type { MoneyRecordBase, MoneyRecordItem } from '@/types/money/record'
import { useRouter } from 'expo-router'
import { useState } from 'react'
import { Text, TouchableOpacity, View } from 'react-native'
import { LucideIcon } from '@/components/common/ao'
import CustomHeader from '@/components/custom/Header'
import PagerTabs from '@/components/custom/PagerTabs'
import { useMoneyCategoryStore } from '@/store/moneyCategory'
import { getCurrentDate, toDateString } from '@/utils/time'
import { toast } from '@/utils/toast'
import AmountKeyboard from './components/AmountKeyboard'
import CategoryGrid from './components/CategoryGrid'
export default function MoenyRecordEditor() {
const router = useRouter()
const moneyCategoryStore = useMoneyCategoryStore()
const expense = useMoneyCategoryStore(state => state.expense)
const income = useMoneyCategoryStore(state => state.income)
console.log(moneyCategoryStore.categories)
console.log(expense)
console.log(income)
// 选择顺序:0 支出 1收入
const [selectedIndex, setSelectedIndex] = useState(0)
// 自定义键盘
const [showKeydown, setShowKeydown] = useState(false)
// 选中分类
const [selectedCategory, setSelectedCategory] = useState<MoneyCategoryItem | null>(null)
// 记账记录
const defaultMoneyRecord: MoneyRecordBase = {
id: -1,
category_id: -1,
user_id: -1,
amount: '0.00',
remark: '',
record_time: getCurrentDate(),
create_time: '',
}
const [moneyRecord, setMoneyRecord] = useState<MoneyRecordBase>(defaultMoneyRecord)
// 保存记录
function handleSaveRecord() {
if (selectedCategory === null) {
toast.show({
title: '请选择分类',
})
return
}
if (moneyRecord.amount === '0.00') {
toast.show({
title: '请输入金额',
})
}
}
return (
<View className="flex-1">
<CustomHeader
showHeader={false}
screenProps={{
options: {
animation: 'slide_from_bottom',
},
}}
/>
<PagerTabs
left={(
<TouchableOpacity className="w-[30%] flex items-center px-4 flex-row">
<LucideIcon icon="LayoutList" />
<Text className="ml-1">管理</Text>
</TouchableOpacity>
)}
right={(
<TouchableOpacity
className="w-[30%] flex flex-row items-center px-4"
onPress={() => {
router.back()
}}
>
<Text className="ml-auto text-gray-400">取消</Text>
</TouchableOpacity>
)}
routes={[
{
key: 'expense',
title: '支出',
element: (
<CategoryGrid
data={expense}
selectedCategory={selectedCategory}
onSelectCategory={(data) => {
setShowKeydown(true)
setSelectedCategory(data)
}}
className="mt-4"
/>
),
},
{
key: 'income',
title: '收入',
element: (
<CategoryGrid
data={income}
selectedCategory={selectedCategory}
onSelectCategory={(data) => {
setShowKeydown(true)
setSelectedCategory(data)
}}
className="mt-4"
/>
),
},
]}
/>
<AmountKeyboard
show={showKeydown}
record_time={moneyRecord.record_time}
amount={moneyRecord.amount}
remark={moneyRecord.remark}
onChange={(data) => {
setMoneyRecord({
...moneyRecord,
record_time: data.record_time,
amount: data.amount,
remark: data.remark,
})
}}
onDone={handleSaveRecord}
/>
</View>
)
}
@@ -1,13 +1,127 @@
import { Stack } from 'expo-router'
import { Text, View } from 'react-native'
import CustomHeader from '@/components/custom/Header'
import * as React from 'react'
import { Animated, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native'
import PagerView from 'react-native-pager-view'
export default function PagerWithTab() {
const layout = useWindowDimensions()
const pagerRef = React.useRef<PagerView>(null)
const [index, setIndex] = React.useState(0)
const [tabWidth, setTabWidth] = React.useState(0)
// 用 Animated.Value 记录滑动位置(0 ~ 1 ~ 2)
const scrollX = React.useRef(new Animated.Value(0)).current
const routes = [
{ key: 'first', title: 'Tab1' },
{ key: 'second', title: 'Tab2' },
]
const onTabPress = (i: number) => {
pagerRef.current?.setPage(i)
}
// 页面滑动回调,把 offset 和 position 转换成 scrollX
const onPageScroll = (e: any) => {
const { position, offset } = e.nativeEvent
scrollX.setValue(position + offset)
}
// 下划线平滑动画
const indicatorTranslateX = scrollX.interpolate({
inputRange: [0, routes.length - 1],
outputRange: [0, tabWidth - tabWidth / routes.length],
extrapolate: 'clamp',
})
export default function Share() {
return (
<View className="flex-1">
<CustomHeader title="共享空间" />
<View style={{ flex: 1 }}>
{/* Header */}
<View style={styles.header}>
<Text style={styles.headerSide}>Left</Text>
<View className="flex-1"></View>
{/* TabBar */}
<View
style={styles.tabBar}
onLayout={e => setTabWidth(e.nativeEvent.layout.width)}
>
{routes.map((r, i) => (
<TouchableOpacity
key={r.key}
style={styles.tabItem}
onPress={() => onTabPress(i)}
>
<Text style={[styles.tabText, index === i && { color: 'blue', fontWeight: 'bold' }]}>
{r.title}
</Text>
</TouchableOpacity>
))}
{tabWidth > 0 && (
<Animated.View
style={[
styles.indicator,
{
width: tabWidth / routes.length,
transform: [{ translateX: indicatorTranslateX }],
},
]}
/>
)}
</View>
<Text style={styles.headerSide}>Right</Text>
</View>
{/* PagerView */}
<PagerView
ref={pagerRef}
style={{ flex: 1 }}
initialPage={0}
onPageScroll={onPageScroll}
onPageSelected={e => setIndex(e.nativeEvent.position)}
>
<View key="1" style={[styles.page, { backgroundColor: '#ffcccb' }]}>
<Text>第一个页面内容</Text>
</View>
<View key="2" style={[styles.page, { backgroundColor: '#add8e6' }]}>
<Text>第二个页面内容</Text>
</View>
</PagerView>
</View>
)
}
const styles = StyleSheet.create({
header: {
flexDirection: 'row',
alignItems: 'center',
},
headerSide: {
textAlign: 'center',
},
tabBar: {
flex: 1,
flexDirection: 'row',
position: 'relative',
},
tabItem: {
flex: 1,
alignItems: 'center',
paddingVertical: 8,
},
tabText: {
fontSize: 16,
color: '#333',
},
indicator: {
height: 2,
backgroundColor: 'blue',
position: 'absolute',
bottom: 0,
left: 0,
},
page: {
justifyContent: 'center',
alignItems: 'center',
},
})
@@ -13,22 +13,26 @@ interface CustomHeaderProps {
left?: React.ReactNode
right?: React.ReactNode
safeArea?: boolean
screenProps?: StackScreenProps
className?: string
style?: ViewStyle
}
export default function CustomHeader({
showHeader = true,
title,
left,
right,
safeArea = true,
className = '',
style,
screenProps,
}: CustomHeaderProps) {
const safeAreaInsets = useSafeAreaInsets()
export default function CustomHeader(props: CustomHeaderProps) {
const {
showHeader = true,
title,
left,
right,
safeArea = true,
screenProps,
className = '',
style,
} = props
const insets = useSafeAreaInsets()
const router = useRouter()
if (!showHeader) {
@@ -37,56 +41,63 @@ export default function CustomHeader({
{...screenProps}
options={{
...screenProps?.options,
headerShown: false,
headerShown: true,
header: () => (
<View
className={`${className}`}
style={[
{ paddingTop: safeArea ? insets.top : 0 },
style,
]}
/>
),
}}
/>
)
}
return (
<>
<Stack.Screen
{...screenProps}
options={{
...screenProps?.options,
headerShown: false,
}}
/>
<View
className={`flex-row items-center h-24 px-2 ${className}`}
style={[
{ paddingTop: safeArea ? safeAreaInsets.top : 0 },
style,
]}
>
<View className="w-10 h-full justify-center">
{left || (
<TouchableOpacity onPress={() => router.back()}>
<LucideIcon icon="ArrowLeft" size={24} clickable={false} />
</TouchableOpacity>
)}
</View>
<View className="flex-1 items-center justify-center">
{typeof title === 'string'
? (
<Text className="text-xl">{title}</Text>
)
: (
title
<Stack.Screen
{...screenProps}
options={{
...screenProps?.options,
headerShown: true,
header: () => (
<View
className={`flex-row items-center h-24 px-2 ${className}`}
style={[
{ paddingTop: safeArea ? insets.top : 0 },
style,
]}
>
{/* 左侧 */}
<View className="w-10 h-full justify-center">
{left || (
<TouchableOpacity onPress={() => router.back()}>
<LucideIcon icon="ArrowLeft" size={24} clickable={false} />
</TouchableOpacity>
)}
</View>
</View>
<View className="w-10 h-full items-center justify-center">
{right || (
<Image
source={require('@/static/images/logo.png')}
style={{ width: 22, height: 22 }}
/>
)}
</View>
</View>
</>
{/* 标题 */}
<View className="flex-1 items-center justify-center">
{typeof title === 'string'
? <Text className="text-xl">{title}</Text>
: title}
</View>
{/* 右侧 */}
<View className="w-10 h-full items-center justify-center">
{right || (
<Image
source={require('@/static/images/logo.png')}
style={{ width: 22, height: 22 }}
/>
)}
</View>
</View>
),
}}
/>
)
}
@@ -0,0 +1,130 @@
import type { ViewStyle } from 'react-native'
import * as React from 'react'
import { Animated, Text, TouchableOpacity, View } from 'react-native'
import PagerView from 'react-native-pager-view'
export interface PagerRoute {
key: string
title: string
element?: React.ReactNode
}
interface PagerTabsProps {
routes: PagerRoute[]
left?: React.ReactNode
right?: React.ReactNode
initialIndex?: number
onChange?: (key: string) => void
indicatorWidth?: number
className?: string
style?: ViewStyle
}
export default function PagerTabs({
routes,
left,
right,
initialIndex = 0,
onChange,
indicatorWidth = 38,
className,
style,
}: PagerTabsProps) {
const pagerRef = React.useRef<PagerView>(null)
const [index, setIndex] = React.useState(initialIndex)
const [tabWidth, setTabWidth] = React.useState(0)
const scrollX = React.useRef(new Animated.Value(0)).current
const onTabPress = (i: number) => {
pagerRef.current?.setPage(i)
}
const onPageScroll = (e: any) => {
const { position, offset } = e.nativeEvent
scrollX.setValue(position + offset)
}
const onPageSelected = (position: number) => {
setIndex(position)
if (onChange && routes[position]) {
onChange(routes[position].key)
}
}
const tabWidthPerTab = tabWidth / routes.length
const indicatorTranslateX = scrollX.interpolate({
inputRange: routes.map((_, i) => i),
outputRange: routes.map(
(_, i) => i * tabWidthPerTab + (tabWidthPerTab - indicatorWidth) / 2,
),
extrapolate: 'clamp',
})
return (
<View
className={`${className} flex-1`}
style={[
style,
]}
>
{/* Header */}
<View className="flex flex-row items-center">
{left}
{/* Tabs */}
<View
className="flex-1 flex-row relative"
onLayout={(e) => {
const w = e.nativeEvent.layout.width
if (w !== tabWidth)
setTabWidth(w)
}}
>
{routes.map((r, i) => (
<TouchableOpacity
key={r.key}
className="flex-1 items-center justify-center py-1"
onPress={() => onTabPress(i)}
>
<Text
className={`text-lg ${index === i ? 'text-[#333] font-bold' : 'text-gray-500'}`}
>
{r.title}
</Text>
</TouchableOpacity>
))}
{tabWidth > 0 && (
<Animated.View
className="absolute bottom-0 h-0.5 bg-black"
style={{
width: indicatorWidth,
transform: [{ translateX: indicatorTranslateX }],
}}
/>
)}
</View>
{right}
</View>
{/* Pager */}
<PagerView
ref={pagerRef}
style={{ flex: 1 }}
initialPage={initialIndex}
onPageScroll={onPageScroll}
onPageSelected={e => onPageSelected(e.nativeEvent.position)}
>
{routes.map(r => (
<View key={r.key} className="flex-1">
{r.element ?? null}
</View>
))}
</PagerView>
</View>
)
}
@@ -0,0 +1,168 @@
import type { ReactNode } from 'react'
import type { LayoutChangeEvent } from 'react-native'
import React, { createContext, useContext, useEffect, useRef, useState } from 'react'
import {
Animated,
LayoutChangeEvent as RNLayoutChangeEvent,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native'
import PagerView from 'react-native-pager-view'
interface TabsContextType {
pagerRef: React.RefObject<PagerView>
scrollX: Animated.Value
index: number
setIndex: (i: number) => void
}
const TabsContext = createContext<TabsContextType | null>(null)
interface TabsProviderProps {
children: ReactNode
}
export function TabsProvider({ children }: TabsProviderProps) {
const pagerRef = useRef<PagerView>(null!)
const scrollX = useRef(new Animated.Value(0)).current
const [index, setIndex] = useState(0)
return (
<TabsContext.Provider value={{ pagerRef, scrollX, index, setIndex }}>
{children}
</TabsContext.Provider>
)
}
// ---------------- Tabs (Header) ----------------
interface TabsProps {
tabs: string[]
leftContent?: ReactNode
rightContent?: ReactNode
}
function useSafeTabsContext(): TabsContextType {
const ctx = useContext(TabsContext)
if (!ctx) {
const pagerRef = useRef<PagerView>(null!)
const scrollX = useRef(new Animated.Value(0)).current
const [index, setIndex] = useState(0)
return { pagerRef, scrollX, index, setIndex }
}
return ctx
}
TabsProvider.Tabs = function Tabs({ tabs, leftContent, rightContent }: TabsProps) {
const ctx = useSafeTabsContext()
const tabLayouts = useRef<{ x: number, width: number }[]>([])
const scrollRef = useRef<ScrollView>(null)
const onTabLayout = (e: LayoutChangeEvent, i: number) => {
const { x, width } = e.nativeEvent.layout
tabLayouts.current[i] = { x, width }
}
const measured = tabLayouts.current.length === tabs.length
const underlineX = measured
? ctx.scrollX.interpolate({
inputRange: tabs.map((_, i) => i),
outputRange: tabLayouts.current.map(t => t.x),
})
: 0
const underlineWidth = measured
? ctx.scrollX.interpolate({
inputRange: tabs.map((_, i) => i),
outputRange: tabLayouts.current.map(t => t.width),
})
: 0
// 自动滚动 ScrollView 保持选中 Tab 可见
useEffect(() => {
if (!measured || !scrollRef.current)
return
const layout = tabLayouts.current[ctx.index]
if (!layout)
return
scrollRef.current.scrollTo({ x: Math.max(layout.x - 50, 0), animated: true })
}, [ctx.index, measured])
return (
<View style={{ flexDirection: 'row', alignItems: 'center', borderBottomWidth: 1, borderColor: '#eee' }}>
{leftContent && <View>{leftContent}</View>}
<ScrollView
ref={scrollRef}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ flexDirection: 'row', position: 'relative' }}
style={{ flex: 1 }}
>
{tabs.map((tab, i) => (
<TouchableOpacity
key={i}
onPress={() => {
ctx.pagerRef.current?.setPage(i)
ctx.setIndex(i)
}}
onLayout={e => onTabLayout(e, i)}
style={{ paddingHorizontal: 20, paddingVertical: 12 }}
>
<Text style={{ color: ctx.index === i ? '#000' : '#999' }}>{tab}</Text>
</TouchableOpacity>
))}
{measured && (
<Animated.View
style={[
styles.underline,
{ width: underlineWidth, transform: [{ translateX: underlineX }] },
]}
/>
)}
</ScrollView>
{rightContent && <View>{rightContent}</View>}
</View>
)
}
// ---------------- Content ----------------
TabsProvider.Content = function Content({ tabs }: { tabs: string[] }) {
const ctx = useSafeTabsContext()
return (
<PagerView
ref={ctx.pagerRef}
style={{ flex: 1 }}
initialPage={0}
onPageScroll={e =>
ctx.scrollX.setValue(e.nativeEvent.position + e.nativeEvent.offset)}
onPageSelected={e => ctx.setIndex(e.nativeEvent.position)}
>
{tabs.map((tab, i) => (
<View key={i} style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ fontSize: 20 }}>
{tab}
{' '}
页面内容
</Text>
</View>
))}
</PagerView>
)
}
const styles = StyleSheet.create({
underline: {
position: 'absolute',
bottom: 0,
height: 2,
backgroundColor: '#007AFF',
},
})
@@ -94,70 +94,27 @@ export default function CustomTabbar({ data }: AppTabbarProps) {
item.onPress()
}
// 定义 JS 线程安全函数
const handlePanTab = (tabName: string) => {
const tab = data.find(item => item.name === tabName)
if (!tab)
return
updateSwipeDirection(tabName)
setActiveTab(tab.name)
if (typeof tab.onPress === 'function') {
tab.onPress()
}
if (tab.href && tab.href !== pathname) {
router.push(tab.href)
}
}
// 手势滑动
const panGesture = Gesture.Pan()
.activeOffsetX([-20, 20])
.failOffsetY([-10, 10])
.onEnd((event) => {
const threshold = 60
const currentIndex = data.findIndex(item => item.name === activeTab)
let targetIndex: number | undefined
if (event.translationX < -threshold && currentIndex < data.length - 1) {
targetIndex = currentIndex + 1
}
else if (event.translationX > threshold && currentIndex > 0) {
targetIndex = currentIndex - 1
}
if (targetIndex !== undefined) {
const tabName = data[targetIndex].name
scheduleOnRN(handlePanTab, tabName)
}
})
return (
<GestureHandlerRootView style={{ flex: 1 }}>
<Tabs>
<GestureDetector gesture={panGesture}>
<View style={{ flex: 1 }}>
{(() => {
const currentTab = data.find(d => d.name === activeTab)
if (!currentTab)
return null
<View style={{ flex: 1 }}>
{(() => {
const currentTab = data.find(d => d.name === activeTab)
if (!currentTab)
return null
return (
<Animated.View
style={{
flex: 1,
backgroundColor: 'transparent',
}}
>
<TabSlot />
</Animated.View>
)
})()}
</View>
</GestureDetector>
return (
<Animated.View
style={{
flex: 1,
backgroundColor: 'transparent',
}}
>
<TabSlot />
</Animated.View>
)
})()}
</View>
<TabList asChild>
<View style={{ flexDirection: 'row', height: 48, alignItems: 'center' }}>
@@ -30,8 +30,8 @@ function isWhiteListed(url: string) {
}
const alovaInstance = createAlova({
baseURL: 'http://192.168.5.6:8085/app',
// baseURL: 'http://192.168.1.136:8085/app',
// baseURL: 'http://192.168.5.6:8085/app',
baseURL: 'http://192.168.1.136:8085/app',
requestAdapter: axiosRequestAdapter(),
timeout: 5000,
cacheFor: null,
@@ -1,44 +1,59 @@
import type { MoneyCategoryItem } from '@/types/money/category'
import AsyncStorage from '@react-native-async-storage/async-storage'
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'
import { queryMoneyCategoryApi } from '@/request/api/money/category'
interface MoneyCategoryStore {
categories: MoneyCategoryItem[]
// 通过 getter 动态计算
expense: MoneyCategoryItem[]
income: MoneyCategoryItem[]
setCategories: (categories: MoneyCategoryItem[]) => void
addCategory: (category: MoneyCategoryItem) => void
removeCategory: (id: number) => void
init: () => Promise<void>
}
export const useMoneyCategoryStore = create<MoneyCategoryStore>()(set => ({
categories: [],
expense: [],
income: [],
export const useMoneyCategoryStore = create<MoneyCategoryStore>()(
persist(
(set, get) => ({
categories: [],
setCategories: categories =>
set({
categories,
expense: categories.filter(c => c.type === 0),
income: categories.filter(c => c.type === 1),
}),
// getter 样式属性,通过 get() 动态计算
get expense() {
return get().categories.filter(c => c.type === 0)
},
get income() {
return get().categories.filter(c => c.type === 1)
},
addCategory: category =>
set((state) => {
const newCategories = [...state.categories, category]
return {
categories: newCategories,
expense: newCategories.filter(c => c.type === 0),
income: newCategories.filter(c => c.type === 1),
}
}),
setCategories: categories => set({ categories }),
removeCategory: id =>
set((state) => {
const newCategories = state.categories.filter(c => c.id !== id)
return {
categories: newCategories,
expense: newCategories.filter(c => c.type === 0),
income: newCategories.filter(c => c.type === 1),
}
addCategory: category =>
set(state => ({ categories: [...state.categories, category] })),
removeCategory: id =>
set(state => ({ categories: state.categories.filter(c => c.id !== id) })),
init: async () => {
try {
const res = await queryMoneyCategoryApi()
if (res.isSuccess) {
const categories = res.data || []
set({ categories })
}
}
catch (error) {
console.error('Failed to fetch categories:', error)
}
},
}),
}))
{
name: 'money-category-storage',
storage: createJSONStorage(() => AsyncStorage),
},
),
)
@@ -11,3 +11,5 @@ export interface MoneyCategoryBase {
export interface MoneyCategoryItem extends MoneyCategoryBase {
}
export type QueryMoneyCategoryResult = MoneyCategoryBase[]