u
This commit is contained in:
1 parent
63b168affc
commit
069b4506e4
29 files changed
+1050
-1089
No files matched your search
@@ -14,7 +14,7 @@
|
||||
"@gluestack-ui/core": "^3.0.12",
|
||||
"@gluestack-ui/utils": "^3.0.11",
|
||||
"@gluestack/ui-next-adapter": "^3.0.3",
|
||||
"@legendapp/motion": "^2.4.0",
|
||||
"@legendapp/motion": "2.4.0",
|
||||
"@react-native-async-storage/async-storage": "2.2.0",
|
||||
"@react-native-community/datetimepicker": "8.4.4",
|
||||
"@react-native-segmented-control/segmented-control": "2.5.7",
|
||||
@@ -47,8 +47,8 @@
|
||||
"react-native-reanimated": "~4.1.3",
|
||||
"react-native-safe-area-context": "~5.6.1",
|
||||
"react-native-screens": "~4.16.0",
|
||||
"react-native-sortables": "^1.9.3",
|
||||
"react-native-svg": "15.12.1",
|
||||
"react-native-tab-view": "^4.2.0",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-worklets": "0.5.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
|
||||
+39
-18
@@ -21,7 +21,7 @@ importers:
|
||||
specifier: ^3.0.3
|
||||
version: 3.0.3
|
||||
'@legendapp/motion':
|
||||
specifier: ^2.4.0
|
||||
specifier: 2.4.0
|
||||
version: 2.4.0(nativewind@4.2.1(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.6.2(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-svg@15.12.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)(tailwindcss@3.4.18(yaml@2.8.1)))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
'@react-native-async-storage/async-storage':
|
||||
specifier: 2.2.0
|
||||
@@ -119,12 +119,12 @@ importers:
|
||||
react-native-screens:
|
||||
specifier: ~4.16.0
|
||||
version: 4.16.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react-native-sortables:
|
||||
specifier: ^1.9.3
|
||||
version: 1.9.3(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react-native-svg:
|
||||
specifier: 15.12.1
|
||||
version: 15.12.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react-native-tab-view:
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0(react-native-pager-view@6.9.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react-native-web:
|
||||
specifier: ^0.21.0
|
||||
version: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
@@ -3911,48 +3911,56 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-gnu@1.30.2:
|
||||
resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.27.0:
|
||||
resolution: {integrity: sha512-rCGBm2ax7kQ9pBSeITfCW9XSVF69VX+fm5DIpvDZQl4NnQoMQyRwhZQm9pd59m8leZ1IesRqWk2v/DntMo26lg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.30.2:
|
||||
resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.27.0:
|
||||
resolution: {integrity: sha512-Dk/jovSI7qqhJDiUibvaikNKI2x6kWPN79AQiD/E/KeQWMjdGe9kw51RAgoWFDi0coP4jinaH14Nrt/J8z3U4A==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.30.2:
|
||||
resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
lightningcss-linux-x64-musl@1.27.0:
|
||||
resolution: {integrity: sha512-QKjTxXm8A9s6v9Tg3Fk0gscCQA1t/HMoF7Woy1u68wCk5kS4fR+q3vXa1p3++REW784cRAtkYKrPy6JKibrEZA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-linux-x64-musl@1.30.2:
|
||||
resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.27.0:
|
||||
resolution: {integrity: sha512-/wXegPS1hnhkeG4OXQKEMQeJd48RDC3qdh+OA8pCuOPCyvnm/yEayrJdJVqzBsqpy1aJklRCVxscpFur80o6iQ==}
|
||||
@@ -4836,6 +4844,11 @@ packages:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
react-native-haptic-feedback@2.3.3:
|
||||
resolution: {integrity: sha512-svS4D5PxfNv8o68m9ahWfwje5NqukM3qLS48+WTdhbDkNUkOhP9rDfDSRHzlhk4zq+ISjyw95EhLeh8NkKX5vQ==}
|
||||
peerDependencies:
|
||||
react-native: '>=0.60.0'
|
||||
|
||||
react-native-iconfont-cli@2.2.4:
|
||||
resolution: {integrity: sha512-qTjfHG2f/sc3/YKWZJvIJk/SwiTu8C9/SvtoLxRX+GM/6pvOJq0ge6rcV3vTh4s3QJDuNDOD6+a4hGD6vv2vSg==}
|
||||
hasBin: true
|
||||
@@ -4892,6 +4905,14 @@ packages:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
react-native-sortables@1.9.3:
|
||||
resolution: {integrity: sha512-VLhW9+3AVEaJNwwQSgN+n/Qe+YRB0C0mNWTjHhyzcZ+YjY4BmJao4bZxl5lD6EsfqZ1Ij6B2ZdxjNlSkUXrvow==}
|
||||
peerDependencies:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
react-native-gesture-handler: '>=2.0.0'
|
||||
react-native-reanimated: '>=3.0.0'
|
||||
|
||||
react-native-svg-transformer@1.5.2:
|
||||
resolution: {integrity: sha512-eW4hOtrd30s4SRdN4X1XYxTCu1czsxDGQKmfQ3RFbZMN5yw4ZmiKGGr+lXbQW4uDGZvSoGd9FHL1f+rgGoKg8Q==}
|
||||
peerDependencies:
|
||||
@@ -4904,13 +4925,6 @@ packages:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
react-native-tab-view@4.2.0:
|
||||
resolution: {integrity: sha512-TUbh7Yr0tE/99t1pJQLbQ+4/Px67xkT7/r3AhfV+93Q3WoUira0Lx7yuKUP2C118doqxub8NCLERwcqsHr29nQ==}
|
||||
peerDependencies:
|
||||
react: '>= 18.2.0'
|
||||
react-native: '*'
|
||||
react-native-pager-view: '>= 6.0.0'
|
||||
|
||||
react-native-web@0.21.2:
|
||||
resolution: {integrity: sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==}
|
||||
peerDependencies:
|
||||
@@ -11782,6 +11796,11 @@ snapshots:
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)
|
||||
|
||||
react-native-haptic-feedback@2.3.3(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)):
|
||||
dependencies:
|
||||
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)
|
||||
optional: true
|
||||
|
||||
react-native-iconfont-cli@2.2.4(react-native-svg@15.12.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
colors: 1.4.0
|
||||
@@ -11844,6 +11863,15 @@ snapshots:
|
||||
react-native-is-edge-to-edge: 1.2.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
warn-once: 0.1.1
|
||||
|
||||
react-native-sortables@1.9.3(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)
|
||||
react-native-gesture-handler: 2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
react-native-reanimated: 4.1.5(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
optionalDependencies:
|
||||
react-native-haptic-feedback: 2.3.3(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))
|
||||
|
||||
react-native-svg-transformer@1.5.2(react-native-svg@15.12.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@svgr/core': 8.1.0(typescript@5.9.3)
|
||||
@@ -11864,13 +11892,6 @@ snapshots:
|
||||
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)
|
||||
warn-once: 0.1.1
|
||||
|
||||
react-native-tab-view@4.2.0(react-native-pager-view@6.9.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)
|
||||
react-native-pager-view: 6.9.1(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
use-latest-callback: 0.2.6(react@19.1.0)
|
||||
|
||||
react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.4
|
||||
|
||||
@@ -1,33 +1,21 @@
|
||||
import { Text, View } from 'react-native'
|
||||
import PagerTabs from '@/components/custom/PagerTabs'
|
||||
import { ACell, ACellGroup } from '@/components/common/ao'
|
||||
|
||||
export default function Demo() {
|
||||
return (
|
||||
<View className="flex-1">
|
||||
<PagerTabs
|
||||
left={(
|
||||
<View className="w-[20%]">
|
||||
<Text>Left</Text>
|
||||
</View>
|
||||
)}
|
||||
right={(
|
||||
<View className="w-[20%]">
|
||||
<Text>right</Text>
|
||||
</View>
|
||||
)}
|
||||
routes={[
|
||||
{
|
||||
key: 'tab1',
|
||||
title: 'Tab 1',
|
||||
element: <Text className="text-lg">第一个页面内容</Text>,
|
||||
},
|
||||
{
|
||||
key: 'tab2',
|
||||
title: 'Tab 2',
|
||||
element: <Text className="text-lg">第二个页面内容</Text>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
className="flex-1"
|
||||
style={{
|
||||
marginTop: 34,
|
||||
}}
|
||||
>
|
||||
<ACellGroup >
|
||||
<ACell leftIcon='Zap' title="测试" rightText="测试右边" isLink />
|
||||
<ACell leftIcon="Airplay" title="测试" rightText="测试右边" isLink />
|
||||
<ACell leftIcon="Airplay" title="测试" rightText="测试右边" isLink />
|
||||
<ACell leftIcon="Airplay" title="测试" rightText="测试右边" isLink />
|
||||
<ACell leftIcon="Airplay" title="测试" rightText="测试右边" isLink />
|
||||
</ACellGroup>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,49 @@
|
||||
import { useRouter } from 'expo-router'
|
||||
import { Text, View } from 'react-native'
|
||||
import { AButton } from '@/components/common/ao'
|
||||
import { StyleSheet, Text, View } from 'react-native'
|
||||
import Sortable from 'react-native-sortables'
|
||||
|
||||
export default function Love() {
|
||||
const router = useRouter()
|
||||
const DATA = [
|
||||
'Poland',
|
||||
'Germany',
|
||||
'France',
|
||||
'Italy',
|
||||
'Spain',
|
||||
'Portugal',
|
||||
'Greece',
|
||||
'Great Britain',
|
||||
'United States',
|
||||
'Canada',
|
||||
'Australia',
|
||||
'New Zealand',
|
||||
]
|
||||
|
||||
export default function Flex() {
|
||||
return (
|
||||
<View className="mt-20">
|
||||
<AButton onPress={() => {
|
||||
router.navigate('/auth')
|
||||
}}
|
||||
<View style={{ flex: 1 }}>
|
||||
<Sortable.Flex
|
||||
gap={10}
|
||||
padding={10}
|
||||
flexDirection="column"
|
||||
width="fill"
|
||||
dragActivationDelay={10}
|
||||
>
|
||||
跳转到登录页面
|
||||
</AButton>
|
||||
{DATA.map(item => (
|
||||
<View style={styles.cell} key={item}>
|
||||
<Text style={{ color: '#fff' }}>{item}</Text>
|
||||
</View>
|
||||
))}
|
||||
</Sortable.Flex>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
cell: {
|
||||
backgroundColor: '#36877F',
|
||||
borderRadius: 9999,
|
||||
padding: 10,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
alignSelf: 'stretch', // ✅ 关键:让子元素撑满父容器宽度
|
||||
width: '100%',
|
||||
},
|
||||
})
|
||||
@@ -1,630 +0,0 @@
|
||||
import type { MoneyCategoryItem } from '@/types/money/category'
|
||||
import type { MoneyRecordBase, MoneyRecordItem } from '@/types/money/record'
|
||||
import Big from 'big.js'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { FlatList, Keyboard, Pressable, StyleSheet, Text, TouchableWithoutFeedback, View } from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { AInput, LucideIcon } from '@/components/common/ao'
|
||||
import { ADatePicker } from '@/components/common/ao/index'
|
||||
import SegmentedTabs from '@/components/common/ao/SegmentedTabs'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerBackdrop,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
} from '@/components/ui/drawer'
|
||||
import { useKeyboardHeight } from '@/hooks/useKeyboardHeight'
|
||||
import { queryMoneyCategoryApi } from '@/request/api/money/category'
|
||||
import { insertMoneyRecordApi, updateMoneyRecordApi } from '@/request/api/money/record'
|
||||
import { useMoneyCategoryStore } from '@/store/moneyCategory'
|
||||
import { getCurrentDate, toDateString } from '@/utils/time'
|
||||
import { toast } from '@/utils/toast'
|
||||
import MoneyCategoryIcon from './MoneyCategoryIcon'
|
||||
|
||||
interface AddMoneyDrawerProps {
|
||||
isOpen: boolean
|
||||
updateData?: MoneyRecordItem | null
|
||||
onClose?: () => void
|
||||
onConfirm?: (result: MoneyRecordItem) => void
|
||||
}
|
||||
|
||||
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>
|
||||
) },
|
||||
]
|
||||
|
||||
export default function AddMoneyDrawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
updateData,
|
||||
}: AddMoneyDrawerProps) {
|
||||
const moneyCategoryStore = useMoneyCategoryStore()
|
||||
|
||||
const insets = useSafeAreaInsets()
|
||||
// 选择顺序:0 支出 1收入
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
// 自定义键盘
|
||||
const [showKeydown, setShowKeydown] = useState(false)
|
||||
// 自定义键盘计算过程
|
||||
const [keydownInput, setKeydownInput] = useState('')
|
||||
// 日期选择
|
||||
const [showDatePicker, setShowDatePicker] = useState(false)
|
||||
// 备注聚焦
|
||||
const [remarkIsFocused, setRemarkIsFocused] = useState(false)
|
||||
// 选中分类
|
||||
const [selectedCategory, setSelectedCategory] = useState<MoneyCategoryItem | null>(null)
|
||||
|
||||
const keyboardHeight = useKeyboardHeight()
|
||||
|
||||
const defaultMoneyRecord: MoneyRecordBase = {
|
||||
id: -1,
|
||||
category_id: -1,
|
||||
user_id: -1,
|
||||
amount: '0.00',
|
||||
remark: '',
|
||||
record_time: getCurrentDate(),
|
||||
create_time: '',
|
||||
}
|
||||
|
||||
const [moneyRecord, setMoneyRecord] = useState<MoneyRecordBase>(defaultMoneyRecord)
|
||||
|
||||
function handleSelectCategory(item: MoneyCategoryItem) {
|
||||
setSelectedCategory(item)
|
||||
|
||||
setShowKeydown(true)
|
||||
}
|
||||
|
||||
const fetchCategories = async () => {
|
||||
try {
|
||||
if (moneyCategoryStore.categories.length === 0) {
|
||||
const res = await queryMoneyCategoryApi()
|
||||
if (res.isSuccess) {
|
||||
moneyCategoryStore.setCategories(res.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.error('获取分类失败', err)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (updateData) {
|
||||
const type = updateData.category_type
|
||||
setSelectedIndex(type)
|
||||
setMoneyRecord({
|
||||
...updateData,
|
||||
record_time: toDateString(updateData.record_time),
|
||||
})
|
||||
setShowKeydown(true)
|
||||
|
||||
if (type === 0) {
|
||||
// 支出
|
||||
moneyCategoryStore.expense.forEach((item) => {
|
||||
if (item.id === updateData.category_id) {
|
||||
setSelectedCategory(item)
|
||||
}
|
||||
})
|
||||
}
|
||||
else if (type === 1) {
|
||||
// 收入
|
||||
moneyCategoryStore.income.forEach((item) => {
|
||||
if (item.id === updateData.category_id) {
|
||||
setSelectedCategory(item)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
function resetData() {
|
||||
// 清空键盘记录
|
||||
setKeydownInput('')
|
||||
// 关闭默认键盘
|
||||
setShowKeydown(false)
|
||||
// 设置默认值
|
||||
setMoneyRecord(defaultMoneyRecord)
|
||||
// 选中支出
|
||||
setSelectedIndex(0)
|
||||
// 关闭日期
|
||||
setShowDatePicker(false)
|
||||
// 清空选中类别
|
||||
setSelectedCategory(null)
|
||||
}
|
||||
|
||||
// 关闭整个drawer处理
|
||||
function onMainDrawerClose() {
|
||||
resetData()
|
||||
|
||||
onClose?.()
|
||||
}
|
||||
|
||||
// 计算过程
|
||||
const handlePress = (key: string | number) => {
|
||||
setKeydownInput((prev) => {
|
||||
let newInput = prev
|
||||
|
||||
if (key === 'del') {
|
||||
newInput = prev.slice(0, -1)
|
||||
}
|
||||
else if (key === 'clear') {
|
||||
setMoneyRecord({
|
||||
...moneyRecord,
|
||||
amount: '0.00',
|
||||
remark: '',
|
||||
record_time: getCurrentDate(),
|
||||
})
|
||||
return ''
|
||||
}
|
||||
else if (key === '/') {
|
||||
saveRecord()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 计算结果
|
||||
try {
|
||||
const computed = calculateWithBig(newInput)
|
||||
setMoneyRecord({
|
||||
...moneyRecord,
|
||||
amount: computed,
|
||||
})
|
||||
}
|
||||
catch (e) {
|
||||
setMoneyRecord({
|
||||
...moneyRecord,
|
||||
amount: '0',
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 保存
|
||||
function saveRecord() {
|
||||
if (selectedCategory === null) {
|
||||
toast.show({
|
||||
title: '请选择分类',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (moneyRecord.amount === '0.00') {
|
||||
toast.show({
|
||||
title: '请输入金额',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 去掉金额负号
|
||||
const amount = moneyRecord.amount.replace(/^-+/, '')
|
||||
|
||||
const isUpdate = updateData && updateData !== null
|
||||
|
||||
if (isUpdate) {
|
||||
updateMoneyRecordApi({
|
||||
id: moneyRecord.id,
|
||||
category_id: selectedCategory.id,
|
||||
amount,
|
||||
remark: moneyRecord.remark,
|
||||
record_time: moneyRecord.record_time,
|
||||
}).then((res) => {
|
||||
if (res.isSuccess) {
|
||||
const result: MoneyRecordItem = {
|
||||
...moneyRecord,
|
||||
category_id: selectedCategory.id,
|
||||
category_name: selectedCategory.name,
|
||||
category_icon: selectedCategory.icon,
|
||||
category_type: selectedCategory.type,
|
||||
}
|
||||
|
||||
onConfirm?.(result)
|
||||
|
||||
resetData()
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
})
|
||||
}
|
||||
else {
|
||||
insertMoneyRecordApi({
|
||||
...moneyRecord,
|
||||
amount,
|
||||
category_id: selectedCategory.id,
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.isSuccess) {
|
||||
const result: MoneyRecordItem = {
|
||||
...res.data,
|
||||
category_name: selectedCategory.name,
|
||||
category_icon: selectedCategory.icon,
|
||||
category_type: selectedCategory.type,
|
||||
}
|
||||
|
||||
onConfirm?.(result)
|
||||
resetData()
|
||||
}
|
||||
else {
|
||||
toast.show({
|
||||
title: res.message || '保存失败',
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.log(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
isOpen={isOpen}
|
||||
size="full"
|
||||
anchor="bottom"
|
||||
onClose={onMainDrawerClose}
|
||||
>
|
||||
<DrawerBackdrop />
|
||||
|
||||
<DrawerContent
|
||||
className="p-0 flex-1"
|
||||
|
||||
onAnimationComplete={() => {
|
||||
fetchCategories()
|
||||
}}
|
||||
>
|
||||
<DrawerHeader
|
||||
className="flex items-center justify-between"
|
||||
style={{ paddingTop: insets.top + 8, paddingLeft: 16, paddingRight: 16 }}
|
||||
>
|
||||
<View className="w-1" />
|
||||
<SegmentedTabs
|
||||
value={selectedIndex}
|
||||
tabs={[{ label: '支出' }, { label: '收入' }]}
|
||||
onChange={setSelectedIndex}
|
||||
style={{ width: 180 }}
|
||||
className="mx-auto"
|
||||
/>
|
||||
<Pressable onPress={onMainDrawerClose}>
|
||||
<Text className="text-gray-500 font-medium">取消</Text>
|
||||
</Pressable>
|
||||
</DrawerHeader>
|
||||
|
||||
<View
|
||||
style={{
|
||||
flex: 1,
|
||||
paddingLeft: 8,
|
||||
paddingRight: 8,
|
||||
marginTop: 16,
|
||||
backgroundColor: '#fff',
|
||||
}}
|
||||
>
|
||||
{(() => {
|
||||
const list = selectedIndex === 1 ? moneyCategoryStore.income : moneyCategoryStore.expense
|
||||
|
||||
const memoItems = useMemo(() => {
|
||||
const items = [...list]
|
||||
const remainder = items.length % 4
|
||||
if (remainder > 0) {
|
||||
const emptyCount = 4 - remainder
|
||||
for (let i = 0; i < emptyCount; i++) {
|
||||
// 补充占位对象
|
||||
items.push({ id: `empty-${i}` } as any)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}, [list])
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={memoItems}
|
||||
numColumns={4}
|
||||
keyExtractor={item => item.id.toString()}
|
||||
showsVerticalScrollIndicator={false}
|
||||
renderItem={({ item }) => {
|
||||
if (!('name' in item)) {
|
||||
return <View style={{ flex: 1 / 4, margin: 4 }} />
|
||||
}
|
||||
|
||||
const isSelected = selectedCategory?.id === item.id
|
||||
return (
|
||||
<Pressable
|
||||
style={{ flex: 1 / 4, margin: 4, alignItems: 'center' }}
|
||||
onPress={() => handleSelectCategory(item)}
|
||||
>
|
||||
<View
|
||||
style={{
|
||||
width: 46,
|
||||
height: 46,
|
||||
borderRadius: 28,
|
||||
marginTop: 4,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: isSelected ? '#BFDBFE' : '#F3F4F6',
|
||||
}}
|
||||
>
|
||||
<MoneyCategoryIcon name={item.icon} size={24} />
|
||||
</View>
|
||||
<Text style={{ marginBottom: 4, fontSize: 14, textAlign: 'center' }}>
|
||||
{item.name}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</View>
|
||||
|
||||
{showKeydown && (
|
||||
<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">{moneyRecord.record_time}</Text>
|
||||
<LucideIcon icon="ChevronDown" size={12} color="#aaa" clickable={false} />
|
||||
</Pressable>
|
||||
<Text className="text-3xl text-blue-700 text-right">{moneyRecord.amount}</Text>
|
||||
</View>
|
||||
|
||||
<Text className="text-gray-400 text-right">{keydownInput}</Text>
|
||||
<AInput
|
||||
value={moneyRecord.remark}
|
||||
placeholder="备注"
|
||||
className="bg-[#f2f3f5] border-0"
|
||||
size="sm"
|
||||
onChangeText={(val) => {
|
||||
setMoneyRecord({
|
||||
...moneyRecord,
|
||||
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 }} />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
|
||||
<ADatePicker
|
||||
pickerTitle="记账时间"
|
||||
mode="date"
|
||||
date={moneyRecord.record_time}
|
||||
show={showDatePicker}
|
||||
onClose={() => {
|
||||
setShowDatePicker(false)
|
||||
}}
|
||||
onConfirm={(date) => {
|
||||
setMoneyRecord({
|
||||
...moneyRecord,
|
||||
record_time: date,
|
||||
})
|
||||
setShowDatePicker(false)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import Animated, { useAnimatedStyle, withSpring, withTiming } from 'react-native-reanimated'
|
||||
// import { IconQuxian1 } from '@/components/iconfont'
|
||||
|
||||
interface TabSelectorProps {
|
||||
options: string[]
|
||||
selected: number
|
||||
onSelect: (tab: number) => void
|
||||
}
|
||||
|
||||
const TabSelector: React.FC<TabSelectorProps> = ({ options, selected, onSelect }) => {
|
||||
return (
|
||||
<View className="flex-row gap-4 items-center h-10 mt-2">
|
||||
{options.map((tab, index) => {
|
||||
const isActive = selected === index
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [
|
||||
{ rotate: '50deg' },
|
||||
{ scale: withSpring(isActive ? 1 : 0.8, { damping: 12, stiffness: 120 }) },
|
||||
],
|
||||
opacity: withTiming(isActive ? 1 : 0, { duration: 200 }),
|
||||
}))
|
||||
|
||||
return (
|
||||
<View key={tab} className="relative h-full items-center">
|
||||
<Pressable
|
||||
onPress={() => onSelect(index)}
|
||||
className="h-full items-center justify-center"
|
||||
>
|
||||
<Text
|
||||
className={`${isActive ? 'font-bold text-xl text-black' : 'font-normal text-base text-gray-600 '}`}
|
||||
>
|
||||
{tab}
|
||||
</Text>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: 'absolute',
|
||||
bottom: -2,
|
||||
},
|
||||
animatedStyle,
|
||||
]}
|
||||
>
|
||||
{/* <IconQuxian1 size={22} color="#7566f3" /> */}
|
||||
</Animated.View>
|
||||
</Pressable>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default TabSelector
|
||||
@@ -10,18 +10,20 @@ import {
|
||||
} from 'react-native-popup-menu'
|
||||
import { LucideIcon } from '@/components/common/ao'
|
||||
import EmptyState from '@/components/common/EmptyStatus'
|
||||
import MoneyCategoryIcon from './MoneyCategoryIcon'
|
||||
import MoneyCategoryIcon from '@/components/common/MoneyCategoryIcon'
|
||||
|
||||
interface StaticTransactionsProps {
|
||||
records: MoneyRecordGroup[]
|
||||
onUpdate?: (data: MoneyRecordItem) => void
|
||||
onDelete?: (id: number) => void
|
||||
onDetail?: (id: number) => void
|
||||
}
|
||||
|
||||
export default function StaticTransactions({
|
||||
records,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
onDetail,
|
||||
}: StaticTransactionsProps) {
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
|
||||
@@ -87,6 +89,7 @@ export default function StaticTransactions({
|
||||
placement: 'bottom',
|
||||
preferredPlacement: 'bottom',
|
||||
anchorStyle: { backgroundColor: 'transparent' },
|
||||
closeAnimationDuration: 0,
|
||||
}}
|
||||
onSelect={() => setSelectedId(null)}
|
||||
onBackdropPress={() => setSelectedId(null)}
|
||||
@@ -137,6 +140,7 @@ export default function StaticTransactions({
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
||||
<MenuOption
|
||||
customStyles={{
|
||||
OptionTouchableComponent: TouchableHighlight,
|
||||
@@ -171,6 +175,23 @@ export default function StaticTransactions({
|
||||
<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>
|
||||
</Menu>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { MoneyRecordGroup, MoneyRecordItem, QueryMoneyRecordsResult } from '@/types/money/record'
|
||||
import { useRouter } from 'expo-router'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Text, TouchableOpacity, View } from 'react-native'
|
||||
@@ -7,7 +6,7 @@ import { ADatePicker, LucideIcon } from '@/components/common/ao'
|
||||
import { Fab, FabIcon } from '@/components/ui/fab'
|
||||
import { AddIcon } from '@/components/ui/icon'
|
||||
import { Progress, ProgressFilledTrack } from '@/components/ui/progress'
|
||||
import { deleteMoneyRecordApi, queryMoneyRecordsApi } from '@/request/api/money/record'
|
||||
import { queryMoneyRecordsApi } from '@/request/api/money/record'
|
||||
import { useMoneyRecordStore } from '@/store/moneyRecord'
|
||||
import { getCurrentYearMonth } from '@/utils/time'
|
||||
import { toast } from '@/utils/toast'
|
||||
@@ -133,6 +132,12 @@ export default function Money() {
|
||||
params: { id: data.id },
|
||||
})
|
||||
}}
|
||||
onDetail={(id) => {
|
||||
router.navigate({
|
||||
pathname: '/money/record/detail',
|
||||
params: { id },
|
||||
})
|
||||
}}
|
||||
onDelete={(id) => {
|
||||
moneyRecordStore.deleteMoneyRecord(id)
|
||||
}}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { MenuProvider } from 'react-native-popup-menu'
|
||||
import { AButton } from '@/components/common/ao'
|
||||
import EmptyState from '@/components/common/EmptyStatus'
|
||||
import { GluestackUIProvider } from '@/components/ui/gluestack-ui-provider'
|
||||
import { useMoneyCategoryStore } from '@/store/moneyCategory'
|
||||
import { useUserStore } from '@/store/user'
|
||||
import { ToastProviderWrapper } from '@/utils/toast'
|
||||
import '@/styles/global.css'
|
||||
@@ -42,6 +43,7 @@ export function ErrorBoundary({ error, retry }: ErrorBoundaryProps) {
|
||||
export default function RootLayout() {
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const moneyCategoryStore = useMoneyCategoryStore()
|
||||
|
||||
const [fontsLoaded] = useFonts({
|
||||
SpaceMono: require('@/static/fonts/SpaceMono-Regular.ttf'),
|
||||
@@ -60,10 +62,12 @@ export default function RootLayout() {
|
||||
}
|
||||
|
||||
if (!userStore.isLoggedIn()) {
|
||||
// 未登录直接跳到 auth
|
||||
router.replace('/auth/index')
|
||||
return null
|
||||
}
|
||||
|
||||
moneyCategoryStore.init()
|
||||
|
||||
return (
|
||||
<MenuProvider>
|
||||
<KeyboardProvider>
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { useState } from 'react'
|
||||
import { Text, View } from 'react-native'
|
||||
import { AButton, ACell, ACellGroup, AModal } from '@/components/common/ao'
|
||||
import MoneyCategoryIcon from '@/components/common/MoneyCategoryIcon'
|
||||
import CustomHeader from '@/components/custom/Header'
|
||||
import { useMoneyRecordStore } from '@/store/moneyRecord'
|
||||
import { toDateString, toDateTimeString } from '@/utils/time'
|
||||
|
||||
export default function MoneyRecordDetail() {
|
||||
const router = useRouter()
|
||||
const moneyRecordStore = useMoneyRecordStore()
|
||||
|
||||
const { id: detailId } = useLocalSearchParams<{ id: string }>()
|
||||
|
||||
const moneryRecord = moneyRecordStore.getMoneyRecordById(Number(detailId))
|
||||
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false)
|
||||
|
||||
if (moneryRecord === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<View className="flex-1">
|
||||
<CustomHeader title="账单详情" />
|
||||
<View className="flex-1">
|
||||
<View className="flex flex-col items-center">
|
||||
<MoneyCategoryIcon name={moneryRecord.category_icon} size={56} />
|
||||
<Text className="mt-1">{moneryRecord.category_name}</Text>
|
||||
</View>
|
||||
|
||||
<ACellGroup showDivider={false} className="mt-4">
|
||||
<ACell title="类别" rightText={moneryRecord.category_type === 0 ? '支出' : '收入'} />
|
||||
<ACell title="金额" rightText={moneryRecord.amount} />
|
||||
<ACell title="备注" rightText={moneryRecord.remark} />
|
||||
<ACell title="日期" rightText={toDateString(moneryRecord.record_time)} />
|
||||
<ACell title="操作日期" rightText={toDateTimeString(moneryRecord.update_time || moneryRecord.create_time)} />
|
||||
</ACellGroup>
|
||||
|
||||
<View className="flex flex-row px-4 mt-4 gap-4">
|
||||
<AButton
|
||||
className="flex-1"
|
||||
action="negative"
|
||||
variant="outline"
|
||||
onPress={() => {
|
||||
setShowDeleteModal(true)
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</AButton>
|
||||
<AButton
|
||||
className="flex-1"
|
||||
onPress={() => {
|
||||
router.navigate({
|
||||
pathname: '/money/record/editor',
|
||||
params: { id: detailId },
|
||||
})
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</AButton>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
</View>
|
||||
|
||||
<AModal
|
||||
isOpen={showDeleteModal}
|
||||
title="提示"
|
||||
body="确认删除该条账单?"
|
||||
onConfirm={() => {
|
||||
setShowDeleteModal(false)
|
||||
|
||||
moneyRecordStore.deleteMoneyRecord(Number(detailId))
|
||||
|
||||
router.back()
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowDeleteModal(false)
|
||||
}}
|
||||
onClose={() => {
|
||||
setShowDeleteModal(false)
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import type { ViewStyle } from 'react-native'
|
||||
import type { MoneyCategoryItem } from '@/types/money/category'
|
||||
import React, { useMemo } from 'react'
|
||||
import { FlatList, Pressable, Text, View } from 'react-native'
|
||||
import MoneyCategoryIcon from './CategoryIcon'
|
||||
import MoneyCategoryIcon from '@/components/common/MoneyCategoryIcon'
|
||||
|
||||
interface CategoryGridProps {
|
||||
data: MoneyCategoryItem[]
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
import React from 'react'
|
||||
import IconFont from '@/components/iconfont'
|
||||
|
||||
interface CategoryIconProps {
|
||||
name?: string
|
||||
size?: number
|
||||
color?: string
|
||||
type?: 0 | 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类图标组件
|
||||
*/
|
||||
export default function MoneyCategoryIcon({
|
||||
name,
|
||||
size = 24,
|
||||
color,
|
||||
}: CategoryIconProps) {
|
||||
return <IconFont name={name as any} size={size} color={color} />
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { Text, TouchableOpacity, View } from 'react-native'
|
||||
import { LucideIcon } from '@/components/common/ao'
|
||||
import CustomHeader from '@/components/custom/Header'
|
||||
import PagerTabs from '@/components/custom/PagerTabs'
|
||||
import { insertMoneyRecordApi } from '@/request/api/money/record'
|
||||
import { insertMoneyRecordApi, updateMoneyRecordApi } from '@/request/api/money/record'
|
||||
import { useMoneyCategoryStore } from '@/store/moneyCategory'
|
||||
import { useMoneyRecordStore } from '@/store/moneyRecord'
|
||||
import { getCurrentDate, toDateString } from '@/utils/time'
|
||||
@@ -23,7 +23,6 @@ export default function MoenyRecordEditor() {
|
||||
const income = moneyCategoryStore.getIncomeCategories()
|
||||
|
||||
const { id: updateId } = useLocalSearchParams<{ id: string }>()
|
||||
|
||||
// 自定义键盘
|
||||
const [showKeydown, setShowKeydown] = useState(false)
|
||||
// 选中分类
|
||||
@@ -78,6 +77,32 @@ export default function MoenyRecordEditor() {
|
||||
|
||||
const amount = moneyRecord.amount.replace(/^-+/, '')
|
||||
|
||||
if (updateId) {
|
||||
updateMoneyRecordApi({
|
||||
id: moneyRecord.id,
|
||||
category_id: selectedCategory.id,
|
||||
amount,
|
||||
remark: moneyRecord.remark,
|
||||
record_time: moneyRecord.record_time,
|
||||
}).then((res) => {
|
||||
if (res.isSuccess) {
|
||||
const result: MoneyRecordItem = {
|
||||
...moneyRecord,
|
||||
category_id: selectedCategory.id,
|
||||
category_name: selectedCategory.name,
|
||||
category_icon: selectedCategory.icon,
|
||||
category_type: selectedCategory.type,
|
||||
}
|
||||
|
||||
moneyRecordStore.updateMoneyRecord(result)
|
||||
|
||||
router.back()
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.log(err)
|
||||
})
|
||||
}
|
||||
else {
|
||||
insertMoneyRecordApi({
|
||||
...moneyRecord,
|
||||
amount,
|
||||
@@ -107,6 +132,7 @@ export default function MoenyRecordEditor() {
|
||||
console.log(err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1">
|
||||
|
||||
@@ -1,127 +1,11 @@
|
||||
import * as React from 'react'
|
||||
import { Animated, StyleSheet, Text, TouchableOpacity, useWindowDimensions, View } from 'react-native'
|
||||
import PagerView from 'react-native-pager-view'
|
||||
import { View } from 'react-native'
|
||||
import CustomHeader from '@/components/custom/Header'
|
||||
|
||||
export default function PagerWithTab() {
|
||||
const layout = useWindowDimensions()
|
||||
const pagerRef = React.useRef<PagerView>(null)
|
||||
const [index, setIndex] = React.useState(0)
|
||||
const [tabWidth, setTabWidth] = React.useState(0)
|
||||
|
||||
// 用 Animated.Value 记录滑动位置(0 ~ 1 ~ 2)
|
||||
const scrollX = React.useRef(new Animated.Value(0)).current
|
||||
|
||||
const routes = [
|
||||
{ key: 'first', title: 'Tab1' },
|
||||
{ key: 'second', title: 'Tab2' },
|
||||
]
|
||||
|
||||
const onTabPress = (i: number) => {
|
||||
pagerRef.current?.setPage(i)
|
||||
}
|
||||
|
||||
// 页面滑动回调,把 offset 和 position 转换成 scrollX
|
||||
const onPageScroll = (e: any) => {
|
||||
const { position, offset } = e.nativeEvent
|
||||
scrollX.setValue(position + offset)
|
||||
}
|
||||
|
||||
// 下划线平滑动画
|
||||
const indicatorTranslateX = scrollX.interpolate({
|
||||
inputRange: [0, routes.length - 1],
|
||||
outputRange: [0, tabWidth - tabWidth / routes.length],
|
||||
extrapolate: 'clamp',
|
||||
})
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Header */}
|
||||
<View style={styles.header}>
|
||||
<Text style={styles.headerSide}>Left</Text>
|
||||
|
||||
{/* TabBar */}
|
||||
<View
|
||||
style={styles.tabBar}
|
||||
onLayout={e => setTabWidth(e.nativeEvent.layout.width)}
|
||||
>
|
||||
{routes.map((r, i) => (
|
||||
<TouchableOpacity
|
||||
key={r.key}
|
||||
style={styles.tabItem}
|
||||
onPress={() => onTabPress(i)}
|
||||
>
|
||||
<Text style={[styles.tabText, index === i && { color: 'blue', fontWeight: 'bold' }]}>
|
||||
{r.title}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
|
||||
{tabWidth > 0 && (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.indicator,
|
||||
{
|
||||
width: tabWidth / routes.length,
|
||||
transform: [{ translateX: indicatorTranslateX }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<Text style={styles.headerSide}>Right</Text>
|
||||
</View>
|
||||
|
||||
{/* PagerView */}
|
||||
<PagerView
|
||||
ref={pagerRef}
|
||||
style={{ flex: 1 }}
|
||||
initialPage={0}
|
||||
onPageScroll={onPageScroll}
|
||||
onPageSelected={e => setIndex(e.nativeEvent.position)}
|
||||
>
|
||||
<View key="1" style={[styles.page, { backgroundColor: '#ffcccb' }]}>
|
||||
<Text>第一个页面内容</Text>
|
||||
</View>
|
||||
<View key="2" style={[styles.page, { backgroundColor: '#add8e6' }]}>
|
||||
<Text>第二个页面内容</Text>
|
||||
</View>
|
||||
</PagerView>
|
||||
<View className="flex-1">
|
||||
<CustomHeader title="共享空间" />
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
headerSide: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
tabBar: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
position: 'relative',
|
||||
},
|
||||
tabItem: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
paddingVertical: 8,
|
||||
},
|
||||
tabText: {
|
||||
fontSize: 16,
|
||||
color: '#333',
|
||||
},
|
||||
indicator: {
|
||||
height: 2,
|
||||
backgroundColor: 'blue',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
},
|
||||
page: {
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
})
|
||||
File renamed without changes.
Whitespace-only changes.
@@ -51,7 +51,6 @@ const Button: React.FC<CustomButtonProps> = ({
|
||||
return <span className="mr-2">{leftIcon}</span>
|
||||
}
|
||||
|
||||
// 只剩下 LucideIconProps & { icon }
|
||||
const { icon, ...props } = leftIcon as LucideIconProps & { icon: keyof typeof icons }
|
||||
return <LucideIcon icon={icon} className="mr-2" {...props} />
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { icons } from 'lucide-react-native'
|
||||
import type { ReactElement, ReactNode } from 'react'
|
||||
import type { ViewStyle } from 'react-native'
|
||||
import { Children, cloneElement, isValidElement } from 'react'
|
||||
import { Text, TouchableOpacity, View } from 'react-native'
|
||||
import LucideIcon from './LucideIcon'
|
||||
|
||||
/* ================= Cell ================= */
|
||||
export interface CellProps {
|
||||
left?: ReactNode
|
||||
right?: ReactNode
|
||||
|
||||
title?: string
|
||||
leftIcon?: keyof typeof icons
|
||||
rightIcon?: keyof typeof icons
|
||||
rightText?: string
|
||||
|
||||
isLink?: boolean
|
||||
className?: string
|
||||
style?: ViewStyle
|
||||
|
||||
showDivider?: boolean // 由 CellGroup 注入
|
||||
|
||||
onPress?: () => void
|
||||
}
|
||||
|
||||
export function Cell({
|
||||
left,
|
||||
right,
|
||||
title,
|
||||
leftIcon,
|
||||
rightIcon = 'ChevronRight',
|
||||
rightText,
|
||||
isLink = false,
|
||||
className,
|
||||
style,
|
||||
showDivider = false,
|
||||
onPress,
|
||||
}: CellProps) {
|
||||
return (
|
||||
<View>
|
||||
<TouchableOpacity
|
||||
activeOpacity={isLink ? 0.5 : 1}
|
||||
className={`flex flex-row items-center justify-between px-4 py-3 ${className}`}
|
||||
style={style}
|
||||
onPress={onPress}
|
||||
>
|
||||
{left || (
|
||||
<View className="flex flex-row items-center space-x-2">
|
||||
{leftIcon && <LucideIcon icon={leftIcon} size={18} />}
|
||||
{title && <Text className="text-base text-gray-900 ml-2">{title}</Text>}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{right || (
|
||||
<View className="flex flex-row items-center ">
|
||||
{rightText && <Text className="text-gray-500 text-base mr-2">{rightText}</Text>}
|
||||
{isLink && rightIcon && (
|
||||
<LucideIcon icon={rightIcon} size={16} color="#9ca3af" />
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
|
||||
{showDivider && (
|
||||
<View
|
||||
className="bg-gray-100"
|
||||
style={{ marginLeft: 16, marginRight: 16, height: 1 }}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
/* ================= CellGroup ================= */
|
||||
export interface CellGroupProps {
|
||||
className?: string
|
||||
style?: ViewStyle
|
||||
children?: ReactNode
|
||||
showDivider?: boolean // 整组是否显示下划线
|
||||
}
|
||||
|
||||
export function CellGroup({
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
showDivider = true,
|
||||
}: CellGroupProps) {
|
||||
const elements = Children.toArray(children)
|
||||
|
||||
return (
|
||||
<View className={`bg-white ${className}`} style={style}>
|
||||
{elements.map((child, index) => {
|
||||
if (!isValidElement<CellProps>(child))
|
||||
return child
|
||||
|
||||
const isLast = index === elements.length - 1
|
||||
|
||||
return cloneElement<CellProps>(child as ReactElement<CellProps>, {
|
||||
showDivider: showDivider && !isLast,
|
||||
})
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { ViewStyle } from 'react-native'
|
||||
import { Text } from 'react-native'
|
||||
import { CloseIcon, Icon } from '@/components/ui/icon'
|
||||
|
||||
import {
|
||||
Modal,
|
||||
ModalBackdrop,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
} from '@/components/ui/modal'
|
||||
import AButton from '../ao/Button'
|
||||
|
||||
interface AModalProps {
|
||||
isOpen?: boolean
|
||||
title?: string | React.ReactNode
|
||||
body?: string | React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
onClose?: () => void
|
||||
onConfirm?: () => void
|
||||
onCancel?: () => void
|
||||
showBackdrop?: boolean
|
||||
showCloseIcon?: boolean
|
||||
|
||||
className?: string
|
||||
style?: ViewStyle
|
||||
}
|
||||
|
||||
export default function AModal({
|
||||
isOpen,
|
||||
title,
|
||||
body,
|
||||
footer,
|
||||
onClose,
|
||||
onConfirm,
|
||||
showBackdrop = true,
|
||||
showCloseIcon = false,
|
||||
|
||||
className,
|
||||
style,
|
||||
}: AModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
>
|
||||
{ showBackdrop && <ModalBackdrop />}
|
||||
<ModalContent
|
||||
className={className}
|
||||
style={{
|
||||
...style,
|
||||
width: '80%',
|
||||
}}
|
||||
>
|
||||
<ModalHeader>
|
||||
{title && (typeof title === 'string' ? <Text>{title}</Text> : title)}
|
||||
{
|
||||
showCloseIcon && (
|
||||
<ModalCloseButton>
|
||||
<Icon as={CloseIcon} />
|
||||
</ModalCloseButton>
|
||||
)
|
||||
}
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
{body && (typeof body === 'string' ? <Text>{body}</Text> : body)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
{
|
||||
footer || (
|
||||
<>
|
||||
<AButton
|
||||
variant="outline"
|
||||
action="secondary"
|
||||
className="mr-3"
|
||||
onPress={onClose}
|
||||
>
|
||||
取消
|
||||
</AButton>
|
||||
<AButton
|
||||
onPress={onConfirm}
|
||||
>
|
||||
确认
|
||||
</AButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -1,18 +1,23 @@
|
||||
import Button from './Button'
|
||||
import { Cell, CellGroup } from './Cell'
|
||||
import Checkbox from './Checkbox'
|
||||
import DatePicker from './DatePicker'
|
||||
import Form, { FormRef } from './Form'
|
||||
import FormItem from './FormItem'
|
||||
import Input from './Input'
|
||||
import LucideIcon from './LucideIcon'
|
||||
import Modal from './Modal'
|
||||
|
||||
export {
|
||||
Button as AButton,
|
||||
Cell as ACell,
|
||||
CellGroup as ACellGroup,
|
||||
Checkbox as ACheckbox,
|
||||
DatePicker as ADatePicker,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
FormRef as AFormRef,
|
||||
Input as AInput,
|
||||
Modal as AModal,
|
||||
LucideIcon,
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ViewStyle } from 'react-native'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Animated, Text, TouchableOpacity, View } from 'react-native'
|
||||
import PagerView from 'react-native-pager-view'
|
||||
|
||||
@@ -31,16 +31,20 @@ export default function PagerTabs({
|
||||
className,
|
||||
style,
|
||||
}: PagerTabsProps) {
|
||||
const pagerRef = React.useRef<PagerView>(null)
|
||||
const [index, setIndex] = React.useState(selectedIndex ?? 0)
|
||||
const [tabWidth, setTabWidth] = React.useState(0)
|
||||
const scrollX = React.useRef(new Animated.Value(selectedIndex ?? 0)).current
|
||||
const pagerRef = useRef<PagerView>(null)
|
||||
const [index, setIndex] = useState(selectedIndex ?? 0)
|
||||
const [tabWidth, setTabWidth] = useState(0)
|
||||
const scrollX = useRef(new Animated.Value(selectedIndex ?? 0)).current
|
||||
|
||||
// 外部 selectedIndex 更新时同步内部 index
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (typeof selectedIndex === 'number' && selectedIndex !== index) {
|
||||
setIndex(selectedIndex)
|
||||
pagerRef.current?.setPage(selectedIndex)
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
if (pagerRef.current) {
|
||||
pagerRef.current.setPage(selectedIndex)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [selectedIndex])
|
||||
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
'use client'
|
||||
import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils'
|
||||
import type {
|
||||
MotionComponentProps,
|
||||
} from '@legendapp/motion'
|
||||
import type { ViewStyle } from 'react-native'
|
||||
import { createModal } from '@gluestack-ui/core/modal/creator'
|
||||
import { tva, useStyleContext, withStyleContext } from '@gluestack-ui/utils/nativewind-utils'
|
||||
import {
|
||||
AnimatePresence,
|
||||
createMotionAnimatedComponent,
|
||||
Motion,
|
||||
} from '@legendapp/motion'
|
||||
|
||||
import { cssInterop } from 'nativewind'
|
||||
import React from 'react'
|
||||
import { Pressable, ScrollView, View } from 'react-native'
|
||||
|
||||
type IAnimatedPressableProps = React.ComponentProps<typeof Pressable>
|
||||
& MotionComponentProps<typeof Pressable, ViewStyle, unknown, unknown, unknown>
|
||||
|
||||
const AnimatedPressable = createMotionAnimatedComponent(
|
||||
Pressable,
|
||||
) as React.ComponentType<IAnimatedPressableProps>
|
||||
const SCOPE = 'MODAL'
|
||||
|
||||
type IMotionViewProps = React.ComponentProps<typeof View>
|
||||
& MotionComponentProps<typeof View, ViewStyle, unknown, unknown, unknown>
|
||||
|
||||
const MotionView = Motion.View as React.ComponentType<IMotionViewProps>
|
||||
|
||||
const UIModal = createModal({
|
||||
Root: withStyleContext(View, SCOPE),
|
||||
Backdrop: AnimatedPressable,
|
||||
Content: MotionView,
|
||||
Body: ScrollView,
|
||||
CloseButton: Pressable,
|
||||
Footer: View,
|
||||
Header: View,
|
||||
AnimatePresence,
|
||||
})
|
||||
|
||||
cssInterop(AnimatedPressable, { className: 'style' })
|
||||
cssInterop(MotionView, { className: 'style' })
|
||||
|
||||
const modalStyle = tva({
|
||||
base: 'group/modal w-full h-full justify-center items-center web:pointer-events-none',
|
||||
variants: {
|
||||
size: {
|
||||
xs: '',
|
||||
sm: '',
|
||||
md: '',
|
||||
lg: '',
|
||||
full: '',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const modalBackdropStyle = tva({
|
||||
base: 'absolute left-0 top-0 right-0 bottom-0 bg-background-dark web:cursor-default',
|
||||
})
|
||||
|
||||
const modalContentStyle = tva({
|
||||
base: 'bg-background-0 rounded-md overflow-hidden border border-outline-100 shadow-hard-2 p-6',
|
||||
parentVariants: {
|
||||
size: {
|
||||
xs: 'w-[60%] max-w-[360px]',
|
||||
sm: 'w-[70%] max-w-[420px]',
|
||||
md: 'w-[80%] max-w-[510px]',
|
||||
lg: 'w-[90%] max-w-[640px]',
|
||||
full: 'w-full',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const modalBodyStyle = tva({
|
||||
base: 'mt-2 mb-6',
|
||||
})
|
||||
|
||||
const modalCloseButtonStyle = tva({
|
||||
base: 'group/modal-close-button z-10 rounded data-[focus-visible=true]:web:bg-background-100 web:outline-0 cursor-pointer',
|
||||
})
|
||||
|
||||
const modalHeaderStyle = tva({
|
||||
base: 'justify-between items-center flex-row',
|
||||
})
|
||||
|
||||
const modalFooterStyle = tva({
|
||||
base: 'flex-row justify-end items-center gap-2',
|
||||
})
|
||||
|
||||
type IModalProps = React.ComponentProps<typeof UIModal>
|
||||
& VariantProps<typeof modalStyle> & { className?: string }
|
||||
|
||||
type IModalBackdropProps = React.ComponentProps<typeof UIModal.Backdrop>
|
||||
& VariantProps<typeof modalBackdropStyle> & { className?: string }
|
||||
|
||||
type IModalContentProps = React.ComponentProps<typeof UIModal.Content>
|
||||
& VariantProps<typeof modalContentStyle> & { className?: string }
|
||||
|
||||
type IModalHeaderProps = React.ComponentProps<typeof UIModal.Header>
|
||||
& VariantProps<typeof modalHeaderStyle> & { className?: string }
|
||||
|
||||
type IModalBodyProps = React.ComponentProps<typeof UIModal.Body>
|
||||
& VariantProps<typeof modalBodyStyle> & { className?: string }
|
||||
|
||||
type IModalFooterProps = React.ComponentProps<typeof UIModal.Footer>
|
||||
& VariantProps<typeof modalFooterStyle> & { className?: string }
|
||||
|
||||
type IModalCloseButtonProps = React.ComponentProps<typeof UIModal.CloseButton>
|
||||
& VariantProps<typeof modalCloseButtonStyle> & { className?: string }
|
||||
|
||||
const Modal = React.forwardRef<React.ComponentRef<typeof UIModal>, IModalProps>(
|
||||
({ className, size = 'md', ...props }, ref) => (
|
||||
<UIModal
|
||||
ref={ref}
|
||||
{...props}
|
||||
pointerEvents="box-none"
|
||||
className={modalStyle({ size, class: className })}
|
||||
context={{ size }}
|
||||
/>
|
||||
),
|
||||
)
|
||||
|
||||
const ModalBackdrop = React.forwardRef<
|
||||
React.ComponentRef<typeof UIModal.Backdrop>,
|
||||
IModalBackdropProps
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<UIModal.Backdrop
|
||||
ref={ref}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 0.5,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
}}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
damping: 18,
|
||||
stiffness: 250,
|
||||
opacity: {
|
||||
type: 'timing',
|
||||
duration: 250,
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
className={modalBackdropStyle({
|
||||
class: className,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const ModalContent = React.forwardRef<
|
||||
React.ComponentRef<typeof UIModal.Content>,
|
||||
IModalContentProps
|
||||
>(({ className, size, ...props }, ref) => {
|
||||
const { size: parentSize } = useStyleContext(SCOPE)
|
||||
|
||||
return (
|
||||
<UIModal.Content
|
||||
ref={ref}
|
||||
initial={{
|
||||
opacity: 0,
|
||||
scale: 0.9,
|
||||
}}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
}}
|
||||
exit={{
|
||||
opacity: 0,
|
||||
}}
|
||||
transition={{
|
||||
type: 'spring',
|
||||
damping: 18,
|
||||
stiffness: 250,
|
||||
opacity: {
|
||||
type: 'timing',
|
||||
duration: 250,
|
||||
},
|
||||
}}
|
||||
{...props}
|
||||
className={modalContentStyle({
|
||||
parentVariants: {
|
||||
size: parentSize,
|
||||
},
|
||||
size,
|
||||
class: className,
|
||||
})}
|
||||
pointerEvents="auto"
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const ModalHeader = React.forwardRef<
|
||||
React.ComponentRef<typeof UIModal.Header>,
|
||||
IModalHeaderProps
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<UIModal.Header
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={modalHeaderStyle({
|
||||
class: className,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const ModalBody = React.forwardRef<
|
||||
React.ComponentRef<typeof UIModal.Body>,
|
||||
IModalBodyProps
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<UIModal.Body
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={modalBodyStyle({
|
||||
class: className,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const ModalFooter = React.forwardRef<
|
||||
React.ComponentRef<typeof UIModal.Footer>,
|
||||
IModalFooterProps
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<UIModal.Footer
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={modalFooterStyle({
|
||||
class: className,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
const ModalCloseButton = React.forwardRef<
|
||||
React.ComponentRef<typeof UIModal.CloseButton>,
|
||||
IModalCloseButtonProps
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<UIModal.CloseButton
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={modalCloseButtonStyle({
|
||||
class: className,
|
||||
})}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
Modal.displayName = 'Modal'
|
||||
ModalBackdrop.displayName = 'ModalBackdrop'
|
||||
ModalContent.displayName = 'ModalContent'
|
||||
ModalHeader.displayName = 'ModalHeader'
|
||||
ModalBody.displayName = 'ModalBody'
|
||||
ModalFooter.displayName = 'ModalFooter'
|
||||
ModalCloseButton.displayName = 'ModalCloseButton'
|
||||
|
||||
export {
|
||||
Modal,
|
||||
ModalBackdrop,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
}
|
||||
@@ -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:8085/app',
|
||||
baseURL: 'http://192.168.1.136:8085/app',
|
||||
requestAdapter: axiosRequestAdapter(),
|
||||
timeout: 5000,
|
||||
cacheFor: null,
|
||||
|
||||
@@ -32,6 +32,12 @@ export const useMoneyCategoryStore = create<MoneyCategoryStore>()(
|
||||
set({ categories: get().categories.filter(c => c.id !== id) }),
|
||||
|
||||
init: async () => {
|
||||
const local = get().categories
|
||||
|
||||
if (local && local.length > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await queryMoneyCategoryApi()
|
||||
if (res.isSuccess) {
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface MoneyRecordBase {
|
||||
remark: string
|
||||
record_time: string
|
||||
create_time: string
|
||||
update_time: string
|
||||
}
|
||||
|
||||
// 查询条件(请求参数)
|
||||
|
||||
@@ -1,232 +1,337 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>共享空间</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto;
|
||||
background: #f6f6f7;
|
||||
color: #111;
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
|
||||
<title>共享空间 — 邀请 / 加入</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg: #f6f7fb;
|
||||
--card: #ffffff;
|
||||
--muted: #9aa4b2;
|
||||
--accent: #5b8cff;
|
||||
--danger: #ff6b6b;
|
||||
--radius: 12px;
|
||||
--gap: 12px;
|
||||
--shadow: 0 6px 18px rgba(22,27,40,0.06);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial;
|
||||
}
|
||||
|
||||
.page {
|
||||
max-width: 420px;
|
||||
margin: 0 auto;
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
/* Reset / basic */
|
||||
*{box-sizing:border-box}
|
||||
html,body{height:100%}
|
||||
body{
|
||||
margin:0;
|
||||
background:linear-gradient(180deg, var(--bg), #eef2ff 60%);
|
||||
color:#0f1724;
|
||||
-webkit-font-smoothing:antialiased;
|
||||
-moz-osx-font-smoothing:grayscale;
|
||||
padding:16px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 20px;
|
||||
/* Container */
|
||||
.screen{
|
||||
max-width:480px;
|
||||
margin:0 auto;
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 40px;
|
||||
padding: 0 14px;
|
||||
border-radius: 8px;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
margin-top: 12px;
|
||||
border: 1px solid #111;
|
||||
header.appbar{
|
||||
display:flex;
|
||||
align-items:center;
|
||||
gap:12px;
|
||||
padding:8px 4px;
|
||||
margin-bottom:12px;
|
||||
}
|
||||
.title{
|
||||
font-size:18px;
|
||||
font-weight:600;
|
||||
}
|
||||
.subtitle{
|
||||
display:block;
|
||||
font-size:12px;
|
||||
color:var(--muted);
|
||||
margin-top:2px;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
color: #111;
|
||||
/* Cards */
|
||||
.card{
|
||||
background:var(--card);
|
||||
border-radius:var(--radius);
|
||||
box-shadow:var(--shadow);
|
||||
padding:14px;
|
||||
margin-bottom:var(--gap);
|
||||
}
|
||||
|
||||
.input-box {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #ddd;
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
/* Invite area */
|
||||
.invite-top{
|
||||
display:flex;
|
||||
gap:12px;
|
||||
align-items:center;
|
||||
}
|
||||
.space-avatar{
|
||||
width:56px;height:56px;border-radius:12px;background:linear-gradient(135deg,#7fb6ff,#5b8cff);
|
||||
display:flex;align-items:center;justify-content:center;color:#fff;font-weight:700;font-size:20px;
|
||||
}
|
||||
.space-info{
|
||||
flex:1;
|
||||
}
|
||||
.space-name{font-weight:600;font-size:16px}
|
||||
.space-meta{font-size:12px;color:var(--muted);margin-top:4px}
|
||||
|
||||
.list-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 14px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
.invite-row{
|
||||
margin-top:12px;display:flex;gap:8px;
|
||||
}
|
||||
.btn{
|
||||
flex:1;
|
||||
border:0;padding:10px 12px;border-radius:10px;font-weight:600;
|
||||
background:var(--accent);color:#fff;cursor:pointer;
|
||||
box-shadow:0 6px 12px rgba(91,140,255,0.18);
|
||||
}
|
||||
.btn.ghost{background:transparent;color:var(--accent);border:1px solid rgba(91,140,255,0.12);box-shadow:none}
|
||||
.btn.danger{background:var(--danger);box-shadow:0 6px 12px rgba(255,107,107,0.16)}
|
||||
|
||||
.switcher {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
/* Invite code box */
|
||||
.code-box{display:flex;align-items:center;justify-content:space-between;padding:10px;border-radius:10px;background:#f4f7ff;border:1px dashed rgba(91,140,255,0.18)}
|
||||
.code-text{font-weight:700;letter-spacing:2px;font-size:16px}
|
||||
.code-actions{display:flex;gap:8px}
|
||||
|
||||
.switcher button {
|
||||
flex: 1;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
border: 1px solid #ccc;
|
||||
background: #fafafa;
|
||||
/* Join section */
|
||||
.join-input{
|
||||
display:flex;gap:8px;margin-top:8px;
|
||||
}
|
||||
.input{
|
||||
flex:1;padding:10px;border-radius:10px;border:1px solid #e6e9ef;background:#fff;
|
||||
font-size:14px;
|
||||
}
|
||||
.help-line{font-size:12px;color:var(--muted);margin-top:8px}
|
||||
|
||||
.switcher button.active {
|
||||
background: #111;
|
||||
color: #fff;
|
||||
border-color: #111;
|
||||
}
|
||||
/* Recent invites / list */
|
||||
.list-row{display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-top:1px solid #f2f4f8}
|
||||
.list-left{display:flex;align-items:center;gap:10px}
|
||||
.small-avatar{width:36px;height:36px;border-radius:8px;background:#eef4ff;display:flex;align-items:center;justify-content:center;color:#3759c8;font-weight:700}
|
||||
.list-title{font-weight:600}
|
||||
.list-sub{font-size:12px;color:var(--muted)}
|
||||
|
||||
/* 弹窗 */
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
/* Modal (simple) */
|
||||
.overlay{position:fixed;left:0;right:0;top:0;bottom:0;background:rgba(8,10,15,0.45);display:flex;align-items:center;justify-content:center;padding:20px;z-index:80}
|
||||
.modal{width:100%;max-width:420px;border-radius:14px;background:var(--card);padding:16px;box-shadow:var(--shadow)}
|
||||
.modal .h1{font-weight:700;margin-bottom:8px}
|
||||
.modal .muted{color:var(--muted);font-size:13px;margin-bottom:12px}
|
||||
.share-grid{display:flex;gap:8px;flex-wrap:wrap}
|
||||
|
||||
.modal {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
text-align: center;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 0;
|
||||
}
|
||||
.share-chip{flex:0 0 30%;min-width:84px;padding:10px;border-radius:10px;background:#f7f9ff;text-align:center;font-size:13px;color:#123;background:linear-gradient(180deg,#fff,#f7f9ff);cursor:pointer}
|
||||
.copy-toast{position:fixed;left:50%;transform:translateX(-50%);bottom:22px;padding:8px 14px;background:rgba(0,0,0,0.7);color:#fff;border-radius:20px;font-size:13px;z-index:90}
|
||||
|
||||
.qr-box {
|
||||
width: 180px;
|
||||
height: 180px;
|
||||
background: #eee;
|
||||
margin: 16px auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
/* Footer hint */
|
||||
.hint{font-size:12px;color:var(--muted);text-align:center;margin-top:12px}
|
||||
|
||||
/* small screens */
|
||||
@media (max-width:360px){
|
||||
.btn{padding:10px}
|
||||
.space-avatar{width:48px;height:48px;font-size:18px}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="screen">
|
||||
<header class="appbar">
|
||||
<div style="width:44px;"></div>
|
||||
<div>
|
||||
<div class="title">共享空间</div>
|
||||
<div class="subtitle">邀请别人加入你的空间,或输入邀请码加入别人空间</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="page">
|
||||
<h1>共享空间(预览)</h1>
|
||||
|
||||
<div class="switcher">
|
||||
<button id="btnNotJoined" class="active" onclick="showPage('not')">未加入</button>
|
||||
<button id="btnJoined" onclick="showPage('yes')">已加入</button>
|
||||
<!-- Invite card -->
|
||||
<section class="card">
|
||||
<div class="invite-top">
|
||||
<div class="space-avatar" id="spaceAvatar">A</div>
|
||||
<div class="space-info">
|
||||
<div class="space-name" id="spaceName">工作协作空间</div>
|
||||
<div class="space-meta">成员 12 • 权限 管理者</div>
|
||||
</div>
|
||||
<div style="width:68px;text-align:right"><button class="btn ghost" id="manageBtn">管理</button></div>
|
||||
</div>
|
||||
|
||||
<!-- 未加入 -->
|
||||
<div id="notJoined">
|
||||
<p>你尚未加入共享空间,你可以创建自己的空间或加入他人的。</p>
|
||||
|
||||
<div class="btn" onclick="openInviteModal()">生成邀请码</div>
|
||||
<div class="btn-outline btn" onclick="openScanModal()">扫码加入</div>
|
||||
|
||||
<div class="btn btn-outline" onclick="toggleJoinInput()">输入邀请码加入</div>
|
||||
|
||||
<input id="inviteInput" type="text" class="input-box" placeholder="邀请码" style="display:none;" />
|
||||
<div id="joinBtn" class="btn" style="display:none;">加入</div>
|
||||
<div style="margin-top:14px">
|
||||
<div class="code-box" id="codeBox">
|
||||
<div class="code-text" id="inviteCode">AB12-CD34</div>
|
||||
<div class="code-actions">
|
||||
<button class="btn ghost" id="copyBtn">复制</button>
|
||||
<button class="btn" id="shareBtn">分享</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已加入 -->
|
||||
<div id="joined" style="display:none;">
|
||||
<p>你已加入共享空间,可查看成员、权限和邀请他人加入。</p>
|
||||
<div class="invite-row">
|
||||
<button class="btn ghost" id="qrBtn">二维码</button>
|
||||
<button class="btn ghost" id="linkBtn">链接</button>
|
||||
<button class="btn danger" id="revokeBtn">撤销并重置</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hint">邀请码有效期:24小时 · 点击分享可选择渠道</div>
|
||||
</section>
|
||||
|
||||
<h3 style="margin-top:20px; font-size:16px; font-weight:600;">成员列表</h3>
|
||||
<!-- Join other -->
|
||||
<section class="card" aria-label="加入他人空间">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;">
|
||||
<div style="font-weight:700">加入别人的空间</div>
|
||||
<div class="list-sub">通过邀请码或扫码加入</div>
|
||||
</div>
|
||||
|
||||
<div class="list-item"><span>张三</span><span>管理员</span></div>
|
||||
<div class="list-item"><span>李四</span><span>成员</span></div>
|
||||
<div class="list-item"><span>王五</span><span>成员</span></div>
|
||||
<div class="join-input">
|
||||
<input class="input" id="joinInput" placeholder="输入邀请码或链接(例如:AB12-CD34)" />
|
||||
<button class="btn" id="joinBtn">加入</button>
|
||||
</div>
|
||||
<div class="help-line">若对方使用链接邀请,可直接粘贴链接;扫码请使用 App 内相机。</div>
|
||||
|
||||
<div class="btn" onclick="openInviteModal()">邀请新成员</div>
|
||||
<div style="margin-top:12px">
|
||||
<div style="font-weight:600;margin-bottom:8px">最近加入的空间</div>
|
||||
<div class="list-row">
|
||||
<div class="list-left">
|
||||
<div class="small-avatar">P</div>
|
||||
<div>
|
||||
<div class="list-title">产品设计空间</div>
|
||||
<div class="list-sub">邀请人:小李 · 2 天前</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<div class="list-sub">成员 8</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="list-row">
|
||||
<div class="list-left">
|
||||
<div class="small-avatar">M</div>
|
||||
<div>
|
||||
<div class="list-title">市场推广</div>
|
||||
<div class="list-sub">邀请人:小王 · 1 周前</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<div class="list-sub">成员 20</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- footer actions -->
|
||||
<div style="display:flex;gap:8px;margin-top:8px">
|
||||
<button class="btn ghost" style="flex:1" id="createSpace">创建新空间</button>
|
||||
<button class="btn" style="flex:1" id="explore">发现空间</button>
|
||||
</div>
|
||||
|
||||
<div style="height:40px"></div>
|
||||
</div>
|
||||
|
||||
<!-- Modal markup (hidden initially) -->
|
||||
<div id="modalOverlay" class="overlay" style="display:none;">
|
||||
<div class="modal" role="dialog" aria-modal="true">
|
||||
<div class="h1">分享邀请</div>
|
||||
<div class="muted">选择一种方式邀请你的成员</div>
|
||||
|
||||
<div class="share-grid" style="margin-bottom:12px;">
|
||||
<div class="share-chip" data-action="wechat">微信</div>
|
||||
<div class="share-chip" data-action="qq">QQ</div>
|
||||
<div class="share-chip" data-action="link">复制链接</div>
|
||||
<div class="share-chip" data-action="sms">短信</div>
|
||||
<div class="share-chip" data-action="email">邮件</div>
|
||||
<div class="share-chip" data-action="more">更多</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||
<button class="btn ghost" onclick="closeModal()">取消</button>
|
||||
<button class="btn" id="modalShare">分享</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 邀请二维码弹窗 -->
|
||||
<div id="inviteModal" class="modal-mask">
|
||||
<div class="modal">
|
||||
<h3>邀请码 / 二维码</h3>
|
||||
<div id="qrBox" class="qr-box">二维码生成中…</div>
|
||||
<div class="btn" onclick="closeInviteModal()">关闭</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 扫码加入弹窗(假装相机界面) -->
|
||||
<div id="scanModal" class="modal-mask">
|
||||
<div class="modal">
|
||||
<h3>扫码加入</h3>
|
||||
<div class="qr-box">[ 相机画面模拟区域 ]</div>
|
||||
<p style="font-size:12px;color:#666;">打开摄像头并扫描共享空间二维码</p>
|
||||
<div class="btn" onclick="closeScanModal()">关闭</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- toast -->
|
||||
<div id="copyToast" class="copy-toast" style="display:none;">已复制到剪贴板</div>
|
||||
|
||||
<script>
|
||||
function showPage(type) {
|
||||
const not = document.getElementById("notJoined");
|
||||
const yes = document.getElementById("joined");
|
||||
const btnNot = document.getElementById("btnNotJoined");
|
||||
const btnYes = document.getElementById("btnJoined");
|
||||
// demo interactions
|
||||
const inviteCodeEl = document.getElementById('inviteCode');
|
||||
const copyBtn = document.getElementById('copyBtn');
|
||||
const shareBtn = document.getElementById('shareBtn');
|
||||
const modal = document.getElementById('modalOverlay');
|
||||
const copyToast = document.getElementById('copyToast');
|
||||
const joinInput = document.getElementById('joinInput');
|
||||
const joinBtn = document.getElementById('joinBtn');
|
||||
|
||||
if (type === 'not') {
|
||||
not.style.display = 'block';
|
||||
yes.style.display = 'none';
|
||||
btnNot.classList.add('active');
|
||||
btnYes.classList.remove('active');
|
||||
// copy code
|
||||
copyBtn.addEventListener('click', async () => {
|
||||
const code = inviteCodeEl.textContent.trim();
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
showToast('已复制邀请码');
|
||||
} catch (e) {
|
||||
showToast('复制失败,请手动复制');
|
||||
}
|
||||
});
|
||||
|
||||
// open share modal
|
||||
shareBtn.addEventListener('click', () => openModal());
|
||||
|
||||
document.getElementById('qrBtn').addEventListener('click', ()=> {
|
||||
alert('打开二维码生成器(示例占位)');
|
||||
});
|
||||
document.getElementById('linkBtn').addEventListener('click', ()=> {
|
||||
alert('打开链接分享(示例占位)');
|
||||
});
|
||||
document.getElementById('revokeBtn').addEventListener('click', ()=> {
|
||||
const newCode = randomCode();
|
||||
inviteCodeEl.textContent = newCode;
|
||||
showToast('邀请码已重置');
|
||||
});
|
||||
|
||||
// join
|
||||
joinBtn.addEventListener('click', ()=> {
|
||||
const v = (joinInput.value || '').trim();
|
||||
if (!v) return showToast('请输入邀请码或链接');
|
||||
// 简单验证格式示例
|
||||
const matched = /^[A-Z0-9]{2,}-[A-Z0-9]{2,}$/.test(v) || v.includes('http');
|
||||
if (!matched) return showToast('邀请码格式不正确');
|
||||
showToast('加入请求已发送');
|
||||
// TODO: 调用后端 API 加入空间
|
||||
});
|
||||
|
||||
// modal actions
|
||||
function openModal(){ modal.style.display='flex'; }
|
||||
function closeModal(){ modal.style.display='none'; }
|
||||
|
||||
document.getElementById('modalShare').addEventListener('click', ()=>{
|
||||
// 示例:复制链接并关闭
|
||||
const fakeLink = location.href + '?invite=' + inviteCodeEl.textContent.trim();
|
||||
navigator.clipboard.writeText(fakeLink).catch(()=>{});
|
||||
showToast('邀请链接已复制');
|
||||
closeModal();
|
||||
});
|
||||
|
||||
// copy chips
|
||||
document.querySelectorAll('.share-chip').forEach(el=>{
|
||||
el.addEventListener('click', ()=> {
|
||||
const action = el.getAttribute('data-action');
|
||||
if (action === 'link') {
|
||||
const fakeLink = location.href + '?invite=' + inviteCodeEl.textContent.trim();
|
||||
navigator.clipboard.writeText(fakeLink).then(()=> showToast('链接已复制'));
|
||||
} else {
|
||||
not.style.display = 'none';
|
||||
yes.style.display = 'block';
|
||||
btnYes.classList.add('active');
|
||||
btnNot.classList.remove('active');
|
||||
showToast('已选择:' + action);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
// small utilities
|
||||
function showToast(text){
|
||||
copyToast.textContent = text;
|
||||
copyToast.style.display = 'block';
|
||||
setTimeout(()=> copyToast.style.display='none', 1500);
|
||||
}
|
||||
|
||||
// ----------------- 邀请码 / 二维码弹窗 -----------------
|
||||
function openInviteModal() {
|
||||
const modal = document.getElementById('inviteModal');
|
||||
const qr = document.getElementById('qrBox');
|
||||
const code = Math.random().toString(36).substring(2, 8).toUpperCase();
|
||||
qr.innerHTML = `二维码:${code}`;
|
||||
modal.style.display = 'flex';
|
||||
}
|
||||
function closeInviteModal() {
|
||||
document.getElementById('inviteModal').style.display = 'none';
|
||||
}
|
||||
|
||||
// ----------------- 扫码加入弹窗 -----------------
|
||||
function openScanModal() {
|
||||
document.getElementById('scanModal').style.display = 'flex';
|
||||
}
|
||||
function closeScanModal() {
|
||||
document.getElementById('scanModal').style.display = 'none';
|
||||
}
|
||||
|
||||
function toggleJoinInput() {
|
||||
const input = document.getElementById("inviteInput");
|
||||
const btn = document.getElementById("joinBtn");
|
||||
const show = input.style.display === 'none';
|
||||
input.style.display = show ? 'block' : 'none';
|
||||
btn.style.display = show ? 'block' : 'none';
|
||||
function randomCode(){
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||
const pick = (n)=> Array.from({length:n}).map(()=>chars[Math.floor(Math.random()*chars.length)]).join('');
|
||||
return pick(2)+'-'+pick(4);
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -106,7 +106,7 @@ func loginDefault(c *router.Context) error {
|
||||
|
||||
routinex.Go(func() {
|
||||
kv := mapx.New().SetKV(
|
||||
"last_login_time", timex.NowString(),
|
||||
"last_login_time", timex.DateTimeString(),
|
||||
)
|
||||
_, _ = db.UpdateOne("user_info", "id", userId, kv)
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"base-go-v2/internal/router"
|
||||
"base-go-v2/internal/utils/idgen"
|
||||
"base-go-v2/internal/utils/mapx"
|
||||
"base-go-v2/internal/utils/timex"
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
@@ -272,6 +273,8 @@ func updateMoneyRecord(c *router.Context) error {
|
||||
updateRecord.SetKV("remark", remark)
|
||||
}
|
||||
|
||||
updateRecord.SetKV("update_time", timex.DateTimeString())
|
||||
|
||||
updateOne, err := db.UpdateOne("money_record", "id", recordId, updateRecord)
|
||||
if err != nil {
|
||||
return c.AddError(err)
|
||||
|
||||
@@ -7,8 +7,8 @@ func NowTime() time.Time {
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// NowString 返回当前时间的字符串格式:YYYY-MM-DD HH:mm:ss
|
||||
func NowString() string {
|
||||
// DateTimeString 返回当前时间的字符串格式:YYYY-MM-DD HH:mm:ss
|
||||
func DateTimeString() string {
|
||||
return time.Now().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user