u
This commit is contained in:
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" />
|
||||
|
||||
@@ -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[]
|
||||
Reference in new issue
Block a user