diff --git a/code/base-project/base-app/src/app/(tabs)/chat.tsx b/code/base-project/base-app/src/app/(tabs)/chat.tsx index 2919c633..f7144df8 100644 --- a/code/base-project/base-app/src/app/(tabs)/chat.tsx +++ b/code/base-project/base-app/src/app/(tabs)/chat.tsx @@ -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 ( - - 第一页内容 - - ) -} - -function PageTwo() { - return ( - - 第二页内容 - - ) -} - -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 - case 'two': - return - default: - return null - } - } - - return ( - - {/* Header + Tab */} - - console.log('Left button clicked')}> - 返回 - - - - {routes.map((r, i) => { - const focused = i === index - return ( - setIndex(i)} style={styles.tabButton}> - {r.title} - {focused && } - - ) - })} - - - console.log('Right button clicked')}> - 添加 - - - - {/* TabView */} - null} // 不使用默认 TabBar + + + Left + + )} + right={( + + right + + )} + routes={[ + { + key: 'tab1', + title: 'Tab 1', + element: 第一个页面内容, + }, + { + key: 'tab2', + title: 'Tab 2', + element: 第二个页面内容, + }, + ]} /> - - 123 - + ) } - -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', - }, -}) diff --git a/code/base-project/base-app/src/app/(tabs)/money/components/AddDrawer.tsx b/code/base-project/base-app/src/app/(tabs)/money/components/AddDrawer.tsx index 30ecf0e0..83768e3a 100644 --- a/code/base-project/base-app/src/app/(tabs)/money/components/AddDrawer.tsx +++ b/code/base-project/base-app/src/app/(tabs)/money/components/AddDrawer.tsx @@ -110,7 +110,6 @@ export default function AddMoneyDrawer({ moneyCategoryStore.setCategories(res.data) } } - console.log(moneyCategoryStore.categories) } catch (err) { console.error('获取分类失败', err) diff --git a/code/base-project/base-app/src/app/(tabs)/money/index.tsx b/code/base-project/base-app/src/app/(tabs)/money/index.tsx index bb7b83d5..7bd1385f 100644 --- a/code/base-project/base-app/src/app/(tabs)/money/index.tsx +++ b/code/base-project/base-app/src/app/(tabs)/money/index.tsx @@ -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() { { - setShowAddDrawer(true) + // setShowAddDrawer(true) + router.navigate('/money/record/editor') }} > diff --git a/code/base-project/base-app/src/app/_layout.tsx b/code/base-project/base-app/src/app/_layout.tsx index 372c390d..7e95dee0 100644 --- a/code/base-project/base-app/src/app/_layout.tsx +++ b/code/base-project/base-app/src/app/_layout.tsx @@ -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() { }} /> - - diff --git a/code/base-project/base-app/src/app/money/record/editor/components/AmountKeyboard.tsx b/code/base-project/base-app/src/app/money/record/editor/components/AmountKeyboard.tsx new file mode 100644 index 00000000..7773994c --- /dev/null +++ b/code/base-project/base-app/src/app/money/record/editor/components/AmountKeyboard.tsx @@ -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: 1 }, + { key: '2', content: 2 }, + { key: '3', content: 3 }, + { key: 'del', content: }, + { key: '4', content: 4 }, + { key: '5', content: 5 }, + { key: '6', content: 6 }, + { key: '+', content: }, + { key: '7', content: 7 }, + { key: '8', content: 8 }, + { key: '9', content: 9 }, + { key: '-', content: }, + { key: 'clear', content: ( + + 重置 + + ) }, + { key: '0', content: 0 }, + { key: '.', content: }, + { key: '/', content: ( + + 完成 + + ) }, +] + +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) => { + 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 ( + + + + + setShowDatePicker(true)} + > + + {internalData.record_time} + + + {internalData.amount} + + + {keydownInput} + handleChange({ remark: val })} + onFocus={() => setRemarkIsFocused(true)} + onBlur={() => setRemarkIsFocused(false)} + /> + + + + {keys.map((item, idx) => { + const isFirstCol = idx % 4 === 0 + const isFirstRow = idx < 4 + const isLastCol = (idx + 1) % 4 === 0 + const isLastRow = idx >= 12 + + return ( + 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 ( + <> + + {item.content} + + ) + }} + + ) + })} + + {remarkIsFocused && keyboardHeight > 220 && ( + + )} + + { + setShowDatePicker(false) + }} + onConfirm={(date) => { + setShowDatePicker(false) + handleChange({ record_time: date }) + }} + /> + + ) +} diff --git a/code/base-project/base-app/src/app/money/record/editor/components/CategoryGrid.tsx b/code/base-project/base-app/src/app/money/record/editor/components/CategoryGrid.tsx new file mode 100644 index 00000000..889c5684 --- /dev/null +++ b/code/base-project/base-app/src/app/money/record/editor/components/CategoryGrid.tsx @@ -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 ( + item.id.toString()} + showsVerticalScrollIndicator={false} + className={`${className}`} + style={[style]} + renderItem={({ item }) => { + if (!('name' in item)) { + return + } + + const isSelected = selectedCategory?.id === item.id + + return ( + onSelectCategory?.(item)} + > + + + + + {item.name} + + + ) + }} + /> + ) +} diff --git a/code/base-project/base-app/src/app/money/record/editor/components/CategoryIcon.tsx b/code/base-project/base-app/src/app/money/record/editor/components/CategoryIcon.tsx new file mode 100644 index 00000000..9fafc0f9 --- /dev/null +++ b/code/base-project/base-app/src/app/money/record/editor/components/CategoryIcon.tsx @@ -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 +} diff --git a/code/base-project/base-app/src/app/money/record/editor/index.tsx b/code/base-project/base-app/src/app/money/record/editor/index.tsx new file mode 100644 index 00000000..18ae283a --- /dev/null +++ b/code/base-project/base-app/src/app/money/record/editor/index.tsx @@ -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(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(defaultMoneyRecord) + + // 保存记录 + function handleSaveRecord() { + if (selectedCategory === null) { + toast.show({ + title: '请选择分类', + }) + return + } + + if (moneyRecord.amount === '0.00') { + toast.show({ + title: '请输入金额', + }) + } + } + + return ( + + + + + + 管理 + + )} + right={( + { + router.back() + }} + > + 取消 + + )} + routes={[ + { + key: 'expense', + title: '支出', + element: ( + { + setShowKeydown(true) + setSelectedCategory(data) + }} + className="mt-4" + /> + ), + }, + { + key: 'income', + title: '收入', + element: ( + { + setShowKeydown(true) + setSelectedCategory(data) + }} + className="mt-4" + /> + ), + }, + ]} + /> + + { + setMoneyRecord({ + ...moneyRecord, + record_time: data.record_time, + amount: data.amount, + remark: data.remark, + }) + }} + onDone={handleSaveRecord} + /> + + + ) +} diff --git a/code/base-project/base-app/src/app/system/share/index.tsx b/code/base-project/base-app/src/app/system/share/index.tsx index a61be67d..0edafdef 100644 --- a/code/base-project/base-app/src/app/system/share/index.tsx +++ b/code/base-project/base-app/src/app/system/share/index.tsx @@ -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(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 ( - - + + {/* Header */} + + Left - + {/* TabBar */} + setTabWidth(e.nativeEvent.layout.width)} + > + {routes.map((r, i) => ( + onTabPress(i)} + > + + {r.title} + + + ))} + + {tabWidth > 0 && ( + + )} + + + Right + + + {/* PagerView */} + setIndex(e.nativeEvent.position)} + > + + 第一个页面内容 + + + 第二个页面内容 + + ) } + +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', + }, +}) diff --git a/code/base-project/base-app/src/components/custom/Header.tsx b/code/base-project/base-app/src/components/custom/Header.tsx index 08e6fd00..0706e3ce 100644 --- a/code/base-project/base-app/src/components/custom/Header.tsx +++ b/code/base-project/base-app/src/components/custom/Header.tsx @@ -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: () => ( + + ), }} /> ) } return ( - <> - - - - - {left || ( - router.back()}> - - - )} - - - - {typeof title === 'string' - ? ( - {title} - ) - : ( - title + ( + + {/* 左侧 */} + + {left || ( + router.back()}> + + )} - + - - {right || ( - - )} - - - + {/* 标题 */} + + {typeof title === 'string' + ? {title} + : title} + + + {/* 右侧 */} + + {right || ( + + )} + + + ), + }} + /> ) } diff --git a/code/base-project/base-app/src/components/custom/PagerTabs.tsx b/code/base-project/base-app/src/components/custom/PagerTabs.tsx new file mode 100644 index 00000000..e23e77b2 --- /dev/null +++ b/code/base-project/base-app/src/components/custom/PagerTabs.tsx @@ -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(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 ( + + {/* Header */} + + {left} + + {/* Tabs */} + { + const w = e.nativeEvent.layout.width + if (w !== tabWidth) + setTabWidth(w) + }} + > + {routes.map((r, i) => ( + onTabPress(i)} + > + + {r.title} + + + ))} + + {tabWidth > 0 && ( + + )} + + + {right} + + + {/* Pager */} + onPageSelected(e.nativeEvent.position)} + > + {routes.map(r => ( + + {r.element ?? null} + + ))} + + + ) +} diff --git a/code/base-project/base-app/src/components/custom/TabsProvider.tsx b/code/base-project/base-app/src/components/custom/TabsProvider.tsx new file mode 100644 index 00000000..37c16d32 --- /dev/null +++ b/code/base-project/base-app/src/components/custom/TabsProvider.tsx @@ -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 + scrollX: Animated.Value + index: number + setIndex: (i: number) => void +} + +const TabsContext = createContext(null) + +interface TabsProviderProps { + children: ReactNode +} + +export function TabsProvider({ children }: TabsProviderProps) { + const pagerRef = useRef(null!) + const scrollX = useRef(new Animated.Value(0)).current + const [index, setIndex] = useState(0) + + return ( + + {children} + + ) +} + +// ---------------- Tabs (Header) ---------------- +interface TabsProps { + tabs: string[] + leftContent?: ReactNode + rightContent?: ReactNode +} + +function useSafeTabsContext(): TabsContextType { + const ctx = useContext(TabsContext) + if (!ctx) { + const pagerRef = useRef(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(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 ( + + {leftContent && {leftContent}} + + + {tabs.map((tab, i) => ( + { + ctx.pagerRef.current?.setPage(i) + ctx.setIndex(i) + }} + onLayout={e => onTabLayout(e, i)} + style={{ paddingHorizontal: 20, paddingVertical: 12 }} + > + {tab} + + ))} + + {measured && ( + + )} + + + {rightContent && {rightContent}} + + ) +} + +// ---------------- Content ---------------- +TabsProvider.Content = function Content({ tabs }: { tabs: string[] }) { + const ctx = useSafeTabsContext() + + return ( + + ctx.scrollX.setValue(e.nativeEvent.position + e.nativeEvent.offset)} + onPageSelected={e => ctx.setIndex(e.nativeEvent.position)} + > + {tabs.map((tab, i) => ( + + + {tab} + {' '} + 页面内容 + + + ))} + + ) +} + +const styles = StyleSheet.create({ + underline: { + position: 'absolute', + bottom: 0, + height: 2, + backgroundColor: '#007AFF', + }, +}) diff --git a/code/base-project/base-app/src/components/custom/tabbar.tsx b/code/base-project/base-app/src/components/custom/tabbar.tsx index 6d47e7f3..d55828d3 100644 --- a/code/base-project/base-app/src/components/custom/tabbar.tsx +++ b/code/base-project/base-app/src/components/custom/tabbar.tsx @@ -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 ( - - - {(() => { - const currentTab = data.find(d => d.name === activeTab) - if (!currentTab) - return null + + {(() => { + const currentTab = data.find(d => d.name === activeTab) + if (!currentTab) + return null - return ( - - - - ) - })()} - - + return ( + + + + ) + })()} + diff --git a/code/base-project/base-app/src/request/index.ts b/code/base-project/base-app/src/request/index.ts index a70533ab..89b78e6c 100644 --- a/code/base-project/base-app/src/request/index.ts +++ b/code/base-project/base-app/src/request/index.ts @@ -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, diff --git a/code/base-project/base-app/src/store/moneyCategory.ts b/code/base-project/base-app/src/store/moneyCategory.ts index b4db0008..3eff17c9 100644 --- a/code/base-project/base-app/src/store/moneyCategory.ts +++ b/code/base-project/base-app/src/store/moneyCategory.ts @@ -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 } -export const useMoneyCategoryStore = create()(set => ({ - categories: [], - expense: [], - income: [], +export const useMoneyCategoryStore = create()( + 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), + }, + ), +) diff --git a/code/base-project/base-app/src/types/money/category.ts b/code/base-project/base-app/src/types/money/category.ts index b55f421e..60513714 100644 --- a/code/base-project/base-app/src/types/money/category.ts +++ b/code/base-project/base-app/src/types/money/category.ts @@ -11,3 +11,5 @@ export interface MoneyCategoryBase { export interface MoneyCategoryItem extends MoneyCategoryBase { } + +export type QueryMoneyCategoryResult = MoneyCategoryBase[]