This commit is contained in:
oneao committed 2026-02-01 22:33:01 +08:00
1 parent ff3ee944d5
commit bde3487da3
4 files changed
+801 -9

No files matched your search

@@ -0,0 +1,193 @@
import type { MoneyCategoryItem } from '@/types/money/category'
import { useCallback, useEffect, useMemo, useRef } from 'react'
import {
Actionsheet,
ActionsheetBackdrop,
ActionsheetContent,
ActionsheetDragIndicator,
ActionsheetDragIndicatorWrapper,
ActionsheetItem,
ActionsheetItemText,
ActionsheetSectionHeaderText,
ActionsheetSectionList,
} from '@/components/ui/actionsheet'
interface CategorySection {
title: string
data: MoneyCategoryItem[]
}
interface CategorySheetProps {
open?: boolean
moneyCategories?: MoneyCategoryItem[]
value?: number | null
onClose?: () => void
onSelect?: (category: MoneyCategoryItem) => void
}
export default function CategorySheet({
open = false,
moneyCategories = [],
value,
onClose,
onSelect,
}: CategorySheetProps) {
const listRef = useRef<any>(null)
const hasAutoScrolled = useRef(false)
// 记住目标滚动位置
const pendingScrollRef = useRef<{
sectionIndex: number
itemIndex: number
} | null>(null)
const handleSelect = useCallback(
(item: MoneyCategoryItem) => {
onSelect?.(item)
onClose?.()
},
[onSelect, onClose],
)
function createVirtualCategory(
id: number,
name: string,
type: number,
): MoneyCategoryItem {
return {
id,
name,
type,
icon: '',
sort_number: -1,
}
}
const sections = useMemo<CategorySection[]>(() => {
const expense = moneyCategories.filter(i => i.type === 0)
const income = moneyCategories.filter(i => i.type === 1)
return [
{
title: '筛选',
data: [
createVirtualCategory(-1, '全部', -1),
createVirtualCategory(-2, '支出', 0),
createVirtualCategory(-3, '收入', 1),
],
},
{
title: '支出分类',
data: expense,
},
{
title: '收入分类',
data: income,
},
]
}, [moneyCategories])
useEffect(() => {
if (!open) {
hasAutoScrolled.current = false
pendingScrollRef.current = null
return
}
if (hasAutoScrolled.current)
return
if (value == null)
return
let sectionIndex = -1
let itemIndex = -1
sections.some((section, sIndex) => {
const index = section.data.findIndex(i => i.id === value)
if (index !== -1) {
sectionIndex = sIndex
itemIndex = index
return true
}
return false
})
if (sectionIndex !== -1 && itemIndex !== -1) {
// 记录目标位置,供失败兜底使用
pendingScrollRef.current = { sectionIndex, itemIndex }
requestAnimationFrame(() => {
listRef.current?.scrollToLocation({
sectionIndex,
itemIndex,
animated: true,
viewPosition: 0.5,
})
})
hasAutoScrolled.current = true
}
}, [open, value, sections])
return (
<Actionsheet isOpen={open} onClose={onClose}>
<ActionsheetBackdrop />
<ActionsheetContent style={{ maxHeight: '60%' }}>
<ActionsheetDragIndicatorWrapper>
<ActionsheetDragIndicator />
</ActionsheetDragIndicatorWrapper>
<ActionsheetSectionList
ref={listRef}
sections={sections}
stickySectionHeadersEnabled={true}
showsVerticalScrollIndicator={false}
onEndReachedThreshold={0.1}
onScrollToIndexFailed={() => {
const target = pendingScrollRef.current
if (!target)
return
setTimeout(() => {
listRef.current?.scrollToLocation({
sectionIndex: target.sectionIndex,
itemIndex: target.itemIndex,
animated: true,
viewPosition: 0.5,
})
}, 0)
}}
keyExtractor={(item, index) =>
String((item as MoneyCategoryItem).id ?? index)}
renderItem={({ item }) => {
const category = item as MoneyCategoryItem
const selected = value === category.id
return (
<ActionsheetItem
className={selected ? 'bg-gray-100' : ''}
onPress={() => handleSelect(category)}
>
<ActionsheetItemText>
{category.name}
</ActionsheetItemText>
</ActionsheetItem>
)
}}
renderSectionHeader={({ section }) => {
const s = section as CategorySection
return (
<ActionsheetSectionHeaderText className="font-semibold text-[black] text-sm bg-white">
{s.title}
{' '}
(
{s.data.length}
)
</ActionsheetSectionHeaderText>
)
}}
/>
</ActionsheetContent>
</Actionsheet>
)
}
@@ -6,13 +6,16 @@ import { ADatePicker, ADrawer, AListItem, LucideIcon } from '@/components/common
import { Fab, FabIcon } from '@/components/ui/fab'
import { AddIcon } from '@/components/ui/icon'
import { deleteMoneyRecordApi, listMoneyRecordsApi } from '@/request/api/money/record'
import { useMoneyCategoryStore } from '@/store/moneyCategory'
import { useMoneyRecordStore } from '@/store/moneyRecord'
import { getCurrentYearMonth } 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 insets = useSafeAreaInsets()
@@ -21,7 +24,9 @@ export default function Money() {
const [showDatePicker, setShowDatePicker] = useState(false)
const [searchParams, setSearchParams] = useState({
record_time: getCurrentYearMonth(),
category: -1,
})
const [showCategorySheet, setShowCategorySheet] = useState(false)
// 操作菜单
const [showActionsMenu, setShowActionsMenu] = useState(false)
@@ -68,13 +73,23 @@ export default function Money() {
}}
>
<View className="flex flex-row justify-between items-center px-3">
<TouchableOpacity
onPress={() => setShowDatePicker(true)}
className="flex flex-row items-center gap-1"
>
<Text className="font-semibold text-lg">{searchParams.record_time}</Text>
<LucideIcon icon="ChevronDown" size={16} color="#666" clickable={false} />
</TouchableOpacity>
<View className="flex flex-row items-center gap-2">
<TouchableOpacity
onPress={() => setShowDatePicker(true)}
className="flex flex-row items-center gap-1"
>
<Text className="font-semibold text-lg">{searchParams.record_time}</Text>
<LucideIcon icon="ChevronDown" size={16} color="#666" clickable={false} />
</TouchableOpacity>
<TouchableOpacity
onPress={() => setShowCategorySheet(true)}
className="flex flex-row items-center gap-1"
>
<Text className="font-semibold text-lg">分类</Text>
<LucideIcon icon="ChevronDown" size={16} color="#666" clickable={false} />
</TouchableOpacity>
</View>
<TouchableOpacity
className="flex flex-row items-center gap-2"
@@ -181,6 +196,21 @@ export default function Money() {
}}
/>
<CategorySheet
open={showCategorySheet}
value={searchParams.category}
moneyCategories={moneyCategoryStore.categories}
onClose={() => {
setShowCategorySheet(false)
}}
onSelect={(category) => {
setSearchParams(prev => ({
...prev,
category: category.id,
}))
}}
/>
<ADrawer
visible={showActionsMenu}
hideModal={() => {
@@ -0,0 +1,569 @@
'use client';
import React from 'react';
import { H4 } from '@expo/html-elements';
import { createActionsheet } from '@gluestack-ui/core/actionsheet/creator';
import {
Pressable,
View,
Text,
ScrollView,
VirtualizedList,
FlatList,
SectionList,
PressableProps,
ViewStyle,
} from 'react-native';
import { PrimitiveIcon, UIIcon } from '@gluestack-ui/core/icon/creator';
import { tva } from '@gluestack-ui/utils/nativewind-utils';
import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils';
import { cssInterop } from 'nativewind';
import {
Motion,
AnimatePresence,
createMotionAnimatedComponent,
MotionComponentProps,
} from '@legendapp/motion';
const ItemWrapper = React.forwardRef<
React.ComponentRef<typeof Pressable>,
PressableProps
>(function ItemWrapper({ ...props }, ref) {
return <Pressable {...props} ref={ref} />;
});
type IMotionViewProps = React.ComponentProps<typeof View> &
MotionComponentProps<typeof View, ViewStyle, unknown, unknown, unknown>;
const MotionView = Motion.View as React.ComponentType<IMotionViewProps>;
type IAnimatedPressableProps = React.ComponentProps<typeof Pressable> &
MotionComponentProps<typeof Pressable, ViewStyle, unknown, unknown, unknown>;
const AnimatedPressable = createMotionAnimatedComponent(
Pressable
) as React.ComponentType<IAnimatedPressableProps>;
export const UIActionsheet = createActionsheet({
Root: View,
Content: MotionView,
Item: ItemWrapper,
ItemText: Text,
DragIndicator: View,
IndicatorWrapper: View,
Backdrop: AnimatedPressable,
ScrollView: ScrollView,
VirtualizedList: VirtualizedList,
FlatList: FlatList,
SectionList: SectionList,
SectionHeaderText: H4,
Icon: UIIcon,
AnimatePresence: AnimatePresence,
});
cssInterop(UIActionsheet, { className: 'style' });
cssInterop(UIActionsheet.Content, { className: 'style' });
cssInterop(ItemWrapper, { className: 'style' });
cssInterop(UIActionsheet.ItemText, { className: 'style' });
cssInterop(UIActionsheet.DragIndicator, { className: 'style' });
cssInterop(UIActionsheet.DragIndicatorWrapper, { className: 'style' });
cssInterop(UIActionsheet.Backdrop, { className: 'style' });
cssInterop(UIActionsheet.ScrollView, {
className: 'style',
contentContainerClassName: 'contentContainerStyle',
indicatorClassName: 'indicatorStyle',
});
cssInterop(UIActionsheet.VirtualizedList, {
className: 'style',
ListFooterComponentClassName: 'ListFooterComponentStyle',
ListHeaderComponentClassName: 'ListHeaderComponentStyle',
contentContainerClassName: 'contentContainerStyle',
indicatorClassName: 'indicatorStyle',
});
cssInterop(UIActionsheet.FlatList, {
className: 'style',
ListFooterComponentClassName: 'ListFooterComponentStyle',
ListHeaderComponentClassName: 'ListHeaderComponentStyle',
columnWrapperClassName: 'columnWrapperStyle',
contentContainerClassName: 'contentContainerStyle',
indicatorClassName: 'indicatorStyle',
});
cssInterop(UIActionsheet.SectionList, { className: 'style' });
cssInterop(UIActionsheet.SectionHeaderText, { className: 'style' });
cssInterop(PrimitiveIcon, {
className: {
target: 'style',
nativeStyleToProp: {
height: true,
width: true,
fill: true,
color: 'classNameColor',
stroke: true,
},
},
});
const actionsheetStyle = tva({ base: 'w-full h-full web:pointer-events-none' });
const actionsheetContentStyle = tva({
base: 'items-center rounded-tl-3xl rounded-tr-3xl p-5 pt-2 bg-background-0 web:pointer-events-auto web:select-none shadow-hard-5 border border-b-0 border-outline-100 pb-safe',
});
const actionsheetItemStyle = tva({
base: 'w-full flex-row items-center p-3 rounded-sm data-[disabled=true]:opacity-40 data-[disabled=true]:web:pointer-events-auto data-[disabled=true]:web:cursor-not-allowed hover:bg-background-50 active:bg-background-100 data-[focus=true]:bg-background-100 web:data-[focus-visible=true]:bg-background-100 web:data-[focus-visible=true]:outline-indicator-primary gap-2',
});
const actionsheetItemTextStyle = tva({
base: 'text-typography-700 font-normal font-body',
variants: {
isTruncated: {
true: '',
},
bold: {
true: 'font-bold',
},
underline: {
true: 'underline',
},
strikeThrough: {
true: 'line-through',
},
size: {
'2xs': 'text-2xs',
'xs': 'text-xs',
'sm': 'text-sm',
'md': 'text-base',
'lg': 'text-lg',
'xl': 'text-xl',
'2xl': 'text-2xl',
'3xl': 'text-3xl',
'4xl': 'text-4xl',
'5xl': 'text-5xl',
'6xl': 'text-6xl',
},
},
});
const actionsheetDragIndicatorStyle = tva({
base: 'w-16 h-1 bg-background-400 rounded-full',
});
const actionsheetDragIndicatorWrapperStyle = tva({
base: 'w-full py-1 items-center',
});
const actionsheetBackdropStyle = tva({
base: 'absolute left-0 top-0 right-0 bottom-0 bg-background-dark web:cursor-default web:pointer-events-auto',
});
const actionsheetScrollViewStyle = tva({
base: 'w-full h-auto',
});
const actionsheetVirtualizedListStyle = tva({
base: 'w-full h-auto',
});
const actionsheetFlatListStyle = tva({
base: 'w-full h-auto',
});
const actionsheetSectionListStyle = tva({
base: 'w-full h-auto',
});
const actionsheetSectionHeaderTextStyle = tva({
base: 'leading-5 font-bold font-heading my-0 text-typography-500 p-3 uppercase',
variants: {
isTruncated: {
true: '',
},
bold: {
true: 'font-bold',
},
underline: {
true: 'underline',
},
strikeThrough: {
true: 'line-through',
},
size: {
'5xl': 'text-5xl',
'4xl': 'text-4xl',
'3xl': 'text-3xl',
'2xl': 'text-2xl',
'xl': 'text-xl',
'lg': 'text-lg',
'md': 'text-base',
'sm': 'text-sm',
'xs': 'text-xs',
},
sub: {
true: 'text-xs',
},
italic: {
true: 'italic',
},
highlight: {
true: 'bg-yellow500',
},
},
defaultVariants: {
size: 'xs',
},
});
const actionsheetIconStyle = tva({
base: 'text-background-500 fill-none',
variants: {
size: {
'2xs': 'h-3 w-3',
'xs': 'h-3.5 w-3.5',
'sm': 'h-4 w-4',
'md': 'w-[18px] h-[18px]',
'lg': 'h-5 w-5',
'xl': 'h-6 w-6',
},
},
});
type IActionsheetProps = VariantProps<typeof actionsheetStyle> &
React.ComponentPropsWithoutRef<typeof UIActionsheet>;
type IActionsheetContentProps = VariantProps<typeof actionsheetContentStyle> &
React.ComponentPropsWithoutRef<typeof UIActionsheet.Content> & {
className?: string;
};
type IActionsheetItemProps = VariantProps<typeof actionsheetItemStyle> &
React.ComponentPropsWithoutRef<typeof UIActionsheet.Item>;
type IActionsheetItemTextProps = VariantProps<typeof actionsheetItemTextStyle> &
React.ComponentPropsWithoutRef<typeof UIActionsheet.ItemText>;
type IActionsheetDragIndicatorProps = VariantProps<
typeof actionsheetDragIndicatorStyle
> &
React.ComponentPropsWithoutRef<typeof UIActionsheet.DragIndicator>;
type IActionsheetDragIndicatorWrapperProps = VariantProps<
typeof actionsheetDragIndicatorWrapperStyle
> &
React.ComponentPropsWithoutRef<typeof UIActionsheet.DragIndicatorWrapper>;
type IActionsheetBackdropProps = VariantProps<typeof actionsheetBackdropStyle> &
React.ComponentPropsWithoutRef<typeof UIActionsheet.Backdrop> & {
className?: string;
};
type IActionsheetScrollViewProps = VariantProps<
typeof actionsheetScrollViewStyle
> &
React.ComponentPropsWithoutRef<typeof UIActionsheet.ScrollView>;
type IActionsheetVirtualizedListProps = VariantProps<
typeof actionsheetVirtualizedListStyle
> &
React.ComponentPropsWithoutRef<typeof UIActionsheet.VirtualizedList>;
type IActionsheetFlatListProps = VariantProps<typeof actionsheetFlatListStyle> &
React.ComponentPropsWithoutRef<typeof UIActionsheet.FlatList>;
type IActionsheetSectionListProps = VariantProps<
typeof actionsheetSectionListStyle
> &
React.ComponentPropsWithoutRef<typeof UIActionsheet.SectionList>;
type IActionsheetSectionHeaderTextProps = VariantProps<
typeof actionsheetSectionHeaderTextStyle
> &
React.ComponentPropsWithoutRef<typeof UIActionsheet.SectionHeaderText>;
type IActionsheetIconProps = VariantProps<typeof actionsheetIconStyle> &
React.ComponentPropsWithoutRef<typeof UIActionsheet.Icon> & {
className?: string;
as?: React.ElementType;
height?: number;
width?: number;
};
const Actionsheet = React.forwardRef<
React.ComponentRef<typeof UIActionsheet>,
IActionsheetProps
>(function Actionsheet({ className, ...props }, ref) {
return (
<UIActionsheet
className={actionsheetStyle({
class: className,
})}
ref={ref}
{...props}
/>
);
});
const ActionsheetContent = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.Content>,
IActionsheetContentProps
>(function ActionsheetContent({ className, ...props }, ref) {
return (
<UIActionsheet.Content
className={actionsheetContentStyle({
class: className,
})}
ref={ref}
{...props}
/>
);
});
const ActionsheetItem = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.Item>,
IActionsheetItemProps
>(function ActionsheetItem({ className, ...props }, ref) {
return (
<UIActionsheet.Item
className={actionsheetItemStyle({
class: className,
})}
ref={ref}
{...props}
/>
);
});
const ActionsheetItemText = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.ItemText>,
IActionsheetItemTextProps
>(function ActionsheetItemText(
{
isTruncated,
bold,
underline,
strikeThrough,
size = 'sm',
className,
...props
},
ref
) {
return (
<UIActionsheet.ItemText
className={actionsheetItemTextStyle({
class: className,
isTruncated: Boolean(isTruncated),
bold: Boolean(bold),
underline: Boolean(underline),
strikeThrough: Boolean(strikeThrough),
size,
})}
ref={ref}
{...props}
/>
);
});
const ActionsheetDragIndicator = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.DragIndicator>,
IActionsheetDragIndicatorProps
>(function ActionsheetDragIndicator({ className, ...props }, ref) {
return (
<UIActionsheet.DragIndicator
className={actionsheetDragIndicatorStyle({
class: className,
})}
ref={ref}
{...props}
/>
);
});
const ActionsheetDragIndicatorWrapper = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.DragIndicatorWrapper>,
IActionsheetDragIndicatorWrapperProps
>(function ActionsheetDragIndicatorWrapper({ className, ...props }, ref) {
return (
<UIActionsheet.DragIndicatorWrapper
className={actionsheetDragIndicatorWrapperStyle({
class: className,
})}
ref={ref}
{...props}
/>
);
});
const ActionsheetBackdrop = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.Backdrop>,
IActionsheetBackdropProps
>(function ActionsheetBackdrop({ className, ...props }, ref) {
return (
<UIActionsheet.Backdrop
initial={{
opacity: 0,
}}
animate={{
opacity: 0.5,
}}
exit={{
opacity: 0,
}}
{...props}
className={actionsheetBackdropStyle({
class: className,
})}
ref={ref}
/>
);
});
const ActionsheetScrollView = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.ScrollView>,
IActionsheetScrollViewProps
>(function ActionsheetScrollView({ className, ...props }, ref) {
return (
<UIActionsheet.ScrollView
className={actionsheetScrollViewStyle({
class: className,
})}
ref={ref}
{...props}
/>
);
});
const ActionsheetVirtualizedList = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.VirtualizedList>,
IActionsheetVirtualizedListProps
>(function ActionsheetVirtualizedList({ className, ...props }, ref) {
return (
<UIActionsheet.VirtualizedList
className={actionsheetVirtualizedListStyle({
class: className,
})}
ref={ref}
{...props}
/>
);
});
const ActionsheetFlatList = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.FlatList>,
IActionsheetFlatListProps
>(function ActionsheetFlatList({ className, ...props }, ref) {
return (
<UIActionsheet.FlatList
className={actionsheetFlatListStyle({
class: className,
})}
ref={ref}
{...props}
/>
);
});
const ActionsheetSectionList = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.SectionList>,
IActionsheetSectionListProps
>(function ActionsheetSectionList({ className, ...props }, ref) {
return (
<UIActionsheet.SectionList
className={actionsheetSectionListStyle({
class: className,
})}
ref={ref}
{...props}
/>
);
});
const ActionsheetSectionHeaderText = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.SectionHeaderText>,
IActionsheetSectionHeaderTextProps
>(function ActionsheetSectionHeaderText(
{
className,
isTruncated,
bold,
underline,
strikeThrough,
size,
sub,
italic,
highlight,
...props
},
ref
) {
return (
<UIActionsheet.SectionHeaderText
className={actionsheetSectionHeaderTextStyle({
class: className,
isTruncated: Boolean(isTruncated),
bold: Boolean(bold),
underline: Boolean(underline),
strikeThrough: Boolean(strikeThrough),
size,
sub: Boolean(sub),
italic: Boolean(italic),
highlight: Boolean(highlight),
})}
ref={ref}
{...props}
/>
);
});
const ActionsheetIcon = React.forwardRef<
React.ComponentRef<typeof UIActionsheet.Icon>,
IActionsheetIconProps
>(function ActionsheetIcon({ className, size = 'sm', ...props }, ref) {
if (typeof size === 'number') {
return (
<UIActionsheet.Icon
ref={ref}
{...props}
className={actionsheetIconStyle({ class: className })}
size={size}
/>
);
} else if (
(props.height !== undefined || props.width !== undefined) &&
size === undefined
) {
return (
<UIActionsheet.Icon
ref={ref}
{...props}
className={actionsheetIconStyle({ class: className })}
/>
);
}
return (
<UIActionsheet.Icon
className={actionsheetIconStyle({
class: className,
size,
})}
ref={ref}
{...props}
/>
);
});
export {
Actionsheet,
ActionsheetContent,
ActionsheetItem,
ActionsheetItemText,
ActionsheetDragIndicator,
ActionsheetDragIndicatorWrapper,
ActionsheetBackdrop,
ActionsheetScrollView,
ActionsheetVirtualizedList,
ActionsheetFlatList,
ActionsheetSectionList,
ActionsheetSectionHeaderText,
ActionsheetIcon,
};
+2 -2
View File
@@ -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,