This commit is contained in:
oneao committed 2025-11-20 22:32:10 +08:00
1 parent 069b4506e4
commit bb780a42ab
10 files changed
+389 -246

No files matched your search

@@ -1,49 +1,140 @@
import { StyleSheet, Text, View } from 'react-native'
import type Animated from 'react-native-reanimated'
import type { SortableGridRenderItem } from 'react-native-sortables'
import type { MoneyCategoryItem } from '@/types/money/category'
import { useCallback, useRef, useState } from 'react'
import { Platform, Animated as RNAnimated, Text, Vibration, View } from 'react-native'
import { useAnimatedRef } from 'react-native-reanimated'
import Sortable from 'react-native-sortables'
import { AButton, ASegmentedTabs, LucideIcon } from '@/components/common/ao'
import MoneyCategoryIcon from '@/components/common/MoneyCategoryIcon'
import CustomHeader from '@/components/custom/Header'
const DATA = [
'Poland',
'Germany',
'France',
'Italy',
'Spain',
'Portugal',
'Greece',
'Great Britain',
'United States',
'Canada',
'Australia',
'New Zealand',
]
// 生成 100 条假数据
function generateFakeCategories(): MoneyCategoryItem[] {
return Array.from({ length: 100 }, (_, index) => ({
id: index + 1,
name: `类别 ${index + 1}`,
icon: 'Coin', // 默认图标
}))
}
export default function Example() {
const [selectedTab, setSelectedTab] = useState(0)
const scrollableRef = useAnimatedRef<Animated.ScrollView>()
const deleteTimeoutRef = useRef<NodeJS.Timeout | null>(null)
const [categories, setCategories] = useState<MoneyCategoryItem[]>(generateFakeCategories())
const [draggingId, setDraggingId] = useState<number | null>(null)
const [isInDeleteZone, setIsInDeleteZone] = useState(false)
const renderItem = useCallback<SortableGridRenderItem<MoneyCategoryItem>>(
({ item }) => (
<View className="flex flex-col items-center justify-center">
<View
style={{
width: 46,
height: 46,
borderRadius: 28,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#F3F4F6',
}}
>
<MoneyCategoryIcon size={24} name={item.icon} />
</View>
<Text style={{ marginBottom: 4, fontSize: 14 }}>{item.name}</Text>
</View>
),
[],
)
// 拖入删除区时启动延迟删除
const handleItemEnter = () => {
if (!draggingId)
return
setIsInDeleteZone(true)
Vibration.vibrate(50)
// 延迟 2 秒删除
deleteTimeoutRef.current = setTimeout(() => {
setCategories(prev => prev.filter(c => c.id !== draggingId))
setDraggingId(null)
setIsInDeleteZone(false)
deleteTimeoutRef.current = null
}, 2000)
}
// 拖出删除区取消删除
const handleItemLeave = () => {
setIsInDeleteZone(false)
if (deleteTimeoutRef.current) {
clearTimeout(deleteTimeoutRef.current)
deleteTimeoutRef.current = null
}
}
export default function Flex() {
return (
<View style={{ flex: 1 }}>
<Sortable.Flex
gap={10}
padding={10}
flexDirection="column"
width="fill"
dragActivationDelay={10}
>
{DATA.map(item => (
<View style={styles.cell} key={item}>
<Text style={{ color: '#fff' }}>{item}</Text>
</View>
))}
</Sortable.Flex>
<View className="flex-1">
<CustomHeader title="类别设置" />
<View className="flex-1 px-3">
<Sortable.MultiZoneProvider>
<Sortable.PortalProvider enabled>
<RNAnimated.ScrollView ref={scrollableRef}>
{/* Grid */}
<Sortable.Grid
columnGap={10}
rowGap={10}
columns={4}
data={categories}
renderItem={renderItem}
scrollableRef={scrollableRef}
autoScrollActivationOffset={200}
showDropIndicator
onDragStart={data => setDraggingId(Number(data.key))}
onDragEnd={() => setDraggingId(null)}
/>
{/* 删除区 */}
<Sortable.BaseZone
className="mt-4"
style={{
height: 42,
borderRadius: 8,
backgroundColor: isInDeleteZone ? '#FF6B6B' : '#FDE2E2',
alignItems: 'center',
justifyContent: 'center',
}}
minActivationDistance={40}
onItemEnter={handleItemEnter}
onItemLeave={handleItemLeave}
>
<Text style={{
color: '#fff',
fontWeight: 'bold',
fontSize: isInDeleteZone ? 18 : 16,
}}
>
拖到这里删除
</Text>
</Sortable.BaseZone>
{/* 新增按钮 */}
<View className="mt-3 mb-6 flex-row justify-end">
<AButton
className="rounded-full px-4 py-2"
leftIcon={{ icon: 'Plus', color: '#fff', size: 18 }}
>
添加分类
</AButton>
</View>
</RNAnimated.ScrollView>
</Sortable.PortalProvider>
</Sortable.MultiZoneProvider>
</View>
</View>
)
}
const styles = StyleSheet.create({
cell: {
backgroundColor: '#36877F',
borderRadius: 9999,
padding: 10,
alignItems: 'center',
justifyContent: 'center',
alignSelf: 'stretch', // ✅ 关键:让子元素撑满父容器宽度
width: '100%',
},
})
+22 -19
View File
@@ -5,6 +5,7 @@ import { Stack, useRouter } from 'expo-router'
import * as SplashScreen from 'expo-splash-screen'
import { StatusBar } from 'expo-status-bar'
import { useEffect } from 'react'
import { GestureHandlerRootView } from 'react-native-gesture-handler'
import { KeyboardProvider } from 'react-native-keyboard-controller'
import { MenuProvider } from 'react-native-popup-menu'
import { AButton } from '@/components/common/ao'
@@ -15,7 +16,6 @@ import { useUserStore } from '@/store/user'
import { ToastProviderWrapper } from '@/utils/toast'
import '@/styles/global.css'
import 'react-native-reanimated'
import 'react-native-gesture-handler'
SplashScreen.preventAutoHideAsync()
@@ -74,25 +74,28 @@ export default function RootLayout() {
<GluestackUIProvider>
<ToastProvider>
<ToastProviderWrapper>
<Stack
screenOptions={{
headerTitleAlign: 'center',
animation: 'slide_from_right',
contentStyle: {
backgroundColor: '#fff',
},
}}
>
<Stack.Screen
name="(tabs)"
options={{
headerShown: false,
}}
/>
<GestureHandlerRootView>
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="auto" />
<Stack
screenOptions={{
headerTitleAlign: 'center',
animation: 'slide_from_right',
contentStyle: {
backgroundColor: '#fff',
},
}}
>
<Stack.Screen
name="(tabs)"
options={{
headerShown: false,
}}
/>
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="auto" />
</GestureHandlerRootView>
</ToastProviderWrapper>
</ToastProvider>
</GluestackUIProvider>
@@ -0,0 +1,207 @@
import type { SortableGridRenderItem } from 'react-native-sortables'
import type { MoneyCategoryItem } from '@/types/money/category'
import { useCallback, useEffect, useState } from 'react'
import { Platform, Text, Vibration, View } from 'react-native'
import Animated, { useAnimatedRef } from 'react-native-reanimated'
import Sortable, { DragStartParams } from 'react-native-sortables'
import { AButton, ASegmentedTabs, LucideIcon } from '@/components/common/ao'
import MoneyCategoryIcon from '@/components/common/MoneyCategoryIcon'
import CustomHeader from '@/components/custom/Header'
import { useMoneyCategoryStore } from '@/store/moneyCategory'
export default function Example() {
const [selectedTab, setSelectedTab] = useState(0)
const moneyCategoryStore = useMoneyCategoryStore()
const scrollableRef = useAnimatedRef<Animated.ScrollView>()
const [categories, setCategories] = useState<MoneyCategoryItem[]>([])
const [draggingId, setDraggingId] = useState<string>('')
const [draggingDeleteZone, setDraggingDeleteZone] = useState<boolean>(false)
const [deleteTimer, setDeleteTimer] = useState<number | null>(null)
useEffect(() => {
setCategories(selectedTab === 0 ? moneyCategoryStore.getExpenseCategories() : moneyCategoryStore.getIncomeCategories())
}, [selectedTab])
const renderItem = useCallback<SortableGridRenderItem<MoneyCategoryItem>>(
({ item }) => (
<View
className="flex flex-col items-center justify-center"
>
<View
style={{
width: 46,
height: 46,
borderRadius: 28,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#F3F4F6',
}}
>
<MoneyCategoryIcon size={24} name={item.icon} />
</View>
<Text style={{
marginBottom: 4,
fontSize: 14,
}}
>
{item.name}
</Text>
</View>
),
[],
)
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">
<ASegmentedTabs
defaultIndex={selectedTab}
onChange={(index) => {
setSelectedTab(index)
}}
tabs={[
{
label: '收入',
},
{
label: '支出',
},
]}
style={{
width: 140,
}}
/>
<View className="flex flex-row gap-2">
<AButton
className="rounded-full"
leftIcon={{
icon: 'Plus',
className: 'mr-0',
color: '#fff',
clickable: false,
size: 18,
}}
>
添加分类
</AButton>
</View>
</View>
<View className="flex-1 px-3">
<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={() => {
setDraggingId('')
}}
/>
</Animated.ScrollView>
<Sortable.BaseZone
onItemEnter={() => {
if (!draggingId)
return
setDraggingDeleteZone(true)
Vibration.vibrate(50)
// 设置 1 秒后删除
const timer = setTimeout(() => {
setCategories(prev => prev.filter(c => c.id !== Number(draggingId)))
setDraggingId('')
setDraggingDeleteZone(false)
setDeleteTimer(null)
}, 1000)
setDeleteTimer(timer as unknown as number)
}}
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-12 rounded-md gap-2 ${
draggingDeleteZone ? 'bg-red-600' : 'bg-red-400'
}`}
>
<LucideIcon icon="Trash" size={18} color="#fff" />
<Text className="text-base text-white ">
拖到这里删除
</Text>
</View>
</Sortable.BaseZone>
<View className="flex flex-row mb-8 mt-4 w-full gap-4">
<AButton
className="flex-1"
variant="outline"
leftIcon={{
icon: 'RefreshCcw',
className: 'mr-0',
color: '#333',
clickable: false,
size: 18,
}}
>
恢复
</AButton>
<AButton
className="flex-1"
leftIcon={{
icon: 'Save',
className: 'mr-0',
color: '#fff',
clickable: false,
size: 18,
}}
>
保存
</AButton>
</View>
</Sortable.PortalProvider>
</Sortable.MultiZoneProvider>
</View>
</View>
</View>
)
}
@@ -148,7 +148,12 @@ export default function MoenyRecordEditor() {
<PagerTabs
selectedIndex={selectedCategory?.type}
left={(
<TouchableOpacity className="w-[30%] flex items-center px-4 flex-row">
<TouchableOpacity
className="w-[30%] flex items-center px-4 flex-row"
onPress={() => {
router.navigate('/money/category/setting')
}}
>
<LucideIcon icon="LayoutList" />
<Text className="ml-1">管理</Text>
</TouchableOpacity>
@@ -7,6 +7,7 @@ import FormItem from './FormItem'
import Input from './Input'
import LucideIcon from './LucideIcon'
import Modal from './Modal'
import SegmentedTabs from './SegmentedTabs'
export {
Button as AButton,
@@ -19,5 +20,6 @@ export {
FormRef as AFormRef,
Input as AInput,
Modal as AModal,
SegmentedTabs as ASegmentedTabs,
LucideIcon,
}
@@ -70,14 +70,17 @@ export default function CustomHeader(props: CustomHeaderProps) {
style,
]}
>
{
left || (
<View className="w-10 h-full justify-center">
<TouchableOpacity onPress={() => router.back()}>
<LucideIcon icon="ArrowLeft" size={24} clickable={false} />
</TouchableOpacity>
</View>
)
}
{/* 左侧 */}
<View className="w-10 h-full justify-center">
{left || (
<TouchableOpacity onPress={() => router.back()}>
<LucideIcon icon="ArrowLeft" size={24} clickable={false} />
</TouchableOpacity>
)}
</View>
{/* 标题 */}
<View className="flex-1 items-center justify-center">
@@ -86,15 +89,15 @@ export default function CustomHeader(props: CustomHeaderProps) {
: title}
</View>
{/* 右侧 */}
<View className="w-10 h-full items-center justify-center">
{right || (
{right || (
<View className="w-10 h-full items-center justify-center">
<Image
source={require('@/static/images/logo.png')}
style={{ width: 22, height: 22 }}
/>
)}
</View>
</View>
)}
</View>
),
}}
@@ -1,168 +0,0 @@
import type { ReactNode } from 'react'
import type { LayoutChangeEvent } from 'react-native'
import React, { createContext, useContext, useEffect, useRef, useState } from 'react'
import {
Animated,
LayoutChangeEvent as RNLayoutChangeEvent,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native'
import PagerView from 'react-native-pager-view'
interface TabsContextType {
pagerRef: React.RefObject<PagerView>
scrollX: Animated.Value
index: number
setIndex: (i: number) => void
}
const TabsContext = createContext<TabsContextType | null>(null)
interface TabsProviderProps {
children: ReactNode
}
export function TabsProvider({ children }: TabsProviderProps) {
const pagerRef = useRef<PagerView>(null!)
const scrollX = useRef(new Animated.Value(0)).current
const [index, setIndex] = useState(0)
return (
<TabsContext.Provider value={{ pagerRef, scrollX, index, setIndex }}>
{children}
</TabsContext.Provider>
)
}
// ---------------- Tabs (Header) ----------------
interface TabsProps {
tabs: string[]
leftContent?: ReactNode
rightContent?: ReactNode
}
function useSafeTabsContext(): TabsContextType {
const ctx = useContext(TabsContext)
if (!ctx) {
const pagerRef = useRef<PagerView>(null!)
const scrollX = useRef(new Animated.Value(0)).current
const [index, setIndex] = useState(0)
return { pagerRef, scrollX, index, setIndex }
}
return ctx
}
TabsProvider.Tabs = function Tabs({ tabs, leftContent, rightContent }: TabsProps) {
const ctx = useSafeTabsContext()
const tabLayouts = useRef<{ x: number, width: number }[]>([])
const scrollRef = useRef<ScrollView>(null)
const onTabLayout = (e: LayoutChangeEvent, i: number) => {
const { x, width } = e.nativeEvent.layout
tabLayouts.current[i] = { x, width }
}
const measured = tabLayouts.current.length === tabs.length
const underlineX = measured
? ctx.scrollX.interpolate({
inputRange: tabs.map((_, i) => i),
outputRange: tabLayouts.current.map(t => t.x),
})
: 0
const underlineWidth = measured
? ctx.scrollX.interpolate({
inputRange: tabs.map((_, i) => i),
outputRange: tabLayouts.current.map(t => t.width),
})
: 0
// 自动滚动 ScrollView 保持选中 Tab 可见
useEffect(() => {
if (!measured || !scrollRef.current)
return
const layout = tabLayouts.current[ctx.index]
if (!layout)
return
scrollRef.current.scrollTo({ x: Math.max(layout.x - 50, 0), animated: true })
}, [ctx.index, measured])
return (
<View style={{ flexDirection: 'row', alignItems: 'center', borderBottomWidth: 1, borderColor: '#eee' }}>
{leftContent && <View>{leftContent}</View>}
<ScrollView
ref={scrollRef}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ flexDirection: 'row', position: 'relative' }}
style={{ flex: 1 }}
>
{tabs.map((tab, i) => (
<TouchableOpacity
key={i}
onPress={() => {
ctx.pagerRef.current?.setPage(i)
ctx.setIndex(i)
}}
onLayout={e => onTabLayout(e, i)}
style={{ paddingHorizontal: 20, paddingVertical: 12 }}
>
<Text style={{ color: ctx.index === i ? '#000' : '#999' }}>{tab}</Text>
</TouchableOpacity>
))}
{measured && (
<Animated.View
style={[
styles.underline,
{ width: underlineWidth, transform: [{ translateX: underlineX }] },
]}
/>
)}
</ScrollView>
{rightContent && <View>{rightContent}</View>}
</View>
)
}
// ---------------- Content ----------------
TabsProvider.Content = function Content({ tabs }: { tabs: string[] }) {
const ctx = useSafeTabsContext()
return (
<PagerView
ref={ctx.pagerRef}
style={{ flex: 1 }}
initialPage={0}
onPageScroll={e =>
ctx.scrollX.setValue(e.nativeEvent.position + e.nativeEvent.offset)}
onPageSelected={e => ctx.setIndex(e.nativeEvent.position)}
>
{tabs.map((tab, i) => (
<View key={i} style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ fontSize: 20 }}>
{tab}
{' '}
页面内容
</Text>
</View>
))}
</PagerView>
)
}
const styles = StyleSheet.create({
underline: {
position: 'absolute',
bottom: 0,
height: 2,
backgroundColor: '#007AFF',
},
})
@@ -30,8 +30,8 @@ function isWhiteListed(url: string) {
}
const alovaInstance = createAlova({
// baseURL: 'http://192.168.5.6:8085/app',
baseURL: 'http://192.168.1.136:8085/app',
baseURL: 'http://192.168.5.6:8086/app',
// baseURL: 'http://192.168.1.136:8085/app',
requestAdapter: axiosRequestAdapter(),
timeout: 5000,
cacheFor: null,
@@ -9,7 +9,7 @@ export interface MoneyRecordBase {
remark: string
record_time: string
create_time: string
update_time: string
update_time?: string
}
// 查询条件(请求参数)
@@ -1,5 +1,5 @@
server:
port: 8085
port: 8086
baseUrl: '/app'
snowflake: