This commit is contained in:
oneao committed 2026-03-04 17:30:05 +08:00
1 parent 46106d0c76
commit 34240d2e16
36 files changed
+2853 -165

No files matched your search

@@ -3,7 +3,6 @@ import type { NavItem } from '@/store/navigation'
import { Slot, usePathname, useRouter } from 'expo-router'
import { useCallback, useEffect, useState } from 'react'
import { Text, TouchableOpacity, View } from 'react-native'
import { KeyboardAvoidingView } from 'react-native-keyboard-controller'
import Animated, { useAnimatedRef } from 'react-native-reanimated'
import Sortable from 'react-native-sortables'
import { ADrawer, LucideIcon } from '@/components/common/ao'
@@ -72,14 +71,13 @@ export default function TabsLayout() {
return (
<View className="flex-1 bg-gray-100">
<View className="flex-1 bg-white">
<KeyboardAvoidingView
behavior="padding"
<View
style={{
flex: 1,
}}
>
<Slot />
</KeyboardAvoidingView>
</View>
</View>
<View
@@ -1,3 +1,4 @@
import { useNavigation } from 'expo-router'
import React, { useState } from 'react'
import { Text, View } from 'react-native'
import { AButton, AModal } from '@/components/common/ao'
@@ -7,6 +8,9 @@ import { useSpaceStore } from '@/store/space'
export default function TestScreen() {
const [showDissolve, setShowDissolve] = useState(false)
const spaceStore = useSpaceStore()
const navigation = useNavigation()
console.log(navigation.getState()?.routes)
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
@@ -1,4 +1,4 @@
import type { MoneyCategoryItem } from '@/types/money/category'
import type { FinanceCategory } from '@/types/finance/category'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AInput, LucideIcon } from '@/components/common/ao'
import EmptyState from '@/components/common/EmptyStatus'
@@ -16,15 +16,15 @@ import {
interface CategorySection {
title: string
data: MoneyCategoryItem[]
data: FinanceCategory[]
}
interface CategorySheetProps {
open?: boolean
moneyCategories?: MoneyCategoryItem[]
moneyCategories?: FinanceCategory[]
value?: number | null
onClose?: () => void
onSelect?: (category: MoneyCategoryItem) => void
onSelect?: (category: FinanceCategory) => void
}
export default function CategorySheet({
@@ -45,7 +45,7 @@ export default function CategorySheet({
} | null>(null)
const handleSelect = useCallback(
(item: MoneyCategoryItem) => {
(item: FinanceCategory) => {
onSelect?.(item)
onClose?.()
},
@@ -56,7 +56,7 @@ export default function CategorySheet({
id: number,
name: string,
type: number,
): MoneyCategoryItem {
): FinanceCategory {
return {
id,
name,
@@ -188,9 +188,9 @@ export default function CategorySheet({
}, 0)
}}
keyExtractor={(item, index) =>
String((item as MoneyCategoryItem).id ?? index)}
String((item as FinanceCategory).id ?? index)}
renderItem={({ item }) => {
const category = item as MoneyCategoryItem
const category = item as FinanceCategory
const selected = value === category.id
return (
@@ -1,7 +1,6 @@
import type { MoneyRecord, MoneyRecordGroup } from '@/types/money/record'
import type { FinanceRecord, FinanceRecordGroup } from '@/types/finance/record'
import React, { useCallback, useState } from 'react'
import { Pressable, RefreshControl, SectionList, Text, Vibration, View } from 'react-native'
import { LucideIcon } from '@/components/common/ao'
import EmptyState from '@/components/common/EmptyStatus'
import MoneyCategoryIcon from '@/components/common/MoneyCategoryIcon'
@@ -11,8 +10,8 @@ import { useUserStore } from '@/store/user'
import { MoneyCategoryEnum } from '@/types/money/category'
interface StaticTransactionsProps {
records: MoneyRecordGroup[]
onUpdate?: (data: MoneyRecord) => void
records: FinanceRecordGroup[]
onUpdate?: (data: FinanceRecord) => void
onDelete?: (id: number) => void
onDetail?: (id: number) => void
onRefreshData?: () => void
@@ -163,9 +162,9 @@ export default function StaticTransactions({
{item.remark ? item.remark : category?.name}
</Text>
{
item.user_id !== userID && (
item.member_id !== userID && (
<Text className="text-gray-500 text-xs italic">
{item.username}
{item.member_name}
</Text>
)
}
@@ -186,7 +185,7 @@ export default function StaticTransactions({
}}
>
{
userID === item.user_id && (
userID === item.member_id && (
<>
<MenuItem
key="update"
@@ -1,24 +1,25 @@
import type { DateMorePickerMode } from '@/components/common/ao/DateMorePicker'
import { useFocusEffect, useRouter } from 'expo-router'
import React, { useCallback, useState } from 'react'
import React, { useCallback, useEffect, useState } from 'react'
import { Image, Text, TouchableOpacity, View } from 'react-native'
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
import { ADateMorePicker, ADatePicker, ADrawer, AListItem, LucideIcon } from '@/components/common/ao'
import { resolveDateRange } from '@/components/common/ao/DateMorePicker'
import { Fab, FabIcon } from '@/components/ui/fab'
import { AddIcon } from '@/components/ui/icon'
import { listFinanceRecordsApi } from '@/request/api/finance/record'
import { deleteMoneyRecordApi, listMoneyRecordsApi } from '@/request/api/money/record'
import { useMoneyCategoryStore } from '@/store/moneyCategory'
import { useMoneyRecordStore } from '@/store/moneyRecord'
import { useFinanceStore } from '@/store/finance'
import { getCurrentYearMonth, normalizeDateTime } from '@/utils/time'
import { toast } from '@/utils/toast'
import CategorySheet from './components/CategorySheet'
import TransactionsList from './components/TransactionsList'
export default function Money() {
const moneyRecordStore = useMoneyRecordStore()
const moneyCategoryStore = useMoneyCategoryStore()
const router = useRouter()
const financeStore = useFinanceStore()
// 安全高度
const insets = useSafeAreaInsets()
@@ -37,17 +38,16 @@ export default function Money() {
// 操作菜单
const [showActionsMenu, setShowActionsMenu] = useState(false)
function fetchMoneyRecords() {
console.log(searchParams)
function fetchFinanceRecords() {
const resolveDate = resolveDateRange(dateMode, searchParams.startTime)
listMoneyRecordsApi({
listFinanceRecordsApi({
start_time: resolveDate.startTime,
end_time: resolveDate.endTime,
category_id: 0,
}).then((res) => {
if (res.isSuccess) {
moneyRecordStore.setRecords(res.data.records || [])
financeStore.setRecords(res.data.records || [])
}
else {
toast.show({
@@ -58,9 +58,11 @@ export default function Money() {
console.log(err)
})
}
useFocusEffect(
useCallback(() => {
fetchMoneyRecords()
financeStore.ensureCategoriesLoaded()
fetchFinanceRecords()
}, [searchParams.startTime, searchParams.endTime]),
)
@@ -69,7 +71,7 @@ export default function Money() {
id,
})
fetchMoneyRecords()
fetchFinanceRecords()
}
function timeModeTip() {
@@ -147,7 +149,7 @@ export default function Money() {
numberOfLines={1}
ellipsizeMode="tail"
>
{moneyRecordStore.getTotalIncome()}
{financeStore.getTotalIncome()}
</Text>
</View>
@@ -158,7 +160,7 @@ export default function Money() {
numberOfLines={1}
ellipsizeMode="tail"
>
{moneyRecordStore.getTotalExpense()}
{financeStore.getTotalExpense()}
</Text>
</View>
@@ -169,7 +171,7 @@ export default function Money() {
numberOfLines={1}
ellipsizeMode="tail"
>
{moneyRecordStore.getTotalBalance()}
{financeStore.getTotalBalance()}
</Text>
</View>
</View>
@@ -178,16 +180,16 @@ export default function Money() {
<View className="flex-1 mt-2">
<TransactionsList
records={moneyRecordStore.records}
records={financeStore.records}
onUpdate={(data) => {
router.navigate({
pathname: '/money/record/editor',
router.push({
pathname: '/finance/record/editor',
params: { id: data.id },
})
}}
onDetail={(id) => {
router.navigate({
pathname: '/money/record/detail',
pathname: '/finance/record/detail',
params: { id },
})
}}
@@ -195,7 +197,7 @@ export default function Money() {
handleDeleteRecord(id)
}}
onRefreshData={() => {
fetchMoneyRecords()
fetchFinanceRecords()
}}
/>
</View>
@@ -203,7 +205,7 @@ export default function Money() {
<Fab
size="lg"
onPress={() => {
router.navigate('/money/record/editor')
router.navigate('/finance/record/editor')
}}
>
<FabIcon as={AddIcon} size="xl" />
@@ -247,7 +249,7 @@ export default function Money() {
<CategorySheet
open={showCategorySheet}
value={searchParams.category}
moneyCategories={moneyCategoryStore.categories}
moneyCategories={financeStore.categories}
onClose={() => {
setShowCategorySheet(false)
}}
@@ -283,7 +285,8 @@ export default function Money() {
icon="LayoutGrid"
text="类别设置"
onPress={() => {
router.navigate('/money/category/setting')
// router.navigate('/money/category/setting')
router.push('/finance/category')
setShowActionsMenu(false)
}}
/>
@@ -26,21 +26,21 @@ export default function Index() {
{/* <AButton
className="w-full"
onPress={() => ('/money')}
onPress={() => ('/finance')}
>
开始使用
</AButton> */}
<View className="flex flex-row gap-3 mt-4">
<AButton
className="flex-1"
onPress={() => ('/money')}
onPress={() => ('/finance')}
>
创建空间
</AButton>
<AButton
className="flex-1"
variant="outline"
onPress={() => ('/money')}
onPress={() => ('/finance')}
>
加入空间
</AButton>
@@ -0,0 +1,197 @@
import type { LegendListRef } from '@legendapp/list'
import type { FinanceCategory } from '@/types/finance/category'
import { LegendList } from '@legendapp/list'
import { useEffect, useRef, useState } from 'react'
import { Pressable, Text, View } from 'react-native'
import { AButton, AInput } from '@/components/common/ao'
import MoneyCategoryIcon from '@/components/common/MoneyCategoryIcon'
import {
Actionsheet,
ActionsheetBackdrop,
ActionsheetContent,
ActionsheetDragIndicator,
ActionsheetDragIndicatorWrapper,
} from '@/components/ui/actionsheet'
import { useFinanceStore } from '@/store/finance'
import { FinanceCategoryEnum } from '@/types/finance/category'
export interface EditCategoryForm {
id?: number
name: string
icon: string
}
interface EditCategoryProps {
open: boolean
onClose?: () => void
type: FinanceCategoryEnum
category: FinanceCategory | null
onConfirm?: (form: EditCategoryForm) => Promise<void>
}
export default function EditCategory({
open,
onClose,
type,
onConfirm,
category,
}: EditCategoryProps) {
const financeStore = useFinanceStore()
const [isOpen, setIsOpen] = useState(open)
const [form, setForm] = useState<EditCategoryForm>({ icon: '', name: '' })
const [confirmDisabled, setConfirmDisabled] = useState(true)
const [confirmLoading, setConfirmLoading] = useState(false)
const listRef = useRef<LegendListRef>(null)
useEffect(() => {
setIsOpen(open)
if (!open)
setForm({ icon: '', name: '' })
}, [open])
// 确认按钮状态
useEffect(() => {
setConfirmDisabled(!(form.name && form.name.length <= 5 && form.icon))
}, [form.name, form.icon])
useEffect(() => {
if (!form.icon || !listRef.current) {
return
}
const index = financeStore.getDefaultGroupRow(form.icon)
if (index !== -1) {
listRef.current.scrollToIndex({
index,
viewPosition: 0.5,
animated: true,
})
}
}, [form.icon])
// 编辑已有分类时填充数据
useEffect(() => {
if (category)
setForm(category)
}, [category])
function handleClose() {
setForm({ icon: '', name: '' })
setIsOpen(false)
onClose?.()
}
return (
<Actionsheet
isOpen={isOpen}
onClose={handleClose}
>
<ActionsheetBackdrop />
<ActionsheetContent style={{
padding: 0,
}}
>
<ActionsheetDragIndicatorWrapper>
<ActionsheetDragIndicator />
</ActionsheetDragIndicatorWrapper>
<View>
<View className="flex flex-row items-center justify-between w-full p-4">
<AButton size="xs" variant="outline" onPress={handleClose}>
取消
</AButton>
<Text className="text-lg font-medium">
{type === FinanceCategoryEnum.Expense ? '添加支出分类' : '添加收入分类'}
</Text>
<AButton
size="xs"
disabled={confirmDisabled}
loading={confirmLoading}
onPress={async () => {
if (!onConfirm)
return
try {
setConfirmLoading(true)
await onConfirm({ ...form, name: form.name.trim() })
}
finally {
setConfirmLoading(false)
}
}}
>
确认
</AButton>
</View>
<View>
<AInput
className="mx-4 mb-4"
placeholder="分类名称"
value={form.name}
onChangeText={text => setForm(prevForm => ({ ...prevForm, name: text }))}
/>
<LegendList
ref={listRef}
data={financeStore.DefaultGroupCategories}
extraData={form.icon}
keyExtractor={item => String(item.index)}
recycleItems
initialContainerPoolRatio={20}
style={{
maxHeight: 400,
}}
initialScrollIndex={financeStore.getDefaultGroupRow(category?.icon || '')}
renderItem={({ item }) => (
<View className="mb-1" key={item.index}>
{
item.type === 'title'
? (
<Text className="text-center text-sm mb-3">{item.value}</Text>
)
: (
<View className="flex flex-row flex-wrap">
{item.value.map((icon) => {
const selected = form.icon === icon
return (
<Pressable
key={icon}
className="w-1/5 mb-3 flex items-center"
onPress={() => {
setForm(prevForm => ({
...prevForm,
icon,
}))
}}
>
<View
style={{
width: 46,
height: 46,
borderRadius: 28,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: selected ? '#333' : '#F3F4F6',
}}
>
<MoneyCategoryIcon
name={icon}
size={32}
color={selected ? '#fff' : '#000'}
/>
</View>
</Pressable>
)
})}
</View>
)
}
</View>
)}
/>
</View>
</View>
</ActionsheetContent>
</Actionsheet>
)
}
@@ -0,0 +1,303 @@
import type { SortableGridRenderItem } from 'react-native-sortables'
import type { EditCategoryForm } from './components/EditCategory'
import type { FinanceCategory } from '@/types/finance/category'
import { useCallback, useEffect, useState } from 'react'
import { Pressable, Text, Vibration, View } from 'react-native'
import Animated, { useAnimatedRef } from 'react-native-reanimated'
import Sortable from 'react-native-sortables'
import { toast } from 'sonner-native'
import { AButton, ATab, LucideIcon } from '@/components/common/ao'
import MoneyCategoryIcon from '@/components/common/MoneyCategoryIcon'
import CustomHeader from '@/components/custom/Header'
import { Spinner } from '@/components/ui/spinner'
import { createFinanceCategoryApi, deleteFinanceCategoryApi, updateFinanceCategoryApi } from '@/request/api/finance/category'
import { useFinanceStore } from '@/store/finance'
import { useMoneyCategoryStore } from '@/store/moneyCategory'
import { useSpaceStore } from '@/store/space'
import { FinanceCategoryEnum } from '@/types/finance/category'
import EditCategory from './components/EditCategory'
export default function MoneyCategorySetting() {
const spaceStore = useSpaceStore()
const financeStore = useFinanceStore()
const moneyCategoryStore = useMoneyCategoryStore()
const [selectedTab, setSelectedTab] = useState(FinanceCategoryEnum.Expense)
const scrollableRef = useAnimatedRef<Animated.ScrollView>()
const [categories, setCategories] = useState<FinanceCategory[]>([])
const [draggingId, setDraggingId] = useState<string>('')
const [draggingDeleteZone, setDraggingDeleteZone] = useState<boolean>(false)
const [deleteTimer, setDeleteTimer] = useState<number | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false)
const [showEditCategory, setshowEditCategory] = useState<boolean>(false)
const [selectedCategory, setSelectedCategory] = useState<FinanceCategory | null>(null)
useEffect(() => {
setCategories(selectedTab === FinanceCategoryEnum.Expense ? financeStore.getExpenseCategories() : financeStore.getIncomeCategories())
}, [selectedTab, financeStore.categories])
const renderItem = useCallback<SortableGridRenderItem<FinanceCategory>>(
({ item }) => (
<Pressable
onPress={() => {
setSelectedCategory(item)
setshowEditCategory(true)
}}
className="flex flex-col items-center justify-center"
>
<View
style={{
width: 46,
height: 46,
borderRadius: 28,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#F3F4F6',
}}
>
<MoneyCategoryIcon size={28} name={item.icon} />
</View>
<Text
style={{
marginTop: 4,
fontSize: 14,
}}
>
{item.name}
</Text>
</Pressable>
),
[],
)
async function handleConfirmEdit(form: EditCategoryForm) {
if (form.name === '') {
toast.warning('分类名称不能为空')
return
}
if (form.icon === '') {
toast.warning('分类图标不能为空')
return
}
const isDuplicate = moneyCategoryStore.isDuplicateCategory(
form.name,
selectedTab,
form.id ?? null,
)
if (isDuplicate) {
toast.warning('类别不能重复')
return
}
if (form.id != null) {
const currentCategory = financeStore.getCategoryById(form.id)
if (currentCategory === null) {
toast.warning('分类无效')
return
}
await updateFinanceCategoryApi({
id: form.id,
name: form.name,
icon: form.icon,
}).then((res) => {
if (res.isSuccess) {
currentCategory.name = form.name
currentCategory.icon = form.icon
financeStore.updateCategory(currentCategory)
}
})
}
else {
const nextSortNumber = financeStore.getCategoryNextSortNumber(selectedTab)
await createFinanceCategoryApi({
name: form.name,
icon: form.icon,
sort_number: nextSortNumber,
type: selectedTab,
}).then((res) => {
if (res.isSuccess) {
const newCategory = {
id: res.data.id,
name: form.name,
icon: form.icon,
sort_number: nextSortNumber,
type: selectedTab,
space_id: spaceStore.currentSpace?.space_id || 0,
}
financeStore.createCategory(newCategory)
}
})
scrollableRef.current?.scrollToEnd({ animated: true })
}
setshowEditCategory(false)
}
return (
<View className="flex-1">
<CustomHeader
title="记账类别"
/>
<View className="flex-1">
<View className="pt-2 mb-4 px-3 flex flex-row items-center justify-between">
<ATab
defaultKey={selectedTab}
onChange={(key) => {
setSelectedTab(key as FinanceCategoryEnum)
}}
data={[
{
key: FinanceCategoryEnum.Expense,
label: '支出',
},
{
key: FinanceCategoryEnum.Income,
label: '收入',
},
]}
/>
<AButton
className="rounded-full"
size="sm"
leftIcon={<LucideIcon icon="Plus" color="#fff" />}
onPress={(() =>
setshowEditCategory(true)
)}
>
新增
</AButton>
</View>
<View className="flex-1">
<Sortable.MultiZoneProvider>
<Sortable.PortalProvider enabled>
<Animated.ScrollView
ref={scrollableRef}
>
<Sortable.Grid
columnGap={10}
rowGap={10}
columns={4}
data={categories}
renderItem={renderItem}
scrollableRef={scrollableRef}
autoScrollActivationOffset={200}
showDropIndicator
onDragStart={(data) => {
Vibration.vibrate(50)
setDraggingId(data.key)
}}
onDragEnd={(dragData) => {
setDraggingId('')
if (draggingDeleteZone)
return
const newData = dragData.data
if (JSON.stringify(categories) !== JSON.stringify(newData)) {
moneyCategoryStore.updateSortNumbers(newData, selectedTab)
}
}}
/>
</Animated.ScrollView>
<Sortable.BaseZone
onItemEnter={() => {
if (!draggingId)
return
setDraggingDeleteZone(true)
Vibration.vibrate(50)
const timer = setTimeout(async () => {
const deleteId = Number(draggingId)
setDeleteLoading(true)
try {
const res = await deleteFinanceCategoryApi({ id: deleteId })
if (!res.isSuccess) {
throw new Error(res.message)
}
financeStore.deleteCategory(deleteId)
setDraggingId('')
setDraggingDeleteZone(false)
}
finally {
setDeleteLoading(false)
setDeleteTimer(null)
}
}, 1000)
setDeleteTimer(timer)
}}
onItemLeave={() => {
setDraggingDeleteZone(false)
if (deleteTimer) {
clearTimeout(deleteTimer)
setDeleteTimer(null)
}
}}
onItemDrop={() => {
setDraggingDeleteZone(false)
if (deleteTimer) {
clearTimeout(deleteTimer)
setDeleteTimer(null)
}
}}
minActivationDistance={40}
>
<View
className={`flex flex-row items-center justify-center h-14 gap-2 ${
draggingDeleteZone ? 'bg-red-600' : 'bg-red-400'
}`}
>
{
deleteLoading
? (
<Spinner color="white" />
)
: (
<LucideIcon icon="Trash" size={18} color="#fff" />
)
}
<Text className="text-base text-white ">
{draggingDeleteZone ? '松手删除(将删除该类下所有记录)' : '拖到这里删除'}
</Text>
</View>
</Sortable.BaseZone>
</Sortable.PortalProvider>
</Sortable.MultiZoneProvider>
</View>
</View>
<EditCategory
type={selectedTab}
open={showEditCategory}
onClose={() => {
setshowEditCategory(false)
setSelectedCategory(null)
}}
category={selectedCategory}
onConfirm={handleConfirmEdit}
/>
</View>
)
}
@@ -0,0 +1,367 @@
import Big from 'big.js'
import { useEffect, useState } from 'react'
import { Keyboard, Pressable, StyleSheet, Text, TouchableWithoutFeedback, View } from 'react-native'
import { ADatePicker, AInput, LucideIcon } from '@/components/common/ao/index'
import { useKeyboardHeight } from '@/hooks/useKeyboardHeight'
import { getCurrentDate } from '@/utils/time'
interface KeydownItem {
key: string | number
content: React.ReactNode
}
const keys: KeydownItem[] = [
{ key: '1', content: <Text className="text-2xl">1</Text> },
{ key: '2', content: <Text className="text-2xl">2</Text> },
{ key: '3', content: <Text className="text-2xl">3</Text> },
{ key: 'del', content: <LucideIcon icon="Delete" size={22} strokeWidth={1.5} clickable={false} /> },
{ key: '4', content: <Text className="text-2xl">4</Text> },
{ key: '5', content: <Text className="text-2xl">5</Text> },
{ key: '6', content: <Text className="text-2xl">6</Text> },
{ key: '+', content: <LucideIcon icon="Plus" size={22} strokeWidth={1.5} clickable={false} /> },
{ key: '7', content: <Text className="text-2xl">7</Text> },
{ key: '8', content: <Text className="text-2xl">8</Text> },
{ key: '9', content: <Text className="text-2xl">9</Text> },
{ key: '-', content: <LucideIcon icon="Minus" size={22} strokeWidth={1.5} clickable={false} /> },
{ key: 'clear', content: (
<View className="w-full h-full flex items-center justify-center">
<Text className="text-xl text-gray-600">重置</Text>
</View>
) },
{ key: '0', content: <Text className="text-2xl">0</Text> },
{ key: '.', content: <LucideIcon icon="Dot" size={22} strokeWidth={1.5} clickable={false} /> },
{ key: '/', content: (
<View className="w-full h-full flex items-center justify-center">
<Text className="text-2xl">完成</Text>
</View>
) },
]
interface AmountKeyboardProps {
show?: boolean
record_time?: string
amount?: string
remark?: string
// 回调:当键盘输入或修改数据时触发
onChange?: (data: { record_time: string, amount: string, remark: string }) => void
// 可选:点击完成按钮触发
onDone?: () => void
}
export default function AmountKeyboard({
show = false,
record_time = '',
amount = '',
remark = '',
onChange,
onDone,
}: AmountKeyboardProps) {
// 自定义键盘计算过程
const [keydownInput, setKeydownInput] = useState('')
// 日期选择
const [showDatePicker, setShowDatePicker] = useState(false)
// 备注聚焦
const [remarkIsFocused, setRemarkIsFocused] = useState(false)
// 键盘高度
const keyboardHeight = useKeyboardHeight()
// 使用一个对象管理三个字段
const [internalData, setInternalData] = useState({
record_time: record_time || getCurrentDate(),
amount: amount || '0',
remark: remark || '',
})
// 如果父组件 props 变化,需要同步
useEffect(() => {
setInternalData({
record_time: record_time || getCurrentDate(),
amount: amount || '0',
remark: remark || '',
})
}, [record_time, amount, remark])
useEffect(() => {
if (!keydownInput) {
handleChange({ amount: '0' })
return
}
try {
const computed = calculateWithBig(keydownInput)
handleChange({ amount: computed })
}
catch {
handleChange({ amount: '0' })
}
}, [keydownInput])
const handleChange = (newData: Partial<typeof internalData>) => {
const updated = { ...internalData, ...newData }
setInternalData(updated)
onChange?.(updated)
}
// 计算过程
const handlePress = (key: string | number) => {
setKeydownInput((prev) => {
let newInput = prev
if (key === 'del') {
newInput = prev.slice(0, -1)
}
else if (key === 'clear') {
setInternalData({
record_time: getCurrentDate(),
amount: '0',
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
}
}
return newInput
})
}
function calculateWithBig(expression: string): string {
if (!expression.trim())
return '0'
// 移除表达式末尾的运算符(+或-),避免不完整的表达式
let cleanExpression = expression.trim()
const lastChar = cleanExpression.slice(-1)
if (lastChar === '+' || lastChar === '-') {
cleanExpression = cleanExpression.slice(0, -1)
}
if (!cleanExpression)
return '0'
try {
// 使用更智能的解析方式,支持基本的加减运算
const numbers = cleanExpression.split(/[+-]/).filter(Boolean)
const operators = cleanExpression.split(/[^+-]/).filter(Boolean)
if (numbers.length === 0)
return '0'
let result = new Big(numbers[0])
for (let i = 0; i < operators.length; i++) {
const operator = operators[i]
const nextNumber = new Big(numbers[i + 1])
if (operator === '+') {
result = result.plus(nextNumber)
}
else if (operator === '-') {
result = result.minus(nextNumber)
}
}
// 限制结果为最多两位小数
return result.toFixed(2)
}
catch (error) {
// 如果解析失败,尝试只计算数字部分
const tokens = expression.match(/[+-]?\d+(\.\d+)?/g)
if (!tokens || tokens.length === 0)
return '0'
let total = new Big(0)
tokens.forEach((token) => {
total = total.plus(new Big(token))
})
// 限制结果为最多两位小数
return total.toFixed(2)
}
}
if (!show) {
return null
}
return (
<View style={{
backgroundColor: '#fff',
elevation: 20,
}}
>
<TouchableWithoutFeedback onPress={Keyboard.dismiss}>
<View className="h-28 flex pl-4 pr-4 justify-center">
<View className="flex flex-row items-center justify-between">
<Pressable
className="flex flex-row items-center gap-1.5 px-2 py-1.5 rounded-md bg-gray-50 active:opacity-70"
onPress={() => setShowDatePicker(true)}
>
<LucideIcon icon="Calendar" size={16} color="#60a5fa" clickable={false} />
<Text className="text-gray-600 text-sm">{internalData.record_time}</Text>
<LucideIcon icon="ChevronDown" size={12} color="#aaa" clickable={false} />
</Pressable>
<Text className="text-3xl text-blue-700 text-right">{internalData.amount}</Text>
</View>
<Text className="text-gray-400 text-right">{keydownInput}</Text>
<AInput
value={internalData.remark}
placeholder="备注"
className="bg-[#f2f3f5] border-0"
size="sm"
onChangeText={val => handleChange({ remark: val })}
onFocus={() => setRemarkIsFocused(true)}
onBlur={() => setRemarkIsFocused(false)}
/>
</View>
</TouchableWithoutFeedback>
<View className="flex flex-row flex-wrap" style={{ height: 220 }}>
{keys.map((item, idx) => {
const isFirstCol = idx % 4 === 0
const isFirstRow = idx < 4
const isLastCol = (idx + 1) % 4 === 0
const isLastRow = idx >= 12
return (
<Pressable
key={item.key}
onPress={() => handlePress(item.key)}
style={{
width: '25%',
height: '25%',
justifyContent: 'center',
alignItems: 'center',
borderTopWidth: isFirstRow ? 0.3 : 0,
borderLeftWidth: isFirstCol ? 0.3 : 0,
borderRightWidth: !isLastCol ? 0.3 : 0,
borderBottomWidth: !isLastRow ? 0.3 : 0,
borderColor: '#ddd',
}}
>
{({ pressed }) => {
const defaultBg = item.key === '/' ? '#e6d7b8' : '#fff'
const pressedBg = item.key === '/' ? '#f5e9d4' : '#f2f2f2'
return (
<>
<View
style={{
...StyleSheet.absoluteFillObject,
backgroundColor: pressed ? pressedBg : defaultBg,
}}
/>
{item.content}
</>
)
}}
</Pressable>
)
})}
</View>
{remarkIsFocused && keyboardHeight > 220 && (
<View style={{ height: keyboardHeight + 10 - 220 }} />
)}
<ADatePicker
pickerTitle="记账时间"
mode="date"
date={internalData.record_time}
show={showDatePicker}
onClose={() => {
setShowDatePicker(false)
}}
onConfirm={(date) => {
setShowDatePicker(false)
handleChange({ record_time: date })
}}
/>
</View>
)
}
@@ -0,0 +1,96 @@
import type { ViewStyle } from 'react-native'
import type { FinanceCategory } from '@/types/finance/category'
import React, { useEffect, useRef } from 'react'
import { FlatList, Pressable, Text, View } from 'react-native'
import MoneyCategoryIcon from '@/components/common/MoneyCategoryIcon'
interface CategoryGridProps {
data: FinanceCategory[]
selectedCategory?: FinanceCategory | null
onSelectCategory?: (item: FinanceCategory | null) => void
className?: string
style?: ViewStyle
}
export default function CategoryGrid({
data,
selectedCategory,
onSelectCategory,
className,
style,
}: CategoryGridProps) {
const NUM_COLUMNS = 4
const flatListRef = useRef<FlatList<any>>(null)
// 自动滚动到选中行
useEffect(() => {
if (!selectedCategory || !flatListRef.current)
return
const itemIndex = data.findIndex(item => item.id === selectedCategory.id)
if (itemIndex === -1 || !flatListRef.current)
return
setTimeout(() => {
if (flatListRef.current) {
flatListRef.current.scrollToIndex({
index: Math.floor(itemIndex / 4),
animated: true,
viewPosition: 0.5,
})
}
}, 0)
}, [selectedCategory])
return (
<FlatList
ref={flatListRef}
data={data}
numColumns={NUM_COLUMNS}
keyExtractor={item => item.id.toString()}
showsVerticalScrollIndicator={false}
className={`${className}`}
style={[style]}
getItemLayout={(data, index) => {
return { length: 80, offset: 80 * index, index }
}}
renderItem={({ item }) => {
const isSelected = selectedCategory?.id === item.id
return (
<Pressable
style={{
width: `${100 / NUM_COLUMNS}%`,
height: 80,
alignItems: 'center',
}}
onPress={() => onSelectCategory?.(item)}
>
<View
style={{
width: 46,
height: 46,
borderRadius: 28,
marginTop: 4,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: isSelected ? '#333' : '#F3F4F6',
}}
>
<MoneyCategoryIcon
name={item.icon}
size={28}
color={isSelected ? '#fff' : '#000'}
/>
</View>
<Text style={{ marginTop: 4, fontSize: 14, textAlign: 'center' }}>
{item.name}
</Text>
</Pressable>
)
}}
/>
)
}
@@ -0,0 +1,188 @@
import type { FinanceCategory } from '@/types/finance/category'
import type { FinanceRecord } from '@/types/finance/record'
import { useLocalSearchParams, useRouter } from 'expo-router'
import { useEffect, useState } from 'react'
import { Text, TouchableOpacity, View } from 'react-native'
import { toast } from 'sonner-native'
import { LucideIcon } from '@/components/common/ao'
import CustomHeader from '@/components/custom/Header'
import PagerTabs from '@/components/custom/PagerTabs'
import { createFinanceRecordApi, updateFinanceRecordApi } from '@/request/api/finance/record'
import { useFinanceStore } from '@/store/finance'
import { getCurrentDate, toDateString } from '@/utils/time'
import AmountKeyboard from './components/AmountKeyboard'
import CategoryGrid from './components/CategoryGrid'
export default function MoenyRecordEditor() {
const financeStore = useFinanceStore()
const router = useRouter()
const expense = financeStore.getExpenseCategories()
const income = financeStore.getIncomeCategories()
const { id: updateId } = useLocalSearchParams<{ id: string }>()
// 自定义键盘
const [showKeydown, setShowKeydown] = useState(false)
// 选中分类
const [selectedCategory, setSelectedCategory] = useState<FinanceCategory | null>(null)
// 记账记录
const defaultFinanceRecord: FinanceRecord = {
id: -1,
category_id: -1,
amount: '0.00',
remark: '',
record_time: getCurrentDate(),
member_id: -1,
member_name: '',
}
const [financeRecord, setFinanceRecord] = useState<FinanceRecord>(defaultFinanceRecord)
useEffect(() => {
if (updateId) {
const updateData = financeStore.getRecordById(Number(updateId))
if (updateData !== null) {
setFinanceRecord({
...updateData,
record_time: toDateString(updateData.record_time),
})
setShowKeydown(true)
const moneyCategory = financeStore.getCategoryById(updateData.category_id)
if (moneyCategory != null) {
setSelectedCategory(moneyCategory)
}
}
}
}, [])
// 保存记录
function handleSaveRecord() {
if (selectedCategory === null) {
toast.warning('请选择分类')
return
}
if (financeRecord.amount === '0.00') {
toast.warning('请输入金额')
return
}
const amount = financeRecord.amount.replace(/^-+/, '')
if (updateId) {
updateFinanceRecordApi({
id: financeRecord.id,
category_id: selectedCategory.id,
amount,
remark: financeRecord.remark,
record_time: financeRecord.record_time,
}).then((res) => {
if (res.isSuccess) {
router.back()
}
})
}
else {
createFinanceRecordApi({
amount,
category_id: selectedCategory.id,
remark: financeRecord.remark,
record_time: financeRecord.record_time,
})
.then((res) => {
if (res.isSuccess) {
router.back()
}
})
}
}
return (
<View className="flex-1">
<CustomHeader
showHeader={false}
screenProps={{
options: {
animation: 'slide_from_bottom',
},
}}
/>
<PagerTabs
selectedIndex={selectedCategory?.type}
left={(
<TouchableOpacity
className="w-[30%] flex items-center px-4 flex-row"
onPress={() => {
router.navigate('/finance/category/setting')
}}
>
<LucideIcon icon="LayoutList" />
<Text className="ml-1">管理</Text>
</TouchableOpacity>
)}
right={(
<TouchableOpacity
className="w-[30%] flex flex-row items-center px-4"
onPress={() => {
router.back()
}}
>
<Text className="ml-auto text-base text-gray-400">取消</Text>
</TouchableOpacity>
)}
routes={[
{
key: 'expense',
title: '支出',
element: (
<CategoryGrid
data={expense}
selectedCategory={selectedCategory}
onSelectCategory={(data) => {
setShowKeydown(true)
setSelectedCategory(data)
}}
className="mt-4"
/>
),
},
{
key: 'income',
title: '收入',
element: (
<CategoryGrid
data={income}
selectedCategory={selectedCategory}
onSelectCategory={(data) => {
setShowKeydown(true)
setSelectedCategory(data)
}}
className="mt-4"
/>
),
},
]}
/>
<AmountKeyboard
show={showKeydown}
record_time={financeRecord.record_time}
amount={financeRecord.amount}
remark={financeRecord.remark}
onChange={(data) => {
setFinanceRecord({
...financeRecord,
record_time: data.record_time,
amount: data.amount,
remark: data.remark,
})
}}
onDone={handleSaveRecord}
/>
</View>
)
}
@@ -1,5 +1,5 @@
import type { UserSpace } from '@/types/space'
import { router, useFocusEffect, useLocalSearchParams, useNavigation } from 'expo-router'
import { router, useFocusEffect, useLocalSearchParams } from 'expo-router'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ActivityIndicator, Text, TouchableOpacity, View } from 'react-native'
import { KeyboardAvoidingView } from 'react-native-keyboard-controller'
@@ -0,0 +1,40 @@
'use client';
import { ActivityIndicator } from 'react-native';
import React from 'react';
import { tva } from '@gluestack-ui/utils/nativewind-utils';
import { cssInterop } from 'nativewind';
cssInterop(ActivityIndicator, {
className: { target: 'style', nativeStyleToProp: { color: true } },
});
const spinnerStyle = tva({});
const Spinner = React.forwardRef<
React.ComponentRef<typeof ActivityIndicator>,
React.ComponentProps<typeof ActivityIndicator>
>(function Spinner(
{
className,
color,
focusable = false,
'aria-label': ariaLabel = 'loading',
...props
},
ref
) {
return (
<ActivityIndicator
ref={ref}
focusable={focusable}
aria-label={ariaLabel}
{...props}
color={color}
className={spinnerStyle({ class: className })}
/>
);
});
Spinner.displayName = 'Spinner';
export { Spinner };
@@ -0,0 +1,24 @@
import type { CreateFinanceCategoryParams, CreateFinanceCategoryResult, DeleteFinanceCategoryParams, ListFinanceCategoriesResult, UpdateFinanceCategoryParams, UpdateFinanceCategorySortParams } from '@/types/finance/category'
import alovaInstance from '../../index'
const API_PREFIX = '/finance/category'
export function listFinanceCategoriesApi() {
return alovaInstance.Post<ResponseData<ListFinanceCategoriesResult>>(`${API_PREFIX}/list`)
}
export function createFinanceCategoryApi(params: CreateFinanceCategoryParams) {
return alovaInstance.Post<ResponseData<CreateFinanceCategoryResult>>(`${API_PREFIX}/create`, params)
}
export function updateFinanceCategoryApi(params: UpdateFinanceCategoryParams) {
return alovaInstance.Post<ResponseData<void>>(`${API_PREFIX}/update`, params)
}
export function updateFinanceCategorySortNumberApi(params: UpdateFinanceCategorySortParams) {
return alovaInstance.Post<ResponseData<void>>(`${API_PREFIX}/sortNumber`, params)
}
export function deleteFinanceCategoryApi(params: DeleteFinanceCategoryParams) {
return alovaInstance.Post<ResponseData<void>>(`${API_PREFIX}/delete`, params)
}
@@ -0,0 +1,20 @@
import type { CreateFinanceRecordParams, DeleteFinanceRecordParams, ListFinanceRecordsParams, ListFinanceRecordsResult, UpdateFinanceRecordParams } from '@/types/finance/record'
import alovaInstance from '../../index'
const API_PREFIX = '/finance/record'
export function listFinanceRecordsApi(params: ListFinanceRecordsParams) {
return alovaInstance.Post<ResponseData<ListFinanceRecordsResult>>(`${API_PREFIX}/list`, params)
}
export function createFinanceRecordApi(params: CreateFinanceRecordParams) {
return alovaInstance.Post<ResponseData<void>>(`${API_PREFIX}/create`, params)
}
export function updateFinanceRecordApi(params: UpdateFinanceRecordParams) {
return alovaInstance.Post<ResponseData<void>>(`${API_PREFIX}/update`, params)
}
export function deleteFinanceRecordApi(params: DeleteFinanceRecordParams) {
return alovaInstance.Post<ResponseData<void>>(`${API_PREFIX}/delete`, params)
}
+2 -2
View File
@@ -31,8 +31,8 @@ function isWhiteListed(url: string) {
}
const alovaInstance = createAlova({
baseURL: 'http://192.168.5.6:8086/app',
// baseURL: 'http://192.168.1.137:8086/app',
// baseURL: 'http://192.168.5.6:8086/app',
baseURL: 'http://192.168.1.137:8086/app',
requestAdapter: axiosRequestAdapter(),
timeout: 5000,
cacheFor: null,
+373
View File
@@ -0,0 +1,373 @@
import type { FinanceCategory, IconGroupType } from '@/types/finance/category'
import type { FinanceRecord, FinanceRecordGroup } from '@/types/finance/record'
import { create } from 'zustand'
import { listFinanceCategoriesApi } from '@/request/api/finance/category'
import { FinanceCategoryEnum } from '@/types/finance/category'
import { add, sub } from '@/utils/math'
interface FinanceState {
// 分类
DefaultGroupCategories: IconGroupType[]
categories: FinanceCategory[]
setCategories: (categories: FinanceCategory[]) => void
ensureCategoriesLoaded: () => Promise<void>
getExpenseCategories: () => FinanceCategory[]
getIncomeCategories: () => FinanceCategory[]
createCategory: (category: FinanceCategory) => void
updateCategory: (category: FinanceCategory) => void
deleteCategory: (id: number) => void
updateCategorySortNumber: (data: FinanceCategory[], type: number) => void
isDuplicateCategory: (name: string, type: number, excludeId: number | null) => boolean
getDefaultGroupRow: (iconName: string) => number
getCategoryById: (id: number) => FinanceCategory | null
getCategoryNextSortNumber: (type: number) => number
// 记录
records: FinanceRecordGroup[]
getTotalIncome: () => string
getTotalExpense: () => string
getTotalBalance: () => string
setRecords: (records: FinanceRecord[]) => void
getRecordById: (id: number) => FinanceRecord | null
}
function recomputeGroups(records: FinanceRecord[]) {
const { getCategoryById } = useFinanceStore.getState()
const groupsMap: Record<string, FinanceRecordGroup> = {}
records.forEach((r) => {
const category = getCategoryById(r.category_id)
if (!category)
return
const group = groupsMap[r.record_time] ?? {
record_time: r.record_time,
records: [],
income: '0.00',
expense: '0.00',
}
group.records.push(r)
if (category.type === FinanceCategoryEnum.Expense)
group.expense = add(group.expense, r.amount)
if (category.type === FinanceCategoryEnum.Income)
group.income = add(group.income, r.amount)
groupsMap[r.record_time] = group
})
return Object.values(groupsMap).sort((a, b) =>
b.record_time.localeCompare(a.record_time),
)
}
export const useFinanceStore = create<FinanceState>()(
(set, get) => ({
// ================= 分类 =================
DefaultGroupCategories: [
{
index: 0,
type: 'title',
value: '餐饮',
},
{
index: 1,
type: 'icons',
value: [
'money_canyin',
'money_yinpin',
'money_lingshi',
'money_xiaoye',
],
},
{
index: 2,
type: 'title',
value: '娱乐',
},
{
index: 3,
type: 'icons',
value: [
'money_yule',
'money_youxi',
'money_dianying',
],
},
{
index: 4,
type: 'title',
value: '交通',
},
{
index: 5,
type: 'icons',
value: [
'money_jiaotong',
'money_tingchefei',
'money_jiayou',
],
},
{
index: 6,
type: 'title',
value: '生活消费',
},
{
index: 7,
type: 'icons',
value: [
'money_lijin',
'money_jiadian',
'money_riyongpin',
'money_shucai',
'money_meifa',
],
},
{
index: 8,
type: 'icons',
value: [
'money_shuidian',
'money_wenju',
'money_zhufang',
'money_baoxian',
'money_baojianpin',
],
},
{
index: 9,
type: 'icons',
value: [
'money_huafei',
'money_lvyou',
'money_gouwu',
'money_shuben',
'money_chongwu',
],
},
{
index: 10,
type: 'icons',
value: [
'money_ruanjian',
],
},
{
index: 11,
type: 'title',
value: '健康',
},
{
index: 12,
type: 'icons',
value: [
'money_tijian',
'money_yao',
'money_jiuyi',
],
},
{
index: 13,
type: 'title',
value: '公益',
},
{
index: 14,
type: 'icons',
value: [
'money_gongyi',
],
},
{
index: 15,
type: 'title',
value: '兼职与收入',
},
{
index: 16,
type: 'icons',
value: [
'money_gongzi',
'money_jianzhi',
],
},
{
index: 17,
type: 'title',
value: '鲜花与水果',
},
{
index: 18,
type: 'icons',
value: [
'money_shuiguo',
'money_xianhua',
],
},
{
index: 19,
type: 'title',
value: '转账与转移',
},
{
index: 20,
type: 'icons',
value: [
'money_zhuanzhang',
],
},
{
index: 21,
type: 'title',
value: '家庭与家电',
},
{
index: 22,
type: 'icons',
value: [
'money_kuaidi',
'money_weixiu',
'money_jiaju',
'money_muying',
'money_liwu',
],
},
{
index: 23,
type: 'title',
value: '运动与娱乐',
},
{
index: 24,
type: 'icons',
value: [
'money_yundong',
'money_peixun',
],
},
],
categories: [],
setCategories: categories =>
set({
categories: [...categories].sort(
(a, b) => (a.sort_number ?? 0) - (b.sort_number ?? 0),
),
}),
ensureCategoriesLoaded: async () => {
if (get().categories.length > 0)
return
const res = await listFinanceCategoriesApi()
if (res.isSuccess) {
set({ categories: res.data.categories })
}
},
getExpenseCategories: () =>
get().categories.filter(c => c.type === FinanceCategoryEnum.Expense),
getIncomeCategories: () =>
get().categories.filter(c => c.type === FinanceCategoryEnum.Income),
getCategoryById: (id: number) => {
return get().categories.find(c => c.id === id) || null
},
createCategory: (category) => {
set({
categories: [...get().categories, category],
})
},
updateCategory: (category) => {
set(state => ({
categories: state.categories.map(c =>
c.id !== category.id
? c
: {
...c,
...category,
},
),
}))
},
deleteCategory: (id) => {
const allCategories = get().categories
const finalCategories = allCategories.filter(c => c.id !== id)
set({ categories: finalCategories })
},
updateCategorySortNumber: (data, type) => {
const allCategories = get().categories
const otherCategories = allCategories.filter(c => c.type !== type)
const updatedData = data.map((item, index) => ({
...item,
sort_number: index,
}))
set({ categories: [...otherCategories, ...updatedData] })
},
isDuplicateCategory: (name, type, excludeId) => {
const categories = get().categories || []
return categories.some(c =>
c.name === name
&& c.type === type
&& c.id !== excludeId,
)
},
getDefaultGroupRow: (iconName) => {
const iconGroups = get().DefaultGroupCategories
for (const group of iconGroups) {
if (group.type === 'icons' && group.value.includes(iconName)) {
return group.index
}
}
return 0
},
getCategoryNextSortNumber: (type: number): number => {
const categories = get().categories.filter(c => c.type === type)
if (categories.length === 0) {
return 0
}
const maxSortNumber = Math.max(...categories.map(c => c.sort_number))
return maxSortNumber + 1
},
// ================= 分类 =================
// ================= 记录 =================
records: [],
getTotalIncome: () => {
return get().records.reduce((sum, g) => add(sum, g.income), '0.00')
},
getTotalExpense: () => {
return get().records.reduce((sum, g) => add(sum, g.expense), '0.00')
},
getTotalBalance: () => {
return sub(get().getTotalIncome(), get().getTotalExpense())
},
setRecords: (records) => {
const newGroups = recomputeGroups(records)
set({ records: newGroups })
},
getRecordById: (id) => {
for (const group of get().records) {
const record = group.records.find(r => r.id === id)
if (record)
return record
}
return null
},
// ================= 记录 =================
}),
)
@@ -19,7 +19,7 @@ interface NavState {
}
const DEFAULT_NAV_MENUS: NavItem[] = [
{ label: '记账', path: '/money', icon: 'CircleDollarSign' },
{ label: '记账', path: '/finance', icon: 'CircleDollarSign' },
{ label: '恋爱记', path: '/love', icon: 'Heart' },
{ label: '聊天', path: '/chat', icon: 'MessageCircle' },
{ label: '个人中心', path: '/user', icon: 'CircleUserRound' },
@@ -0,0 +1,69 @@
export interface FinanceCategory {
id: number
name: string
icon: string
type: number
sort_number: number
}
export enum FinanceCategoryEnum {
Expense = 0, // 支出
Income = 1, // 收入
}
// 记账分类
export interface ListFinanceCategoriesResult {
categories: FinanceCategory[]
}
// 创建记账分类
export interface CreateFinanceCategoryParams {
name: string
icon: string
type: number
sort_number: number
}
export interface CreateFinanceCategoryResult {
id: number
}
// 更新记账分类排序
export interface UpdateFinanceCategorySortItem {
id: number
sort_number: number
}
export interface UpdateFinanceCategorySortParams {
categories: UpdateFinanceCategorySortItem[]
}
// 删除记账分类
export interface DeleteFinanceCategoryParams {
id: number
}
// 更新记账分类
export interface UpdateFinanceCategoryParams {
id: number
name: string
icon: string
}
// 图标分组基础接口
interface BaseIconGroupType {
index: number
}
// 当 type 为 'title' 时,value 必须是 string
interface TitleIconGroupType extends BaseIconGroupType {
type: 'title'
value: string
}
// 当 type 为 'icons' 时,value 必须是 string[]
interface IconsIconGroupType extends BaseIconGroupType {
type: 'icons'
value: string[]
}
// 联合类型
export type IconGroupType = TitleIconGroupType | IconsIconGroupType
@@ -0,0 +1,45 @@
export interface FinanceRecordGroup {
record_time: string
expense: string
income: string
records: FinanceRecord[]
}
export interface FinanceRecord {
id: number
category_id: number
amount: string
remark: string
record_time: string
member_id: number
member_name: string
}
export interface ListFinanceRecordsParams {
start_time: string
end_time: string
category_id: number
}
export interface ListFinanceRecordsResult {
records: FinanceRecord[]
}
export interface CreateFinanceRecordParams {
category_id: number
amount: string
record_time: string
remark: string
}
export interface UpdateFinanceRecordParams {
id: number
category_id: number
amount: string
record_time: string
remark: string
}
export interface DeleteFinanceRecordParams {
id: number
}