u
This commit is contained in:
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} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { IDateRangePickerProps } from '@yz1311/react-native-wheel-picker'
|
||||
import { DateRangePicker } from '@yz1311/react-native-wheel-picker'
|
||||
import dayjs from 'dayjs'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Animated, Modal, StyleSheet, TouchableOpacity } from 'react-native'
|
||||
|
||||
interface ADateRangePickerProps extends IDateRangePickerProps {
|
||||
show?: boolean
|
||||
onClose?: () => void
|
||||
onConfirm?: (startDate: string, endDate: string) => void
|
||||
}
|
||||
|
||||
export default function ADateRangePicker({
|
||||
show,
|
||||
onClose,
|
||||
onConfirm,
|
||||
...restProps
|
||||
}: ADateRangePickerProps) {
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current
|
||||
const slideAnim = useRef(new Animated.Value(300)).current
|
||||
|
||||
const [visible, setVisible] = useState(show)
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
setVisible(true)
|
||||
fadeAnim.setValue(0)
|
||||
slideAnim.setValue(300)
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, { toValue: 1, duration: 250, useNativeDriver: true }),
|
||||
Animated.timing(slideAnim, { toValue: 0, duration: 250, useNativeDriver: true }),
|
||||
]).start()
|
||||
}
|
||||
else {
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, { toValue: 0, duration: 250, useNativeDriver: true }),
|
||||
Animated.timing(slideAnim, { toValue: 280, duration: 250, useNativeDriver: true }),
|
||||
]).start(() => setVisible(false))
|
||||
}
|
||||
}, [show])
|
||||
|
||||
const handleCancel = () => onClose?.()
|
||||
|
||||
const handleConfirm = (startDate: Date, endDate: Date) => {
|
||||
const formattedStartDate = dayjs(startDate).format('YYYY-MM-DD')
|
||||
const formattedEndDate = dayjs(endDate).format('YYYY-MM-DD')
|
||||
|
||||
onConfirm?.(formattedStartDate, formattedEndDate)
|
||||
}
|
||||
|
||||
if (!visible)
|
||||
return null
|
||||
|
||||
return (
|
||||
<Modal transparent visible={visible} animationType="none">
|
||||
<Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
|
||||
<TouchableOpacity style={{ flex: 1 }} activeOpacity={1} onPress={handleCancel} />
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
style={[styles.pickerContainer, { transform: [{ translateY: slideAnim }] }]}
|
||||
>
|
||||
<DateRangePicker
|
||||
pickerTitle="时间范围"
|
||||
pickerCancelBtnText="取消"
|
||||
pickerConfirmBtnText="确认"
|
||||
{...restProps}
|
||||
isModal={false}
|
||||
onPickerCancel={handleCancel}
|
||||
onPickerConfirm={handleConfirm}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
pickerContainer: {
|
||||
backgroundColor: '#fff',
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
},
|
||||
})
|
||||
@@ -2,8 +2,9 @@ import { Avatar } from './Avatar'
|
||||
import Button from './Button'
|
||||
import { Cell, CellGroup } from './Cell'
|
||||
import Checkbox from './Checkbox'
|
||||
import DatePicker from './DatePicker'
|
||||
|
||||
import DatePicker from './DatePicker'
|
||||
import DateRangePicker from './DateRangePicker'
|
||||
import Divider from './Divider'
|
||||
import Drawer from './Drawer'
|
||||
import Form, { FormRef } from './Form'
|
||||
@@ -13,7 +14,6 @@ import ListItem from './ListItem'
|
||||
import LucideIcon from './LucideIcon'
|
||||
import Modal from './Modal'
|
||||
import SegmentedTabs from './SegmentedTabs'
|
||||
|
||||
import Tab from './Tab'
|
||||
|
||||
export {
|
||||
@@ -23,6 +23,7 @@ export {
|
||||
CellGroup as ACellGroup,
|
||||
Checkbox as ACheckbox,
|
||||
DatePicker as ADatePicker,
|
||||
DateRangePicker as ADateRangePicker,
|
||||
Divider as ADivider,
|
||||
Drawer as ADrawer,
|
||||
Form as AForm,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { AnalysisMoneyLineResult, AnalysisMoneyParams, AnalysisMoneyPieResult } from '@/types/money/analysis'
|
||||
|
||||
import alovaInstance from '../../index'
|
||||
|
||||
const API_PREFIX = '/money/analysis'
|
||||
|
||||
export function analysisMoneyLineApi(params: AnalysisMoneyParams) {
|
||||
return alovaInstance.Post<ResponseData<AnalysisMoneyLineResult>>(`${API_PREFIX}/line`, params)
|
||||
}
|
||||
|
||||
export function analysisMoneyPieApi(params: AnalysisMoneyParams) {
|
||||
return alovaInstance.Post<ResponseData<AnalysisMoneyPieResult>>(`${API_PREFIX}/pie`, params)
|
||||
}
|
||||
@@ -28,8 +28,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,
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export interface AnalysisMoneyParams {
|
||||
start_date: string
|
||||
end_date: string
|
||||
type: number
|
||||
period_type: number // 0 = 日分析, 1 = 月分析, 2 = 年分析
|
||||
}
|
||||
|
||||
export interface AnalysisMoneyAmount {
|
||||
x: string
|
||||
y: string
|
||||
}
|
||||
|
||||
export interface AnalysisMoneyLineResult {
|
||||
records: AnalysisMoneyAmount[]
|
||||
}
|
||||
|
||||
export interface AnalysisMoneyPieResult {
|
||||
records: AnalysisMoneyAmount[]
|
||||
}
|
||||
@@ -28,6 +28,10 @@ export function getCurrentYearMonth(format = 'YYYY-MM'): string {
|
||||
return dayjs().format(format)
|
||||
}
|
||||
|
||||
export function getCurrentYear(): string {
|
||||
return dayjs().format('YYYY')
|
||||
}
|
||||
|
||||
/** 字符串 / 数字 / Date → Date(无效返回 null) */
|
||||
export function toDate(input: string | number | Date | undefined | null): Date {
|
||||
if (!input)
|
||||
@@ -105,3 +109,13 @@ export function formatRelativeTime(date: string | Date | number): string {
|
||||
return `${years}年前`
|
||||
}
|
||||
}
|
||||
|
||||
// 获取本月的第一天
|
||||
export function getStartOfMonth() {
|
||||
return dayjs().startOf('month').format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
// 获取本月的最后一天
|
||||
export function getEndOfMonth() {
|
||||
return dayjs().endOf('month').format('YYYY-MM-DD')
|
||||
}
|
||||
@@ -1,87 +1,94 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>财务分析 Demo</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WebSocket Test</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
margin: 20px;
|
||||
}
|
||||
#output {
|
||||
margin-top: 20px;
|
||||
padding: 10px;
|
||||
border: 1px solid #ccc;
|
||||
min-height: 150px;
|
||||
}
|
||||
#sendMessage {
|
||||
margin-top: 20px;
|
||||
padding: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-gray-100 p-4">
|
||||
<body>
|
||||
|
||||
<!-- Header: 收支切换 -->
|
||||
<div class="flex justify-center gap-4 mb-4">
|
||||
<button id="expenseBtn" class="px-4 py-2 rounded-full bg-blue-500 text-white font-semibold">支出</button>
|
||||
<button id="incomeBtn" class="px-4 py-2 rounded-full bg-gray-200 text-gray-700 font-semibold">收入</button>
|
||||
</div>
|
||||
|
||||
<!-- 时间维度选择 -->
|
||||
<div class="flex justify-center gap-2 mb-4">
|
||||
<button class="timeBtn px-3 py-1 rounded-full bg-blue-500 text-white font-medium" data-type="month">按月</button>
|
||||
<button class="timeBtn px-3 py-1 rounded-full bg-gray-200 text-gray-700 font-medium" data-type="year">按年</button>
|
||||
<button class="timeBtn px-3 py-1 rounded-full bg-gray-200 text-gray-700 font-medium" data-type="custom">自定义</button>
|
||||
</div>
|
||||
|
||||
<!-- 日期选择 -->
|
||||
<div id="datePickerContainer" class="flex justify-center mb-4">
|
||||
<!-- 默认显示按月日期选择 -->
|
||||
<input type="month" id="monthInput" class="p-2 rounded border border-gray-300">
|
||||
<input type="month" id="yearInput" class="p-2 rounded border border-gray-300 hidden">
|
||||
<div id="customInput" class="flex gap-2 hidden">
|
||||
<input type="date" id="startDate" class="p-2 rounded border border-gray-300">
|
||||
<span class="self-center">~</span>
|
||||
<input type="date" id="endDate" class="p-2 rounded border border-gray-300">
|
||||
<h2>WebSocket Test</h2>
|
||||
<div>
|
||||
<label for="messageInput">Enter Message:</label>
|
||||
<input type="text" id="messageInput" placeholder="Type your message here">
|
||||
<button id="sendBtn">Send Message</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 示例图表区域 -->
|
||||
<div class="bg-white p-4 rounded shadow text-center">
|
||||
<p class="text-gray-500">这里显示图表或分析结果</p>
|
||||
</div>
|
||||
<div id="output">
|
||||
<h3>Messages:</h3>
|
||||
<div id="messages"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const expenseBtn = document.getElementById('expenseBtn')
|
||||
const incomeBtn = document.getElementById('incomeBtn')
|
||||
const timeBtns = document.querySelectorAll('.timeBtn')
|
||||
const monthInput = document.getElementById('monthInput')
|
||||
const yearInput = document.getElementById('yearInput')
|
||||
const customInput = document.getElementById('customInput')
|
||||
<script>
|
||||
// Create a new WebSocket connection
|
||||
const socket = new WebSocket('ws://192.168.1.137:8086/app/ws');
|
||||
|
||||
// 收支切换逻辑
|
||||
expenseBtn.addEventListener('click', () => {
|
||||
expenseBtn.classList.add('bg-blue-500', 'text-white')
|
||||
expenseBtn.classList.remove('bg-gray-200', 'text-gray-700')
|
||||
incomeBtn.classList.add('bg-gray-200', 'text-gray-700')
|
||||
incomeBtn.classList.remove('bg-blue-500', 'text-white')
|
||||
})
|
||||
// Get HTML elements
|
||||
const sendButton = document.getElementById('sendBtn');
|
||||
const messageInput = document.getElementById('messageInput');
|
||||
const messagesDiv = document.getElementById('messages');
|
||||
|
||||
incomeBtn.addEventListener('click', () => {
|
||||
incomeBtn.classList.add('bg-blue-500', 'text-white')
|
||||
incomeBtn.classList.remove('bg-gray-200', 'text-gray-700')
|
||||
expenseBtn.classList.add('bg-gray-200', 'text-gray-700')
|
||||
expenseBtn.classList.remove('bg-blue-500', 'text-white')
|
||||
})
|
||||
// WebSocket onopen event: Fired when WebSocket connection is established
|
||||
socket.onopen = function(event) {
|
||||
console.log("Connected to WebSocket server!");
|
||||
displayMessage("Connected to WebSocket server!");
|
||||
};
|
||||
|
||||
// 时间维度切换逻辑
|
||||
timeBtns.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
timeBtns.forEach(b => {
|
||||
b.classList.remove('bg-blue-500', 'text-white')
|
||||
b.classList.add('bg-gray-200', 'text-gray-700')
|
||||
})
|
||||
btn.classList.add('bg-blue-500', 'text-white')
|
||||
btn.classList.remove('bg-gray-200', 'text-gray-700')
|
||||
// WebSocket onmessage event: Fired when a message is received from the server
|
||||
socket.onmessage = function(event) {
|
||||
console.log("Received message from server: " + event.data);
|
||||
displayMessage("Server: " + event.data);
|
||||
};
|
||||
|
||||
// 显示对应日期选择控件
|
||||
const type = btn.dataset.type
|
||||
monthInput.classList.add('hidden')
|
||||
yearInput.classList.add('hidden')
|
||||
customInput.classList.add('hidden')
|
||||
// WebSocket onerror event: Fired when there's an error with the WebSocket
|
||||
socket.onerror = function(error) {
|
||||
console.log(error)
|
||||
console.error("WebSocket Error: " + error);
|
||||
displayMessage("WebSocket Error: " + error);
|
||||
};
|
||||
|
||||
if (type === 'month') monthInput.classList.remove('hidden')
|
||||
if (type === 'year') yearInput.classList.remove('hidden')
|
||||
if (type === 'custom') customInput.classList.remove('hidden')
|
||||
})
|
||||
})
|
||||
</script>
|
||||
// WebSocket onclose event: Fired when the connection is closed
|
||||
socket.onclose = function(event) {
|
||||
console.log("WebSocket connection closed");
|
||||
displayMessage("WebSocket connection closed");
|
||||
};
|
||||
|
||||
// Function to display messages on the page
|
||||
function displayMessage(message) {
|
||||
const messageElement = document.createElement('div');
|
||||
messageElement.textContent = message;
|
||||
messagesDiv.appendChild(messageElement);
|
||||
}
|
||||
|
||||
// Button to send the message
|
||||
sendButton.onclick = function() {
|
||||
const message = messageInput.value;
|
||||
if (message) {
|
||||
console.log("Sending message: " + message);
|
||||
socket.send(message);
|
||||
displayMessage("You: " + message);
|
||||
messageInput.value = ''; // Clear the input field
|
||||
} else {
|
||||
alert("Please enter a message!");
|
||||
}
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user