u
This commit is contained in:
1 parent
63b168affc
commit
069b4506e4
29 files changed
+1077
-1116
No files matched your search
@@ -1,33 +1,21 @@
|
||||
import { Text, View } from 'react-native'
|
||||
import PagerTabs from '@/components/custom/PagerTabs'
|
||||
import { ACell, ACellGroup } from '@/components/common/ao'
|
||||
|
||||
export default function Demo() {
|
||||
return (
|
||||
<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
|
||||
className="flex-1"
|
||||
style={{
|
||||
marginTop: 34,
|
||||
}}
|
||||
>
|
||||
<ACellGroup >
|
||||
<ACell leftIcon='Zap' title="测试" rightText="测试右边" isLink />
|
||||
<ACell leftIcon="Airplay" title="测试" rightText="测试右边" isLink />
|
||||
<ACell leftIcon="Airplay" title="测试" rightText="测试右边" isLink />
|
||||
<ACell leftIcon="Airplay" title="测试" rightText="测试右边" isLink />
|
||||
<ACell leftIcon="Airplay" title="测试" rightText="测试右边" isLink />
|
||||
</ACellGroup>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,49 @@
|
||||
import { useRouter } from 'expo-router'
|
||||
import { Text, View } from 'react-native'
|
||||
import { AButton } from '@/components/common/ao'
|
||||
import { StyleSheet, Text, View } from 'react-native'
|
||||
import Sortable from 'react-native-sortables'
|
||||
|
||||
export default function Love() {
|
||||
const router = useRouter()
|
||||
const DATA = [
|
||||
'Poland',
|
||||
'Germany',
|
||||
'France',
|
||||
'Italy',
|
||||
'Spain',
|
||||
'Portugal',
|
||||
'Greece',
|
||||
'Great Britain',
|
||||
'United States',
|
||||
'Canada',
|
||||
'Australia',
|
||||
'New Zealand',
|
||||
]
|
||||
|
||||
export default function Flex() {
|
||||
return (
|
||||
<View className="mt-20">
|
||||
<AButton onPress={() => {
|
||||
router.navigate('/auth')
|
||||
}}
|
||||
<View style={{ flex: 1 }}>
|
||||
<Sortable.Flex
|
||||
gap={10}
|
||||
padding={10}
|
||||
flexDirection="column"
|
||||
width="fill"
|
||||
dragActivationDelay={10}
|
||||
>
|
||||
跳转到登录页面
|
||||
</AButton>
|
||||
{DATA.map(item => (
|
||||
<View style={styles.cell} key={item}>
|
||||
<Text style={{ color: '#fff' }}>{item}</Text>
|
||||
</View>
|
||||
))}
|
||||
</Sortable.Flex>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
cell: {
|
||||
backgroundColor: '#36877F',
|
||||
borderRadius: 9999,
|
||||
padding: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
alignSelf: 'stretch', // ✅ 关键:让子元素撑满父容器宽度
|
||||
width: '100%',
|
||||
},
|
||||
})
|
||||
@@ -1,630 +0,0 @@
|
||||
import type { MoneyCategoryItem } from '@/types/money/category'
|
||||
import type { MoneyRecordBase, MoneyRecordItem } from '@/types/money/record'
|
||||
import Big from 'big.js'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { FlatList, Keyboard, Pressable, StyleSheet, Text, TouchableWithoutFeedback, View } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { AInput, LucideIcon } from '@/components/common/ao'
|
||||
import { ADatePicker } from '@/components/common/ao/index'
|
||||
import SegmentedTabs from '@/components/common/ao/SegmentedTabs'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBackdrop,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
} from '@/components/ui/drawer'
|
||||
import { useKeyboardHeight } from '@/hooks/useKeyboardHeight'
|
||||
import { queryMoneyCategoryApi } from '@/request/api/money/category'
|
||||
import { insertMoneyRecordApi, updateMoneyRecordApi } from '@/request/api/money/record'
|
||||
import { useMoneyCategoryStore } from '@/store/moneyCategory'
|
||||
import { getCurrentDate, toDateString } from '@/utils/time'
|
||||
import { toast } from '@/utils/toast'
|
||||
import MoneyCategoryIcon from './MoneyCategoryIcon'
|
||||
|
||||
interface AddMoneyDrawerProps {
|
||||
isOpen: boolean
|
||||
updateData?: MoneyRecordItem | null
|
||||
onClose?: () => void
|
||||
onConfirm?: (result: MoneyRecordItem) => void
|
||||
}
|
||||
|
||||
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>
|
||||
) },
|
||||
]
|
||||
|
||||
export default function AddMoneyDrawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
updateData,
|
||||
}: AddMoneyDrawerProps) {
|
||||
const moneyCategoryStore = useMoneyCategoryStore()
|
||||
|
||||
const insets = useSafeAreaInsets()
|
||||
// 选择顺序:0 支出 1收入
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
// 自定义键盘
|
||||
const [showKeydown, setShowKeydown] = useState(false)
|
||||
// 自定义键盘计算过程
|
||||
const [keydownInput, setKeydownInput] = useState('')
|
||||
// 日期选择
|
||||
const [showDatePicker, setShowDatePicker] = useState(false)
|
||||
// 备注聚焦
|
||||
const [remarkIsFocused, setRemarkIsFocused] = useState(false)
|
||||
// 选中分类
|
||||
const [selectedCategory, setSelectedCategory] = useState<MoneyCategoryItem | null>(null)
|
||||
|
||||
const keyboardHeight = useKeyboardHeight()
|
||||
|
||||
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 handleSelectCategory(item: MoneyCategoryItem) {
|
||||
setSelectedCategory(item)
|
||||
|
||||
setShowKeydown(true)
|
||||
}
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
if (moneyCategoryStore.categories.length === 0) {
|
||||
const res = await queryMoneyCategoryApi()
|
||||
if (res.isSuccess) {
|
||||
moneyCategoryStore.setCategories(res.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error('获取分类失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (updateData) {
|
||||
const type = updateData.category_type
|
||||
setSelectedIndex(type)
|
||||
setMoneyRecord({
|
||||
...updateData,
|
||||
record_time: toDateString(updateData.record_time),
|
||||
})
|
||||
setShowKeydown(true)
|
||||
|
||||
if (type === 0) {
|
||||
// 支出
|
||||
moneyCategoryStore.expense.forEach((item) => {
|
||||
if (item.id === updateData.category_id) {
|
||||
setSelectedCategory(item)
|
||||
}
|
||||
})
|
||||
}
|
||||
else if (type === 1) {
|
||||
// 收入
|
||||
moneyCategoryStore.income.forEach((item) => {
|
||||
if (item.id === updateData.category_id) {
|
||||
setSelectedCategory(item)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
function resetData() {
|
||||
// 清空键盘记录
|
||||
setKeydownInput('')
|
||||
// 关闭默认键盘
|
||||
setShowKeydown(false)
|
||||
// 设置默认值
|
||||
setMoneyRecord(defaultMoneyRecord)
|
||||
// 选中支出
|
||||
setSelectedIndex(0)
|
||||
// 关闭日期
|
||||
setShowDatePicker(false)
|
||||
// 清空选中类别
|
||||
setSelectedCategory(null)
|
||||
}
|
||||
|
||||
// 关闭整个drawer处理
|
||||
function onMainDrawerClose() {
|
||||
resetData()
|
||||
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
// 计算过程
|
||||
const handlePress = (key: string | number) => {
|
||||
setKeydownInput((prev) => {
|
||||
let newInput = prev
|
||||
|
||||
if (key === 'del') {
|
||||
newInput = prev.slice(0, -1)
|
||||
}
|
||||
else if (key === 'clear') {
|
||||
setMoneyRecord({
|
||||
...moneyRecord,
|
||||
amount: '0.00',
|
||||
remark: '',
|
||||
record_time: getCurrentDate(),
|
||||
})
|
||||
return ''
|
||||
}
|
||||
else if (key === '/') {
|
||||
saveRecord()
|
||||
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)
|
||||
setMoneyRecord({
|
||||
...moneyRecord,
|
||||
amount: computed,
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
setMoneyRecord({
|
||||
...moneyRecord,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存
|
||||
function saveRecord() {
|
||||
if (selectedCategory === null) {
|
||||
toast.show({
|
||||
title: '请选择分类',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (moneyRecord.amount === '0.00') {
|
||||
toast.show({
|
||||
title: '请输入金额',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 去掉金额负号
|
||||
const amount = moneyRecord.amount.replace(/^-+/, '')
|
||||
|
||||
const isUpdate = updateData && updateData !== null
|
||||
|
||||
if (isUpdate) {
|
||||
updateMoneyRecordApi({
|
||||
id: moneyRecord.id,
|
||||
category_id: selectedCategory.id,
|
||||
amount,
|
||||
remark: moneyRecord.remark,
|
||||
record_time: moneyRecord.record_time,
|
||||
}).then((res) => {
|
||||
if (res.isSuccess) {
|
||||
const result: MoneyRecordItem = {
|
||||
...moneyRecord,
|
||||
category_id: selectedCategory.id,
|
||||
category_name: selectedCategory.name,
|
||||
category_icon: selectedCategory.icon,
|
||||
category_type: selectedCategory.type,
|
||||
}
|
||||
|
||||
onConfirm?.(result)
|
||||
|
||||
resetData()
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
})
|
||||
}
|
||||
else {
|
||||
insertMoneyRecordApi({
|
||||
...moneyRecord,
|
||||
amount,
|
||||
category_id: selectedCategory.id,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.isSuccess) {
|
||||
const result: MoneyRecordItem = {
|
||||
...res.data,
|
||||
category_name: selectedCategory.name,
|
||||
category_icon: selectedCategory.icon,
|
||||
category_type: selectedCategory.type,
|
||||
}
|
||||
|
||||
onConfirm?.(result)
|
||||
resetData()
|
||||
}
|
||||
else {
|
||||
toast.show({
|
||||
title: res.message || '保存失败',
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
isOpen={isOpen}
|
||||
size="full"
|
||||
anchor="bottom"
|
||||
onClose={onMainDrawerClose}
|
||||
>
|
||||
<DrawerBackdrop />
|
||||
|
||||
<DrawerContent
|
||||
className="p-0 flex-1"
|
||||
|
||||
onAnimationComplete={() => {
|
||||
fetchCategories()
|
||||
}}
|
||||
>
|
||||
<DrawerHeader
|
||||
className="flex items-center justify-between"
|
||||
style={{ paddingTop: insets.top + 8, paddingLeft: 16, paddingRight: 16 }}
|
||||
>
|
||||
<View className="w-1" />
|
||||
<SegmentedTabs
|
||||
value={selectedIndex}
|
||||
tabs={[{ label: '支出' }, { label: '收入' }]}
|
||||
onChange={setSelectedIndex}
|
||||
style={{ width: 180 }}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<Pressable onPress={onMainDrawerClose}>
|
||||
<Text className="text-gray-500 font-medium">取消</Text>
|
||||
</Pressable>
|
||||
</DrawerHeader>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
marginTop: 16,
|
||||
backgroundColor: '#fff',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const list = selectedIndex === 1 ? moneyCategoryStore.income : moneyCategoryStore.expense
|
||||
|
||||
const memoItems = useMemo(() => {
|
||||
const items = [...list]
|
||||
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
|
||||
}, [list])
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={memoItems}
|
||||
numColumns={4}
|
||||
keyExtractor={item => item.id.toString()}
|
||||
showsVerticalScrollIndicator={false}
|
||||
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={() => handleSelectCategory(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>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</View>
|
||||
|
||||
{showKeydown && (
|
||||
<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">{moneyRecord.record_time}</Text>
|
||||
<LucideIcon icon="ChevronDown" size={12} color="#aaa" clickable={false} />
|
||||
</Pressable>
|
||||
<Text className="text-3xl text-blue-700 text-right">{moneyRecord.amount}</Text>
|
||||
</View>
|
||||
|
||||
<Text className="text-gray-400 text-right">{keydownInput}</Text>
|
||||
<AInput
|
||||
value={moneyRecord.remark}
|
||||
placeholder="备注"
|
||||
className="bg-[#f2f3f5] border-0"
|
||||
size="sm"
|
||||
onChangeText={(val) => {
|
||||
setMoneyRecord({
|
||||
...moneyRecord,
|
||||
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 }} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<ADatePicker
|
||||
pickerTitle="记账时间"
|
||||
mode="date"
|
||||
date={moneyRecord.record_time}
|
||||
show={showDatePicker}
|
||||
onClose={() => {
|
||||
setShowDatePicker(false)
|
||||
}}
|
||||
onConfirm={(date) => {
|
||||
setMoneyRecord({
|
||||
...moneyRecord,
|
||||
record_time: date,
|
||||
})
|
||||
setShowDatePicker(false)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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} />
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import Animated, { useAnimatedStyle, withSpring, withTiming } from 'react-native-reanimated'
|
||||
// import { IconQuxian1 } from '@/components/iconfont'
|
||||
|
||||
interface TabSelectorProps {
|
||||
options: string[]
|
||||
selected: number
|
||||
onSelect: (tab: number) => void
|
||||
}
|
||||
|
||||
const TabSelector: React.FC<TabSelectorProps> = ({ options, selected, onSelect }) => {
|
||||
return (
|
||||
<View className="flex-row gap-4 items-center h-10 mt-2">
|
||||
{options.map((tab, index) => {
|
||||
const isActive = selected === index
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ rotate: '50deg' },
|
||||
{ scale: withSpring(isActive ? 1 : 0.8, { damping: 12, stiffness: 120 }) },
|
||||
],
|
||||
opacity: withTiming(isActive ? 1 : 0, { duration: 200 }),
|
||||
}))
|
||||
|
||||
return (
|
||||
<View key={tab} className="relative h-full items-center">
|
||||
<Pressable
|
||||
onPress={() => onSelect(index)}
|
||||
className="h-full items-center justify-center"
|
||||
>
|
||||
<Text
|
||||
className={`${isActive ? 'font-bold text-xl text-black' : 'font-normal text-base text-gray-600 '}`}
|
||||
>
|
||||
{tab}
|
||||
</Text>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
},
|
||||
animatedStyle,
|
||||
]}
|
||||
>
|
||||
{/* <IconQuxian1 size={22} color="#7566f3" /> */}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default TabSelector
|
||||
@@ -10,18 +10,20 @@ import {
|
||||
} from 'react-native-popup-menu'
|
||||
import { LucideIcon } from '@/components/common/ao'
|
||||
import EmptyState from '@/components/common/EmptyStatus'
|
||||
import MoneyCategoryIcon from './MoneyCategoryIcon'
|
||||
import MoneyCategoryIcon from '@/components/common/MoneyCategoryIcon'
|
||||
|
||||
interface StaticTransactionsProps {
|
||||
records: MoneyRecordGroup[]
|
||||
onUpdate?: (data: MoneyRecordItem) => void
|
||||
onDelete?: (id: number) => void
|
||||
onDetail?: (id: number) => void
|
||||
}
|
||||
|
||||
export default function StaticTransactions({
|
||||
records,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onDetail,
|
||||
}: StaticTransactionsProps) {
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
|
||||
@@ -87,6 +89,7 @@ export default function StaticTransactions({
|
||||
placement: 'bottom',
|
||||
preferredPlacement: 'bottom',
|
||||
anchorStyle: { backgroundColor: 'transparent' },
|
||||
closeAnimationDuration: 0,
|
||||
}}
|
||||
onSelect={() => setSelectedId(null)}
|
||||
onBackdropPress={() => setSelectedId(null)}
|
||||
@@ -137,6 +140,7 @@ export default function StaticTransactions({
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
<MenuOption
|
||||
customStyles={{
|
||||
OptionTouchableComponent: TouchableHighlight,
|
||||
@@ -171,6 +175,23 @@ export default function StaticTransactions({
|
||||
<Text className="ml-2 text-lg">删除</Text>
|
||||
</View>
|
||||
</MenuOption>
|
||||
<MenuOption
|
||||
customStyles={{
|
||||
OptionTouchableComponent: TouchableHighlight,
|
||||
optionTouchable: {
|
||||
underlayColor: '#f0f0f0',
|
||||
activeOpacity: 0.7,
|
||||
},
|
||||
}}
|
||||
onSelect={() => {
|
||||
onDetail?.(item.id)
|
||||
}}
|
||||
>
|
||||
<View className="flex flex-row items-center h-10 px-2">
|
||||
<LucideIcon icon="ReceiptText" />
|
||||
<Text className="ml-2 text-lg">详细</Text>
|
||||
</View>
|
||||
</MenuOption>
|
||||
</MenuOptions>
|
||||
</Menu>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { MoneyRecordGroup, MoneyRecordItem, QueryMoneyRecordsResult } from '@/types/money/record'
|
||||
import { useRouter } from 'expo-router'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Text, TouchableOpacity, View } from 'react-native'
|
||||
@@ -7,7 +6,7 @@ import { ADatePicker, LucideIcon } from '@/components/common/ao'
|
||||
import { Fab, FabIcon } from '@/components/ui/fab'
|
||||
import { AddIcon } from '@/components/ui/icon'
|
||||
import { Progress, ProgressFilledTrack } from '@/components/ui/progress'
|
||||
import { deleteMoneyRecordApi, queryMoneyRecordsApi } from '@/request/api/money/record'
|
||||
import { queryMoneyRecordsApi } from '@/request/api/money/record'
|
||||
import { useMoneyRecordStore } from '@/store/moneyRecord'
|
||||
import { getCurrentYearMonth } from '@/utils/time'
|
||||
import { toast } from '@/utils/toast'
|
||||
@@ -133,6 +132,12 @@ export default function Money() {
|
||||
params: { id: data.id },
|
||||
})
|
||||
}}
|
||||
onDetail={(id) => {
|
||||
router.navigate({
|
||||
pathname: '/money/record/detail',
|
||||
params: { id },
|
||||
})
|
||||
}}
|
||||
onDelete={(id) => {
|
||||
moneyRecordStore.deleteMoneyRecord(id)
|
||||
}}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { MenuProvider } from 'react-native-popup-menu'
|
||||
import { AButton } from '@/components/common/ao'
|
||||
import EmptyState from '@/components/common/EmptyStatus'
|
||||
import { GluestackUIProvider } from '@/components/ui/gluestack-ui-provider'
|
||||
import { useMoneyCategoryStore } from '@/store/moneyCategory'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { ToastProviderWrapper } from '@/utils/toast'
|
||||
import '@/styles/global.css'
|
||||
@@ -42,6 +43,7 @@ export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) {
|
||||
export default function RootLayout() {
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const moneyCategoryStore = useMoneyCategoryStore()
|
||||
|
||||
const [fontsLoaded] = useFonts({
|
||||
SpaceMono: require('@/static/fonts/SpaceMono-Regular.ttf'),
|
||||
@@ -60,10 +62,12 @@ export default function RootLayout() {
|
||||
}
|
||||
|
||||
if (!userStore.isLoggedIn()) {
|
||||
// 未登录直接跳到 auth
|
||||
router.replace('/auth/index')
|
||||
return null
|
||||
}
|
||||
|
||||
moneyCategoryStore.init()
|
||||
|
||||
return (
|
||||
<MenuProvider>
|
||||
<KeyboardProvider>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { useState } from 'react'
|
||||
import { Text, View } from 'react-native'
|
||||
import { AButton, ACell, ACellGroup, AModal } from '@/components/common/ao'
|
||||
import MoneyCategoryIcon from '@/components/common/MoneyCategoryIcon'
|
||||
import CustomHeader from '@/components/custom/Header'
|
||||
import { useMoneyRecordStore } from '@/store/moneyRecord'
|
||||
import { toDateString, toDateTimeString } from '@/utils/time'
|
||||
|
||||
export default function MoneyRecordDetail() {
|
||||
const router = useRouter()
|
||||
const moneyRecordStore = useMoneyRecordStore()
|
||||
|
||||
const { id: detailId } = useLocalSearchParams<{ id: string }>()
|
||||
|
||||
const moneryRecord = moneyRecordStore.getMoneyRecordById(Number(detailId))
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
|
||||
if (moneryRecord === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className="flex-1">
|
||||
<CustomHeader title="账单详情" />
|
||||
<View className="flex-1">
|
||||
<View className="flex flex-col items-center">
|
||||
<MoneyCategoryIcon name={moneryRecord.category_icon} size={56} />
|
||||
<Text className="mt-1">{moneryRecord.category_name}</Text>
|
||||
</View>
|
||||
|
||||
<ACellGroup showDivider={false} className="mt-4">
|
||||
<ACell title="类别" rightText={moneryRecord.category_type === 0 ? '支出' : '收入'} />
|
||||
<ACell title="金额" rightText={moneryRecord.amount} />
|
||||
<ACell title="备注" rightText={moneryRecord.remark} />
|
||||
<ACell title="日期" rightText={toDateString(moneryRecord.record_time)} />
|
||||
<ACell title="操作日期" rightText={toDateTimeString(moneryRecord.update_time || moneryRecord.create_time)} />
|
||||
</ACellGroup>
|
||||
|
||||
<View className="flex flex-row px-4 mt-4 gap-4">
|
||||
<AButton
|
||||
className="flex-1"
|
||||
action="negative"
|
||||
variant="outline"
|
||||
onPress={() => {
|
||||
setShowDeleteModal(true)
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</AButton>
|
||||
<AButton
|
||||
className="flex-1"
|
||||
onPress={() => {
|
||||
router.navigate({
|
||||
pathname: '/money/record/editor',
|
||||
params: { id: detailId },
|
||||
})
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</AButton>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
</View>
|
||||
|
||||
<AModal
|
||||
isOpen={showDeleteModal}
|
||||
title="提示"
|
||||
body="确认删除该条账单?"
|
||||
onConfirm={() => {
|
||||
setShowDeleteModal(false)
|
||||
|
||||
moneyRecordStore.deleteMoneyRecord(Number(detailId))
|
||||
|
||||
router.back()
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowDeleteModal(false)
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ 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'
|
||||
import MoneyCategoryIcon from '@/components/common/MoneyCategoryIcon'
|
||||
|
||||
interface CategoryGridProps {
|
||||
data: MoneyCategoryItem[]
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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} />
|
||||
}
|
||||
@@ -6,7 +6,7 @@ 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 { insertMoneyRecordApi } from '@/request/api/money/record'
|
||||
import { insertMoneyRecordApi, updateMoneyRecordApi } from '@/request/api/money/record'
|
||||
import { useMoneyCategoryStore } from '@/store/moneyCategory'
|
||||
import { useMoneyRecordStore } from '@/store/moneyRecord'
|
||||
import { getCurrentDate, toDateString } from '@/utils/time'
|
||||
@@ -23,7 +23,6 @@ export default function MoenyRecordEditor() {
|
||||
const income = moneyCategoryStore.getIncomeCategories()
|
||||
|
||||
const { id: updateId } = useLocalSearchParams<{ id: string }>()
|
||||
|
||||
// 自定义键盘
|
||||
const [showKeydown, setShowKeydown] = useState(false)
|
||||
// 选中分类
|
||||
@@ -78,34 +77,61 @@ export default function MoenyRecordEditor() {
|
||||
|
||||
const amount = moneyRecord.amount.replace(/^-+/, '')
|
||||
|
||||
insertMoneyRecordApi({
|
||||
...moneyRecord,
|
||||
amount,
|
||||
category_id: selectedCategory.id,
|
||||
})
|
||||
.then((res) => {
|
||||
if (updateId) {
|
||||
updateMoneyRecordApi({
|
||||
id: moneyRecord.id,
|
||||
category_id: selectedCategory.id,
|
||||
amount,
|
||||
remark: moneyRecord.remark,
|
||||
record_time: moneyRecord.record_time,
|
||||
}).then((res) => {
|
||||
if (res.isSuccess) {
|
||||
const result: MoneyRecordItem = {
|
||||
...res.data,
|
||||
...moneyRecord,
|
||||
category_id: selectedCategory.id,
|
||||
category_name: selectedCategory.name,
|
||||
category_icon: selectedCategory.icon,
|
||||
category_type: selectedCategory.type,
|
||||
}
|
||||
|
||||
moneyRecordStore.addMoneyRecord(result)
|
||||
moneyRecordStore.updateMoneyRecord(result)
|
||||
|
||||
router.back()
|
||||
}
|
||||
else {
|
||||
toast.show({
|
||||
title: res.message || '保存失败',
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('发生异常啦')
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
})
|
||||
}
|
||||
else {
|
||||
insertMoneyRecordApi({
|
||||
...moneyRecord,
|
||||
amount,
|
||||
category_id: selectedCategory.id,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.isSuccess) {
|
||||
const result: MoneyRecordItem = {
|
||||
...res.data,
|
||||
category_name: selectedCategory.name,
|
||||
category_icon: selectedCategory.icon,
|
||||
category_type: selectedCategory.type,
|
||||
}
|
||||
|
||||
moneyRecordStore.addMoneyRecord(result)
|
||||
|
||||
router.back()
|
||||
}
|
||||
else {
|
||||
toast.show({
|
||||
title: res.message || '保存失败',
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log('发生异常啦')
|
||||
console.log(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,127 +1,11 @@
|
||||
import * as React from 'react'
|
||||
import { Animated, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native'
|
||||
import PagerView from 'react-native-pager-view'
|
||||
import { View } from 'react-native'
|
||||
import CustomHeader from '@/components/custom/Header'
|
||||
|
||||
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',
|
||||
})
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerSide}>Left</Text>
|
||||
|
||||
{/* 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 className="flex-1">
|
||||
<CustomHeader title="共享空间" />
|
||||
</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',
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user