This commit is contained in:
oneao committed 2025-11-19 22:55:09 +08:00
1 parent 49285f1df2
commit 63b168affc
7 files changed
+284 -324

No files matched your search

@@ -1,22 +1,21 @@
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 { Text, TouchableOpacity, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
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 { add, sub } from '@/utils/math'
import { getCurrentYearMonth, isInMonth } from '@/utils/time'
import { useMoneyRecordStore } from '@/store/moneyRecord'
import { getCurrentYearMonth } from '@/utils/time'
import { toast } from '@/utils/toast'
import AddMoneyDrawer from './components/AddDrawer'
import TransactionsList from './components/TransactionsList'
export default function Money() {
const moneyRecordStore = useMoneyRecordStore()
const router = useRouter()
const [showAddDrawer, setShowAddDrawer] = useState(false)
// 安全高度
const insets = useSafeAreaInsets()
@@ -25,22 +24,13 @@ export default function Money() {
const [searchParams, setSearchParams] = useState({
record_time: getCurrentYearMonth(),
})
// 数据
const [moneyRecords, setMoneyRecords] = useState<QueryMoneyRecordsResult>({
total_expense: '0',
total_income: '0',
records: [],
})
// 更新
const [updateItem, setUpdateItem] = useState<MoneyRecordItem | null>(null)
useEffect(() => {
queryMoneyRecordsApi({
record_time: searchParams.record_time,
}).then((res) => {
if (res.isSuccess) {
console.log(res.data.records)
setMoneyRecords(res.data)
moneyRecordStore.setRecords(res.data.records, res.data.total_expense, res.data.total_income, searchParams.record_time)
}
else {
toast.show({
@@ -52,232 +42,6 @@ export default function Money() {
})
}, [searchParams])
function handleAddMoneyRecord(data: MoneyRecordItem) {
const recordTime = data.record_time
setMoneyRecords((prev) => {
// 不在当前月份 → 不添加,直接返回
if (!isInMonth(recordTime, searchParams.record_time)) {
return prev
}
const next = structuredClone(prev)
// 查找分组
const group = next.records.find(g => g.record_time === recordTime)
// === 已存在分组 ===
if (group) {
// 插入到最前面
group.records.unshift(data)
// 更新分组 & 总计
if (data.category_type === 1) {
// 收入
group.income = add(group.income, data.amount)
next.total_income = add(next.total_income, data.amount)
}
else if (data.category_type === 0) {
// 支出
group.expense = add(group.expense, data.amount)
next.total_expense = add(next.total_expense, data.amount)
}
return next
}
// === 无分组,新建 ===
const expense = data.category_type === 0 ? data.amount : '0'
const income = data.category_type === 1 ? data.amount : '0'
const newGroup: MoneyRecordGroup = {
record_time: recordTime,
income,
expense,
records: [data],
}
// ---- 按日期(YYYY-MM-DD)倒序插入 ----
// 最新日期插入前面,比如:2025-01-05 > 2025-01-03
const index = next.records.findIndex(g => g.record_time < recordTime)
if (index === -1) {
next.records.push(newGroup)
}
else {
next.records.splice(index, 0, newGroup)
}
// 更新总计
if (data.category_type === 0) {
next.total_income = add(next.total_income, data.amount)
}
else if (data.category_type === 1) {
next.total_expense = add(next.total_expense, data.amount)
}
return next
})
}
function handleUpdateMoneyRecord(updated: MoneyRecordItem) {
setMoneyRecords((prev) => {
const next = structuredClone(prev)
// 找到旧记录
const oldGroupIndex = next.records.findIndex(g =>
g.records.some(r => r.id === updated.id),
)
if (oldGroupIndex === -1)
return next
const oldGroup = next.records[oldGroupIndex]
const oldRecordIndex = oldGroup.records.findIndex(r => r.id === updated.id)
const oldRecord = oldGroup.records[oldRecordIndex]
const oldAmount = oldRecord.amount
const newAmount = updated.amount
const oldType = oldRecord.category_type
const newType = updated.category_type
const oldDate = oldRecord.record_time
const newDate = updated.record_time
// record_time 没变 → 同分组更新
if (oldDate === newDate) {
oldGroup.records[oldRecordIndex] = updated
// 调整分组金额
if (oldType === 1)
oldGroup.income = sub(oldGroup.income, oldAmount)
if (oldType === 0)
oldGroup.expense = sub(oldGroup.expense, oldAmount)
if (newType === 1)
oldGroup.income = add(oldGroup.income, newAmount)
if (newType === 0)
oldGroup.expense = add(oldGroup.expense, newAmount)
// 调整总计
if (oldType === 1)
next.total_income = sub(next.total_income, oldAmount)
if (oldType === 0)
next.total_expense = sub(next.total_expense, oldAmount)
if (newType === 1)
next.total_income = add(next.total_income, newAmount)
if (newType === 0)
next.total_expense = add(next.total_expense, newAmount)
return next
}
// -------------------------
// 判断是否在当前月份内
// -------------------------
const currentMonth = searchParams.record_time // 例如 "2025-01"
const isInCurrentMonth = isInMonth(newDate, currentMonth)
// 1. 先移除旧分组 & 调整金额(无论是否在当前月份,都要做)
if (oldType === 1)
oldGroup.income = sub(oldGroup.income, oldAmount)
if (oldType === 0)
oldGroup.expense = sub(oldGroup.expense, oldAmount)
if (oldType === 1)
next.total_income = sub(next.total_income, oldAmount)
if (oldType === 0)
next.total_expense = sub(next.total_expense, oldAmount)
oldGroup.records.splice(oldRecordIndex, 1)
if (oldGroup.records.length === 0) {
next.records.splice(oldGroupIndex, 1)
}
// -------------------------
// 不在当前月份 → 不显示,直接返回 next
// -------------------------
if (!isInCurrentMonth) {
return next
}
// -------------------------
// 在当前月份 → 移动到新分组
// -------------------------
// 找新分组,如果没有就创建
let newGroup = next.records.find(g => g.record_time === newDate)
if (!newGroup) {
newGroup = {
record_time: newDate,
income: '0.00',
expense: '0.00',
records: [],
}
// 按日期倒序插入
const insertIndex = next.records.findIndex(g => g.record_time < newDate)
if (insertIndex === -1)
next.records.push(newGroup)
else
next.records.splice(insertIndex, 0, newGroup)
}
// 添加到新分组
newGroup.records.unshift(updated)
// 加金额
if (newType === 1)
newGroup.income = add(newGroup.income, newAmount)
if (newType === 0)
newGroup.expense = add(newGroup.expense, newAmount)
if (newType === 1)
next.total_income = add(next.total_income, newAmount)
if (newType === 0)
next.total_expense = add(next.total_expense, newAmount)
return next
})
}
function handleDeleteRecord(id: number) {
// 直接更新本地状态
setMoneyRecords((prev) => {
const next = structuredClone(prev)
// 遍历每个分组
next.records.forEach((group) => {
const index = group.records.findIndex(r => r.id === id)
if (index !== -1) {
const record = group.records[index]
// 更新分组金额
if (record.category_type === 1) {
group.income = sub(group.income, record.amount)
next.total_income = sub(next.total_income, record.amount)
}
else if (record.category_type === 0) {
group.expense = sub(group.expense, record.amount)
next.total_expense = sub(next.total_expense, record.amount)
}
// 删除记录
group.records.splice(index, 1)
}
})
// 删除空分组
next.records = next.records.filter(g => g.records.length > 0)
return next
})
// 异步请求删除 API
deleteMoneyRecordApi({ id }).catch((err) => {
console.log(err)
})
}
return (
<View
@@ -307,19 +71,7 @@ export default function Money() {
</View>
<View className="mt-3 m-4 ">
<View className="flex flex-row gap-4">
<View className="flex-1">
<Text className="text-gray-500 text-sm">余额</Text>
<Text
className="text-lg font-semibold mt-0.5"
numberOfLines={1}
ellipsizeMode="tail"
>
0.00
</Text>
</View>
<View className="flex-1">
<Text className="text-gray-500 text-sm">收入</Text>
<Text
@@ -327,7 +79,7 @@ export default function Money() {
numberOfLines={1}
ellipsizeMode="tail"
>
0.00
{moneyRecordStore.total_income}
</Text>
</View>
@@ -338,7 +90,7 @@ export default function Money() {
numberOfLines={1}
ellipsizeMode="tail"
>
0.00
{moneyRecordStore.total_expense}
</Text>
</View>
@@ -374,13 +126,15 @@ export default function Money() {
<View className="flex-1 mt-2">
<TransactionsList
records={moneyRecords.records}
records={moneyRecordStore.records}
onUpdate={(data) => {
setUpdateItem(data)
setShowAddDrawer(true)
router.navigate({
pathname: '/money/record/editor',
params: { id: data.id },
})
}}
onDelete={(id) => {
handleDeleteRecord(id)
moneyRecordStore.deleteMoneyRecord(id)
}}
/>
</View>
@@ -388,35 +142,16 @@ export default function Money() {
<Fab
size="lg"
onPress={() => {
// setShowAddDrawer(true)
router.navigate('/money/record/editor')
}}
>
<FabIcon as={AddIcon} size="xl" />
</Fab>
<AddMoneyDrawer
isOpen={showAddDrawer}
updateData={updateItem}
onClose={() => {
setShowAddDrawer(false)
setUpdateItem(null)
}}
onConfirm={(res) => {
setShowAddDrawer(false)
if (updateItem !== null) {
setUpdateItem(null)
handleUpdateMoneyRecord(res)
}
else {
handleAddMoneyRecord(res)
}
}}
/>
<ADatePicker
show={showDatePicker}
mode="month"
date={searchParams.record_time}
onConfirm={(val) => {
setSearchParams({
...searchParams,
@@ -1,30 +1,29 @@
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 { useLocalSearchParams, useRouter } from 'expo-router'
import { useEffect, 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 { insertMoneyRecordApi } from '@/request/api/money/record'
import { useMoneyCategoryStore } from '@/store/moneyCategory'
import { useMoneyRecordStore } from '@/store/moneyRecord'
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)
const moneyRecordStore = useMoneyRecordStore()
console.log(moneyCategoryStore.categories)
console.log(expense)
console.log(income)
const router = useRouter()
const expense = moneyCategoryStore.getExpenseCategories()
const income = moneyCategoryStore.getIncomeCategories()
const { id: updateId } = useLocalSearchParams<{ id: string }>()
// 选择顺序:0 支出 1收入
const [selectedIndex, setSelectedIndex] = useState(0)
// 自定义键盘
const [showKeydown, setShowKeydown] = useState(false)
// 选中分类
@@ -42,6 +41,25 @@ export default function MoenyRecordEditor() {
const [moneyRecord, setMoneyRecord] = useState<MoneyRecordBase>(defaultMoneyRecord)
useEffect(() => {
if (updateId) {
const updateData = moneyRecordStore.getMoneyRecordById(Number(updateId))
if (updateData !== null) {
setMoneyRecord({
...updateData,
record_time: toDateString(updateData.record_time),
})
setShowKeydown(true)
const moneyCategory = moneyCategoryStore.getCategoryById(updateData.category_id)
if (moneyCategory != null) {
setSelectedCategory(moneyCategory)
}
}
}
}, [])
// 保存记录
function handleSaveRecord() {
if (selectedCategory === null) {
@@ -55,7 +73,39 @@ export default function MoenyRecordEditor() {
toast.show({
title: '请输入金额',
})
return
}
const amount = moneyRecord.amount.replace(/^-+/, '')
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 (
@@ -70,6 +120,7 @@ export default function MoenyRecordEditor() {
/>
<PagerTabs
selectedIndex={selectedCategory?.type}
left={(
<TouchableOpacity className="w-[30%] flex items-center px-4 flex-row">
<LucideIcon icon="LayoutList" />
@@ -83,7 +134,7 @@ export default function MoenyRecordEditor() {
router.back()
}}
>
<Text className="ml-auto text-gray-400">取消</Text>
<Text className="ml-auto text-base text-gray-400">取消</Text>
</TouchableOpacity>
)}
routes={[
@@ -13,7 +13,7 @@ interface PagerTabsProps {
routes: PagerRoute[]
left?: React.ReactNode
right?: React.ReactNode
initialIndex?: number
selectedIndex?: number // 外部控制选中页
onChange?: (key: string) => void
indicatorWidth?: number
@@ -25,17 +25,24 @@ export default function PagerTabs({
routes,
left,
right,
initialIndex = 0,
selectedIndex,
onChange,
indicatorWidth = 38,
className,
style,
}: PagerTabsProps) {
const pagerRef = React.useRef<PagerView>(null)
const [index, setIndex] = React.useState(initialIndex)
const [index, setIndex] = React.useState(selectedIndex ?? 0)
const [tabWidth, setTabWidth] = React.useState(0)
const scrollX = React.useRef(new Animated.Value(selectedIndex ?? 0)).current
const scrollX = React.useRef(new Animated.Value(0)).current
// 外部 selectedIndex 更新时同步内部 index
React.useEffect(() => {
if (typeof selectedIndex === 'number' && selectedIndex !== index) {
setIndex(selectedIndex)
pagerRef.current?.setPage(selectedIndex)
}
}, [selectedIndex])
const onTabPress = (i: number) => {
pagerRef.current?.setPage(i)
@@ -54,7 +61,6 @@ export default function PagerTabs({
}
const tabWidthPerTab = tabWidth / routes.length
const indicatorTranslateX = scrollX.interpolate({
inputRange: routes.map((_, i) => i),
outputRange: routes.map(
@@ -66,9 +72,7 @@ export default function PagerTabs({
return (
<View
className={`${className} flex-1`}
style={[
style,
]}
style={[style]}
>
{/* Header */}
<View className="flex flex-row items-center">
@@ -86,7 +90,7 @@ export default function PagerTabs({
{routes.map((r, i) => (
<TouchableOpacity
key={r.key}
className="flex-1 items-center justify-center py-1"
className="flex-1 items-center justify-center py-2"
onPress={() => onTabPress(i)}
>
<Text
@@ -115,7 +119,7 @@ export default function PagerTabs({
<PagerView
ref={pagerRef}
style={{ flex: 1 }}
initialPage={initialIndex}
initialPage={selectedIndex ?? 0}
onPageScroll={onPageScroll}
onPageSelected={e => onPageSelected(e.nativeEvent.position)}
>
@@ -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,
@@ -7,14 +7,15 @@ import { queryMoneyCategoryApi } from '@/request/api/money/category'
interface MoneyCategoryStore {
categories: MoneyCategoryItem[]
// 通过 getter 动态计算
expense: MoneyCategoryItem[]
income: MoneyCategoryItem[]
setCategories: (categories: MoneyCategoryItem[]) => void
addCategory: (category: MoneyCategoryItem) => void
removeCategory: (id: number) => void
init: () => Promise<void>
getExpenseCategories: () => MoneyCategoryItem[]
getIncomeCategories: () => MoneyCategoryItem[]
getCategoryById: (id: number) => MoneyCategoryItem | null
}
export const useMoneyCategoryStore = create<MoneyCategoryStore>()(
@@ -22,34 +23,32 @@ export const useMoneyCategoryStore = create<MoneyCategoryStore>()(
(set, get) => ({
categories: [],
// getter 样式属性,通过 get() 动态计算
get expense() {
return get().categories.filter(c => c.type === 0)
},
get income() {
return get().categories.filter(c => c.type === 1)
},
setCategories: categories => set({ categories }),
addCategory: category =>
set(state => ({ categories: [...state.categories, category] })),
set({ categories: [...get().categories, category] }),
removeCategory: id =>
set(state => ({ categories: state.categories.filter(c => c.id !== id) })),
set({ categories: get().categories.filter(c => c.id !== id) }),
init: async () => {
try {
const res = await queryMoneyCategoryApi()
if (res.isSuccess) {
const categories = res.data || []
set({ categories })
set({ categories: res.data || [] })
}
}
catch (error) {
console.error('Failed to fetch categories:', error)
catch (e) {
console.error('Fetch category failed:', e)
}
},
getExpenseCategories: () => (get().categories || []).filter(c => c.type === 0),
getIncomeCategories: () => (get().categories || []).filter(c => c.type === 1),
getCategoryById: (id: number) => {
return get().categories.find(c => c.id === id) || null
},
}),
{
name: 'money-category-storage',
@@ -1,3 +1,172 @@
import type { MoneyRecordGroup, MoneyRecordItem } from '@/types/money/record'
import { create } from 'zustand'
import { MoneyCategoryItem } from '@/types/money/category'
import { MoneyRecordGroup, MoneyRecordItem } from '@/types/money/record'
import { deleteMoneyRecordApi } from '@/request/api/money/record'
import { add, sub } from '@/utils/math'
import { isInMonth } from '@/utils/time'
interface MoneyRecordStore {
records: MoneyRecordGroup[]
currentMonth: string
total_income: string
total_expense: string
setRecords: (records: MoneyRecordGroup[], total_expense: string, total_income: string, month: string) => void
setCurrentMonth: (month: string) => void
addMoneyRecord: (data: MoneyRecordItem) => void
updateMoneyRecord: (updated: MoneyRecordItem) => void
deleteMoneyRecord: (id: number) => void
getMoneyRecordById: (id: number) => MoneyRecordItem | null
}
export const useMoneyRecordStore = create<MoneyRecordStore>((set, get) => ({
records: [],
currentMonth: '',
total_income: '0.00',
total_expense: '0.00',
setRecords: (records, total_expense, total_income, month) => {
set({ records, currentMonth: month, total_income, total_expense })
},
setCurrentMonth: month => set({ currentMonth: month }),
addMoneyRecord: (data) => {
set((prev) => {
if (!isInMonth(data.record_time, prev.currentMonth))
return prev
const records = [...prev.records]
const groupIndex = records.findIndex(g => g.record_time === data.record_time)
const amount = data.amount
if (groupIndex !== -1) {
const group = { ...records[groupIndex], records: [data, ...records[groupIndex].records] }
if (data.category_type === 0)
group.expense = add(group.expense, amount)
if (data.category_type === 1)
group.income = add(group.income, amount)
records[groupIndex] = group
}
else {
const newGroup: MoneyRecordGroup = {
record_time: data.record_time,
records: [data],
expense: data.category_type === 0 ? amount : '0.00',
income: data.category_type === 1 ? amount : '0.00',
}
const insertIndex = records.findIndex(g => g.record_time < data.record_time)
if (insertIndex === -1)
records.push(newGroup)
else records.splice(insertIndex, 0, newGroup)
}
return {
...prev,
records,
total_expense: data.category_type === 0 ? add(prev.total_expense, amount) : prev.total_expense,
total_income: data.category_type === 1 ? add(prev.total_income, amount) : prev.total_income,
}
})
},
updateMoneyRecord: (updated) => {
set((prev) => {
const records = [...prev.records]
const oldGroupIndex = records.findIndex(g => g.records.some(r => r.id === updated.id))
if (oldGroupIndex === -1)
return prev
const oldGroup = { ...records[oldGroupIndex], records: [...records[oldGroupIndex].records] }
const oldRecordIndex = oldGroup.records.findIndex(r => r.id === updated.id)
const oldRecord = oldGroup.records[oldRecordIndex]
const oldAmount = oldRecord.amount
const newAmount = updated.amount
// 移除旧记录
oldGroup.records.splice(oldRecordIndex, 1)
let total_expense = prev.total_expense
let total_income = prev.total_income
if (oldRecord.category_type === 0)
total_expense = sub(total_expense, oldAmount)
if (oldRecord.category_type === 1)
total_income = sub(total_income, oldAmount)
if (oldGroup.records.length === 0)
records.splice(oldGroupIndex, 1)
else records[oldGroupIndex] = oldGroup
if (!isInMonth(updated.record_time, prev.currentMonth)) {
return { ...prev, records, total_expense, total_income }
}
// 添加到新分组
const newGroupIndex = records.findIndex(g => g.record_time === updated.record_time)
if (newGroupIndex !== -1) {
const group = { ...records[newGroupIndex], records: [updated, ...records[newGroupIndex].records] }
if (updated.category_type === 0)
group.expense = add(group.expense, newAmount)
if (updated.category_type === 1)
group.income = add(group.income, newAmount)
records[newGroupIndex] = group
}
else {
const newGroup: MoneyRecordGroup = {
record_time: updated.record_time,
records: [updated],
expense: updated.category_type === 0 ? newAmount : '0.00',
income: updated.category_type === 1 ? newAmount : '0.00',
}
const insertIndex = records.findIndex(g => g.record_time < updated.record_time)
if (insertIndex === -1)
records.push(newGroup)
else records.splice(insertIndex, 0, newGroup)
}
if (updated.category_type === 0)
total_expense = add(total_expense, newAmount)
if (updated.category_type === 1)
total_income = add(total_income, newAmount)
return { ...prev, records, total_expense, total_income }
})
},
deleteMoneyRecord: (id) => {
set((prev) => {
const records = prev.records.map(g => ({ ...g, records: [...g.records] }))
let total_expense = prev.total_expense
let total_income = prev.total_income
records.forEach((group) => {
const index = group.records.findIndex(r => r.id === id)
if (index !== -1) {
const record = group.records[index]
if (record.category_type === 0)
total_expense = sub(total_expense, record.amount)
if (record.category_type === 1)
total_income = sub(total_income, record.amount)
group.records.splice(index, 1)
}
})
const newRecords = records.filter(g => g.records.length > 0)
return { ...prev, records: newRecords, total_expense, total_income }
})
deleteMoneyRecordApi({ id }).catch(console.error)
},
getMoneyRecordById: (id: number) => {
const records = get().records
for (const group of records) {
const record = group.records.find(r => r.id === id)
if (record)
return record
}
return null
},
}))