This commit is contained in:
oneao committed 2025-11-20 17:30:28 +08:00
1 parent 63b168affc
commit 069b4506e4
29 files changed
+1050 -1089

No files matched your search

+2 -2
View File
@@ -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
View File
@@ -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',
},
})
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
}
// 查询条件(请求参数)
+290 -185
View File
@@ -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")
}