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[]