This commit is contained in:
oneao committed 2026-01-30 17:22:20 +08:00
1 parent c62c9d080b
commit 6a8c31e20c
29 files changed
+1454 -357

No files matched your search

@@ -5,7 +5,7 @@ import { useCallback, useEffect, useState } from 'react'
import { Text, TouchableOpacity, View } from 'react-native'
import Animated, { useAnimatedRef } from 'react-native-reanimated'
import Sortable from 'react-native-sortables'
import { ADrawer, LucideIcon } from '@/components/common/ao'
import { ADivider, ADrawer, LucideIcon } from '@/components/common/ao'
import { useNavStore } from '@/store/navigation'
export default function TabsLayout() {
@@ -75,37 +75,42 @@ export default function TabsLayout() {
<View
className="
absolute
left-3 right-3 bottom-8
h-14
flex-row items-center
px-2
gap-4
bg-white
"
style={{ zIndex: 0 }}
style={{
zIndex: 0,
elevation: 24,
shadowColor: '#000',
shadowOffset: { width: 0, height: 100 },
}}
>
<View className="bg-white rounded-md shadow-lg h-12 w-12">
<TouchableOpacity className="w-full h-full items-center justify-center">
<LucideIcon icon="Bot" size={26} clickable={false} strokeWidth={2} />
</TouchableOpacity>
</View>
<TouchableOpacity className="w-12 items-center justify-center">
<LucideIcon icon="Bot" size={24} clickable={false} strokeWidth={2} />
</TouchableOpacity>
<View className="flex-1 bg-white rounded-md shadow-lg h-12">
<TouchableOpacity
className="w-full h-full items-center justify-center flex flex-row"
onPress={() => {
setShowNavDrawer(true)
}}
>
<LucideIcon icon={currentNav.icon} size={20} strokeWidth={2} />
<Text className="font-semibold text-lg ml-2">{currentNav.label}</Text>
</TouchableOpacity>
</View>
<View className="bg-white rounded-md shadow-lg h-12 w-12">
<TouchableOpacity className="w-full h-full items-center justify-center" onPress={refreshPage}>
<LucideIcon icon="RefreshCcw" size={26} clickable={false} strokeWidth={2} />
</TouchableOpacity>
</View>
<ADivider
direction="vertical"
/>
<TouchableOpacity
className="flex-1 items-center justify-center flex flex-row"
onPress={() => {
setShowNavDrawer(true)
}}
>
<LucideIcon icon={currentNav.icon} size={22} strokeWidth={2} />
<Text className="font-semibold text-lg ml-2">{currentNav.label}</Text>
</TouchableOpacity>
<ADivider direction="vertical" />
<TouchableOpacity className="w-12 items-center justify-center" onPress={refreshPage}>
<LucideIcon icon="RefreshCcw" size={24} clickable={false} strokeWidth={2} />
</TouchableOpacity>
</View>
<ADrawer
@@ -1,16 +1,11 @@
import type { MoneyRecord, MoneyRecordGroup } from '@/types/money/record'
import React, { useCallback, useState } from 'react'
import { RefreshControl, SectionList, Text, TouchableHighlight, Vibration, View } from 'react-native'
import {
Menu,
MenuOption,
MenuOptions,
MenuTrigger,
renderers,
} from 'react-native-popup-menu'
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'
import { Menu, MenuItem, MenuItemLabel } from '@/components/ui/menu'
import { useMoneyCategoryStore } from '@/store/moneyCategory'
import { useUserStore } from '@/store/user'
import { MoneyCategoryEnum } from '@/types/money/category'
@@ -124,124 +119,121 @@ export default function StaticTransactions({
const category = moneyCategoryStore.getCategoryById(item.category_id)
return (
<Menu
renderer={renderers.Popover}
rendererProps={{
placement: 'bottom',
preferredPlacement: 'bottom',
anchorStyle: { backgroundColor: 'transparent' },
closeAnimationDuration: 0,
style={{
elevation: 10,
borderWidth: 0,
shadowColor: '#000',
borderRadius: 4,
width: 160,
}}
onSelect={() => setSelectedId(null)}
onBackdropPress={() => setSelectedId(null)}
onOpen={() => setSelectedId(item.id)}
onClose={() => setSelectedId(null)}
>
<MenuTrigger
triggerOnLongPress
onPress={() => {
Vibration.vibrate(50)
}}
>
<View
className="flex-row items-center gap-4 py-2 px-5"
style={{
backgroundColor: item.id === selectedId ? '#f2f2f2' : 'transparent',
}}
>
<View className="relative w-10 h-10 rounded-full bg-gray-100 items-center justify-center">
<MoneyCategoryIcon name={category?.icon} size={24} color="#000" />
</View>
<View className="flex-1">
<Text className="text-gray-800 text-base">
{item.remark ? item.remark : category?.name}
</Text>
{
item.user_id !== userID && (
<Text className="text-gray-500 text-xs italic">
{item.username}
</Text>
)
}
</View>
<View>
<Text
className="text-sm text-gray-600 text-right"
offset={0}
placement="bottom"
onClose={() => {
setSelectedId(null)
}}
trigger={({ ...triggerProps }) => {
return (
<Pressable
{...triggerProps}
delayLongPress={300}
onLongPress={(e) => {
setSelectedId(item.id)
Vibration.vibrate(50)
// 手动触发菜单显示
triggerProps.onPress?.(e)
}}
onPress={(_e) => {
// 阻止点击触发,可以执行其他点击操作
// 如果需要点击执行其他操作,可以在这里添加
}}
>
<View
className="flex-row items-center gap-4 py-2 px-5"
style={{
backgroundColor: item.id === selectedId ? '#f2f2f2' : 'transparent',
}}
>
{category?.type === MoneyCategoryEnum.Income ? '+' : '-'}
{' '}
{item.amount}
</Text>
</View>
</View>
</MenuTrigger>
<MenuOptions
customStyles={{
optionsContainer: {
backgroundColor: 'white',
minWidth: 160,
},
<View className="relative w-10 h-10 rounded-full bg-gray-100 items-center justify-center">
<MoneyCategoryIcon name={category?.icon} size={24} color="#000" />
</View>
<View className="flex-1">
<Text className="text-gray-800 text-base">
{item.remark ? item.remark : category?.name}
</Text>
{
item.user_id !== userID && (
<Text className="text-gray-500 text-xs italic">
{item.username}
</Text>
)
}
</View>
<View>
<Text
className="text-sm text-gray-600 text-right"
>
{category?.type === MoneyCategoryEnum.Income ? '+' : '-'}
{' '}
{item.amount}
</Text>
</View>
</View>
</Pressable>
)
}}
>
{
userID === item.user_id && (
<MenuItem
key="update"
textValue="update"
className="flex gap-2"
style={{
paddingVertical: 10,
minWidth: 150,
}}
onPress={() => {
onUpdate?.(item)
}}
>
<LucideIcon icon="Pencil" size={18} />
<MenuItemLabel>更新</MenuItemLabel>
</MenuItem>
)
}
<MenuItem
key="delete"
textValue="delete"
className="flex gap-2"
style={{
paddingVertical: 10,
minWidth: 150,
}}
onPress={() => {
onDelete?.(item.id)
}}
>
{
userID === item.user_id && (
<>
<MenuOption
customStyles={{
OptionTouchableComponent: TouchableHighlight,
optionTouchable: {
underlayColor: '#f0f0f0',
activeOpacity: 0.7,
},
}}
onSelect={() => {
onUpdate?.(item)
}}
>
<View className="flex flex-row items-center h-10 px-2">
<LucideIcon icon="Pencil" />
<Text className="ml-2 text-lg">更新</Text>
</View>
</MenuOption>
<MenuOption
customStyles={{
OptionTouchableComponent: TouchableHighlight,
optionTouchable: {
underlayColor: '#f0f0f0',
activeOpacity: 0.7,
},
}}
onSelect={() => {
onDelete?.(item.id)
}}
>
<View className="flex flex-row items-center h-10 px-2">
<LucideIcon icon="Trash" />
<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>
<LucideIcon icon="Trash" size={18} />
<MenuItemLabel>删除</MenuItemLabel>
</MenuItem>
<MenuItem
key="detail"
textValue="detail"
className="flex gap-2"
style={{
paddingVertical: 10,
minWidth: 150,
}}
onPress={() => {
onDetail?.(item.id)
}}
>
<LucideIcon icon="ReceiptText" size={18} />
<MenuItemLabel>详细</MenuItemLabel>
</MenuItem>
</Menu>
)
}}
@@ -161,7 +161,6 @@ export default function Money() {
onPress={() => {
router.navigate('/money/record/editor')
}}
className="mb-20"
>
<FabIcon as={AddIcon} size="xl" />
</Fab>
@@ -1,38 +1,131 @@
import type { AnalysisMoneyAmount } from '@/types/money/analysis'
import dayjs from 'dayjs'
import { useState } from 'react'
import { Text, TouchableOpacity, View } from 'react-native'
import { VictoryChart, VictoryLine, VictoryPie, VictoryTheme, VictoryTooltip, VictoryVoronoiContainer } from 'victory-native'
import { ADatePicker, ASegmentedTabs, LucideIcon } from '@/components/common/ao'
import { useEffect, useState } from 'react'
import { ScrollView, Text, TouchableOpacity, View } from 'react-native'
import Svg from 'react-native-svg'
import { VictoryChart, VictoryLabel, VictoryLine, VictoryPie, VictoryTheme, VictoryTooltip } from 'victory-native'
import { ADatePicker, ADateRangePicker, ASegmentedTabs, LucideIcon } from '@/components/common/ao'
import EmptyState from '@/components/common/EmptyStatus'
import CustomHeader from '@/components/custom/Header'
import PagerTabs from '@/components/custom/PagerTabs'
type GroupBy = 'day' | 'month' | 'year'
type AnalysisType = 'expense' | 'income'
import { analysisMoneyLineApi, analysisMoneyPieApi } from '@/request/api/money/analysis'
import { MoneyCategoryEnum } from '@/types/money/category'
import { getCurrentYear, getCurrentYearMonth, getEndOfMonth, getStartOfMonth } from '@/utils/time'
interface AnalysisContentProps {
analysisType: AnalysisType
analysisType: MoneyCategoryEnum
}
export default function Analysis() {
function AnalysisContent({ analysisType }: AnalysisContentProps) {
// 按月0 按年1 自定义2
const [timeType, setTimeType] = useState<number>(0)
const [groupBy, setGroupBy] = useState<GroupBy>('day')
const [startDate, setStartDate] = useState(
dayjs().format('YYYY-MM'),
)
const [endDate, setEndDate] = useState(
dayjs().format('YYYY-MM'),
)
// 开始结束时间
const [startDate, setStartDate] = useState(getCurrentYearMonth())
const [endDate, setEndDate] = useState('')
// 显示
const [showDatePicker, setShowDatePicker] = useState(false)
// 数据
const [lineRecords, setLineRecords] = useState<AnalysisMoneyAmount[]>([])
const [pieRecords, setPieRecords] = useState<AnalysisMoneyAmount[]>([])
// 请求
function fetchChartsData() {
let start = ''
let end = ''
const selectedStartDate = dayjs(startDate)
const selectedEndDate = dayjs(endDate)
let periodType = 0
if (timeType === 0) {
start = selectedStartDate.startOf('month').format('YYYY-MM-DD')
end = selectedStartDate.endOf('month').format('YYYY-MM-DD')
periodType = 0
}
else if (timeType === 1) {
start = selectedStartDate.startOf('year').format('YYYY-MM-DD')
end = selectedStartDate.endOf('year').format('YYYY-MM-DD')
periodType = 1
}
else {
console.log(selectedStartDate.isSame(selectedEndDate, 'day'))
console.log(selectedStartDate.isSame(selectedEndDate, 'month'))
console.log(selectedStartDate.isSame(selectedEndDate, 'year'))
if (selectedStartDate.isSame(selectedEndDate, 'month')) {
periodType = 0
start = selectedStartDate.startOf('month').format('YYYY-MM-DD')
end = selectedEndDate.endOf('month').format('YYYY-MM-DD')
}
else if (selectedStartDate.isSame(selectedEndDate, 'year')) {
periodType = 1
start = selectedStartDate.startOf('year').format('YYYY-MM-DD')
end = selectedEndDate.endOf('year').format('YYYY-MM-DD')
}
else {
periodType = 2
start = selectedStartDate.format('YYYY-MM-DD')
end = selectedEndDate.format('YYYY-MM-DD')
}
}
analysisMoneyLineApi({
start_date: start,
end_date: end,
type: analysisType,
period_type: periodType,
}).then((res) => {
if (res.isSuccess) {
setLineRecords(res.data.records)
}
})
analysisMoneyPieApi({
start_date: start,
end_date: end,
type: analysisType,
period_type: periodType,
}).then((res) => {
if (res.isSuccess) {
setPieRecords(res.data.records)
}
})
}
useEffect(() => {
fetchChartsData()
}, [timeType, startDate, endDate])
return (
<View className="p-4">
<ScrollView
className="p-4"
>
<ASegmentedTabs
value={timeType}
onChange={(index) => {
setTimeType(index)
if (index === 2) {
// 自定义类型,本月的 1号 - 终止号
setStartDate(getStartOfMonth())
setEndDate(getEndOfMonth())
}
else if (index === 1) {
// 按年
setStartDate(getCurrentYear())
setEndDate('')
}
else {
// 按月
setStartDate(getCurrentYearMonth())
setEndDate('')
}
}}
tabs={[
{ key: '1', label: '按月' },
@@ -42,9 +135,9 @@ export default function Analysis() {
/>
<TouchableOpacity
className="mt-14 flex flex-row justify-center items-center"
className="flex flex-row justify-center items-center"
>
<LucideIcon icon="Calendar" size={16} color="#666" clickable={false} />
<LucideIcon icon="CalendarDays" size={14} color="#666" clickable={false} />
<Text className="font-semibold text-lg mr-2 ml-1">{startDate}</Text>
{timeType === 2 && (
<>
@@ -63,40 +156,99 @@ export default function Analysis() {
<VictoryChart
height={220}
theme={VictoryTheme.material}
padding={{ top: 15, bottom: 30, left: 40, right: 40 }}
>
<VictoryLine
interpolation="catmullRom"
data={[
]}
data={lineRecords}
animate={{
duration: 1000,
}}
/>
</VictoryChart>
</View>
<View className="mt-4">
<View
className="mt-4"
>
<Text className="text-lg font-semibold text-gray-700">分类构成</Text>
<VictoryPie
height={250}
innerRadius={50}
data={[
]}
theme={VictoryTheme.material}
/>
{
pieRecords.length > 0
? (
<VictoryPie
height={250}
animate={{ duration: 1000 }}
data={pieRecords}
theme={VictoryTheme.material}
/>
)
: (
<EmptyState title="空" />
)
}
</View>
<ADatePicker
show={showDatePicker}
mode="month"
onConfirm={(val) => {
setShowDatePicker(false)
}}
onClose={() => {
setShowDatePicker(false)
}}
/>
</View>
<View className="mt-4" style={{ paddingBottom: 24 }}>
<Text className="text-lg font-semibold text-gray-700">分类详细</Text>
{
pieRecords.length > 0
? (
<View className="mt-4 space-y-3">
{pieRecords.map((item, index) => (
<View
key={index}
className={`flex flex-row justify-between items-center p-3 bg-white shadow-md rounded-lg ${index !== pieRecords.length - 1 ? 'border-b border-gray-300' : ''}`}
>
<Text className="text-base font-medium text-gray-800">{item.x}</Text>
<Text className="text-base font-semibold text-gray-700">
{item.y}
{' '}
元
</Text>
</View>
))}
</View>
)
: (
<EmptyState title="空" />
)
}
</View>
{
timeType === 2
? (
<ADateRangePicker
show={showDatePicker}
onConfirm={(startDate, endDate) => {
setStartDate(startDate)
setEndDate(endDate)
setShowDatePicker(false)
}}
onClose={() => {
setShowDatePicker(false)
}}
/>
)
: (
<ADatePicker
show={showDatePicker}
mode={timeType === 0 ? 'month' : 'year'}
onConfirm={(val) => {
setStartDate(val)
setShowDatePicker(false)
}}
onClose={() => {
setShowDatePicker(false)
}}
/>
)
}
</ScrollView>
)
}
@@ -112,12 +264,12 @@ export default function Analysis() {
{
key: 'expense',
title: '支出',
element: <AnalysisContent analysisType="expense" />,
element: <AnalysisContent analysisType={MoneyCategoryEnum.Expense} />,
},
{
key: 'income',
title: '收入',
element: <AnalysisContent analysisType="income" />,
element: <AnalysisContent analysisType={MoneyCategoryEnum.Income} />,
},
]}
/>