This commit is contained in:
oneao committed 2025-11-17 17:30:20 +08:00
1 parent 18e9d94b77
commit 1f82f1dd1f
16 files changed
+1048 -297

No files matched your search

@@ -1,39 +1,62 @@
import React, { useRef, useState } from 'react'
import { Button, ScrollView, Text, View } from 'react-native'
import { useState, useCallback } from 'react'
import { Pressable, Text, View, FlatList } from 'react-native'
import { AddIcon, GlobeIcon, Icon, PlayIcon, SettingsIcon } from '@/components/ui/icon'
import { Menu, MenuItem, MenuItemLabel } from '@/components/ui/menu'
export default function ScrollToExample() {
const scrollRef = useRef<ScrollView>(null)
const [positions, setPositions] = useState<Record<string, number>>({})
export default function Example() {
const data = Array.from({ length: 1000 }, (_, i) => i + 1)
const [openIndex, setOpenIndex] = useState<number | null>(null) // 控制哪个菜单打开
const handleLayout = (name: string, event: any) => {
const { y } = event.nativeEvent.layout
setPositions(prev => ({ ...prev, [name]: y }))
}
const scrollToField = (name: string) => {
const y = positions[name]
if (typeof y === 'number' && scrollRef.current) {
scrollRef.current.scrollTo({ y, animated: true })
}
}
const renderItem = useCallback(
({ item: index }) => (
<View style={{ marginVertical: 10 }}>
<Menu
placement="top"
offset={5}
disabledKeys={['Settings']}
isOpen={openIndex === index}
onOpen={() => setOpenIndex(index)}
onClose={() => setOpenIndex(null)}
trigger={({ onPress, ...triggerProps }) => (
<Pressable {...triggerProps} onLongPress={onPress}>
<Text>Menu {index}</Text>
</Pressable>
)}
>
{openIndex === index && (
<>
<MenuItem key="Add account" textValue="Add account">
<Icon as={AddIcon} size="sm" className="mr-2" />
<MenuItemLabel size="sm">Add account - {index}</MenuItemLabel>
</MenuItem>
<MenuItem key="Community" textValue="Community">
<Icon as={GlobeIcon} size="sm" className="mr-2" />
<MenuItemLabel size="sm">Community - {index}</MenuItemLabel>
</MenuItem>
<MenuItem key="Plugins" textValue="Plugins">
<Icon as={PlayIcon} size="sm" className="mr-2" />
<MenuItemLabel size="sm">Plugins - {index}</MenuItemLabel>
</MenuItem>
<MenuItem key="Settings" textValue="Settings">
<Icon as={SettingsIcon} size="sm" className="mr-2" />
<MenuItemLabel size="sm">Settings - {index}</MenuItemLabel>
</MenuItem>
</>
)}
</Menu>
</View>
),
[openIndex]
)
return (
<View style={{ flex: 1, paddingTop: 40 }}>
<Button title="滚动到 Password" onPress={() => scrollToField('password')} />
<ScrollView ref={scrollRef} style={{ marginTop: 20 }}>
<View onLayout={e => handleLayout('username', e)} style={{ height: 300, backgroundColor: '#f9c2ff', justifyContent: 'center', alignItems: 'center' }}>
<Text>Username 区域</Text>
</View>
<View onLayout={e => handleLayout('email', e)} style={{ height: 300, backgroundColor: '#c2f9ff', justifyContent: 'center', alignItems: 'center' }}>
<Text>Email 区域</Text>
</View>
<View onLayout={e => handleLayout('password', e)} style={{ height: 300, backgroundColor: '#ffc2c2', justifyContent: 'center', alignItems: 'center' }}>
<Text>Password 区域</Text>
</View>
</ScrollView>
</View>
<FlatList
data={data}
renderItem={renderItem}
keyExtractor={(item) => item.toString()}
initialNumToRender={20}
maxToRenderPerBatch={20}
windowSize={10}
/>
)
}
@@ -1,7 +1,7 @@
import type { MoneyCategoryItem, QueryMoneyCategoryResult } from '@/types/money/category'
import type { MoneyRecordBase, MoneyRecordItem } from '@/types/money/record'
import Big from 'big.js'
import { useMemo, useState } from 'react'
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'
@@ -15,14 +15,15 @@ import {
} from '@/components/ui/drawer'
import { useKeyboardHeight } from '@/hooks/useKeyboardHeight'
import { queryMoneyCategoryApi } from '@/request/api/money/category'
import { insertMoneyRecordApi } from '@/request/api/money/record'
import { getCurrentDate } from '@/utils/time'
import { insertMoneyRecordApi, updateMoneyRecordApi } from '@/request/api/money/record'
import { getCurrentDate, toDateString } from '@/utils/time'
import { toast } from '@/utils/toast'
import MoneyCategoryIcon from './MoneyCategoryIcon'
interface AddMoneyDrawerProps {
isOpen: boolean
onClose: () => void
updateData?: MoneyRecordItem | null
onClose?: () => void
onConfirm?: (result: MoneyRecordItem) => void
}
@@ -62,6 +63,7 @@ export default function AddMoneyDrawer({
isOpen,
onClose,
onConfirm,
updateData,
}: AddMoneyDrawerProps) {
const insets = useSafeAreaInsets()
// 选择顺序:0 支出 1收入
@@ -114,6 +116,35 @@ export default function AddMoneyDrawer({
}
}
useEffect(() => {
if (updateData) {
const type = updateData.category_type
setSelectedIndex(type)
setMoneyRecord({
...updateData,
record_time: toDateString(updateData.record_time),
})
setShowKeydown(true)
if (type === 0) {
// 支出
categories.expense.forEach((item) => {
if (item.id === updateData.category_id) {
setSelectedCategory(item)
}
})
}
else if (type === 1) {
// 收入
categories.income.forEach((item) => {
if (item.id === updateData.category_id) {
setSelectedCategory(item)
}
})
}
}
}, [categories])
function resetData() {
// 清空键盘记录
setKeydownInput('')
@@ -133,7 +164,7 @@ export default function AddMoneyDrawer({
function onMainDrawerClose() {
resetData()
onClose()
onClose?.()
}
// 计算过程
@@ -314,7 +345,6 @@ export default function AddMoneyDrawer({
}
}
// 保存
// 保存
function saveRecord() {
if (selectedCategory === null) {
@@ -334,33 +364,62 @@ export default function AddMoneyDrawer({
// 去掉金额负号
const amount = moneyRecord.amount.replace(/^-+/, '')
insertMoneyRecordApi({
...moneyRecord,
amount,
category_id: selectedCategory.id,
})
.then((res) => {
if (res.isSuccess) {
resetData()
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 = {
...res.data,
...moneyRecord,
category_id: selectedCategory.id,
category_name: selectedCategory.name,
category_icon: selectedCategory.icon,
category_type: selectedCategory.type,
}
onConfirm?.(result)
resetData()
}
else {
toast.show({
title: res.message || '保存失败',
})
}
})
.catch((err) => {
}).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 (
@@ -1,14 +1,25 @@
import type { MoneyRecordGroup } from '@/types/money/record'
import React from 'react'
import { SectionList, Text, View } from 'react-native'
import type { MoneyRecordGroup, MoneyRecordItem } from '@/types/money/record'
import React, { useCallback, useState } from 'react'
import { Pressable, SectionList, Text, View } from 'react-native'
import { LucideIcon } from '@/components/common/ao'
import EmptyState from '@/components/common/EmptyStatus'
import { Badge, BadgeText } from '@/components/ui/badge'
import { Menu, MenuItem, MenuItemLabel } from '@/components/ui/menu'
import MoneyCategoryIcon from './MoneyCategoryIcon'
interface StaticTransactionsProps {
records: MoneyRecordGroup[]
onUpdate?: (data: MoneyRecordItem) => void
onDelete?: (id: number) => void
}
export default function StaticTransactions({ records }: StaticTransactionsProps) {
export default function StaticTransactions({
records,
onUpdate,
onDelete,
}: StaticTransactionsProps) {
const [selectedId, setSelectedId] = useState<number | null>(null)
if (records.length === 0) {
return (
<EmptyState
@@ -33,7 +44,7 @@ export default function StaticTransactions({ records }: StaticTransactionsProps)
stickySectionHeadersEnabled={true}
showsVerticalScrollIndicator={false}
renderSectionHeader={({ section }) => (
<View className="bg-white py-2 px-1 border-b border-gray-100 mb-2">
<View className="bg-white py-2 px-4 border-b border-gray-100 mb-2">
<View className="flex-row items-center justify-between">
<Text className="text-gray-700 font-semibold text-base">
{section.title}
@@ -63,31 +74,82 @@ export default function StaticTransactions({ records }: StaticTransactionsProps)
</View>
)}
renderItem={({ item }) => (
<View className="flex-row items-center mb-4 gap-4">
<View className="w-10 h-10 rounded-full bg-gray-100 items-center justify-center">
<MoneyCategoryIcon name={item.category_icon} size={22} color="#000" />
</View>
renderItem={({ item }) => {
const isOpen = item.id === selectedId
<View className="flex-1">
<Text className="text-gray-800 font-medium text-base">
{item.category_name}
</Text>
return (
<Menu
placement="bottom"
offset={5}
isOpen={isOpen}
onClose={() => setSelectedId(null)}
trigger={({ onLongPress, onPressOut, delayLongPress, android_ripple, ...triggerProps }) => (
<Pressable
{...triggerProps}
onLongPress={() => setSelectedId(item.id)}
delayLongPress={200}
android_ripple={undefined}
>
<View
className="flex-row items-center gap-4 py-2 px-5"
style={{
backgroundColor: item.id === selectedId ? '#f2f2f2' : 'transparent',
}}
>
<View className="w-10 h-10 rounded-full bg-gray-100 items-center justify-center">
<MoneyCategoryIcon name={item.category_icon} size={22} color="#000" />
</View>
{item.remark && (
<Text className="text-gray-400 text-sm">{item.remark}</Text>
<View className="flex-1">
<Text className="text-gray-800 font-medium text-base">{item.category_name}</Text>
{item.remark && <Text className="text-gray-400 text-sm">{item.remark}</Text>}
</View>
<View className="flex-row flex">
<Text className="text-sm text-gray-500">
{item.category_type === 1 ? '+' : '-'}
{' '}
{item.amount}
</Text>
</View>
<Badge size='sm' variant='solid' action='muted'>
<BadgeText>宝贝</BadgeText>
</Badge>
</View>
</Pressable>
)}
</View>
<View className="flex-row flex">
<Text className="text-sm text-gray-500">
{item.category_type === 0 ? '+' : '-'}
{' '}
{item.amount}
</Text>
</View>
</View>
)}
>
{isOpen && (
<>
<MenuItem
key="update"
textValue="update"
onPress={() => {
onUpdate?.(item)
}}
>
<LucideIcon icon="Pencil" size={14} className="mr-2" />
<MenuItemLabel size="sm">
修改
</MenuItemLabel>
</MenuItem>
<MenuItem
key="delete"
textValue="delete"
onPress={() => {
onDelete?.(item.id)
}}
>
<LucideIcon icon="Trash2" size={14} className="mr-2" />
<MenuItemLabel size="sm">
删除
</MenuItemLabel>
</MenuItem>
</>
)}
</Menu>
)
}}
/>
)
}
@@ -1,5 +1,4 @@
import type { MoneyRecordGroup, MoneyRecordItem, QueryMoneyRecordsResult } from '@/types/money/record'
import Big from 'big.js'
import React, { useEffect, useState } from 'react'
import { Text, TouchableOpacity, View } from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
@@ -7,9 +6,9 @@ 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 { queryMoneyRecordsApi } from '@/request/api/money/record'
import { deleteMoneyRecordApi, queryMoneyRecordsApi } from '@/request/api/money/record'
import { add, sub } from '@/utils/math'
import { getCurrentYearMonth } from '@/utils/time'
import { getCurrentYearMonth, isInMonth } from '@/utils/time'
import { toast } from '@/utils/toast'
import AddMoneyDrawer from './components/AddDrawer'
import TransactionsList from './components/TransactionsList'
@@ -30,6 +29,8 @@ export default function Money() {
total_income: '0',
records: [],
})
// 更新
const [updateItem, setUpdateItem] = useState<MoneyRecordItem | null>(null)
useEffect(() => {
queryMoneyRecordsApi({
@@ -53,6 +54,11 @@ export default function Money() {
const recordTime = data.record_time
setMoneyRecords((prev) => {
// 不在当前月份 → 不添加,直接返回
if (!isInMonth(recordTime, searchParams.record_time)) {
return prev
}
const next = structuredClone(prev)
// 查找分组
@@ -64,12 +70,12 @@ export default function Money() {
group.records.unshift(data)
// 更新分组 & 总计
if (data.category_type === 0) {
if (data.category_type === 1) {
// 收入
group.income = add(group.income, data.amount)
next.total_income = add(next.total_income, data.amount)
}
else if (data.category_type === 1) {
else if (data.category_type === 0) {
// 支出
group.expense = add(group.expense, data.amount)
next.total_expense = add(next.total_expense, data.amount)
@@ -79,8 +85,8 @@ export default function Money() {
}
// === 无分组,新建 ===
const income = data.category_type === 0 ? data.amount : '0'
const expense = data.category_type === 1 ? data.amount : '0'
const expense = data.category_type === 0 ? data.amount : '0'
const income = data.category_type === 1 ? data.amount : '0'
const newGroup: MoneyRecordGroup = {
record_time: recordTime,
@@ -94,11 +100,9 @@ export default function Money() {
const index = next.records.findIndex(g => g.record_time < recordTime)
if (index === -1) {
// 所有日期都比它大 → 插入最后
next.records.push(newGroup)
}
else {
// 插入正确位置
next.records.splice(index, 0, newGroup)
}
@@ -114,6 +118,164 @@ export default function Money() {
})
}
function handleUpdateMoneyRecord(updated: MoneyRecordItem) {
setMoneyRecords((prev) => {
const next = structuredClone(prev)
// 找到旧记录
const oldGroupIndex = next.records.findIndex(g =>
g.records.some(r => r.id === updated.id),
)
if (oldGroupIndex === -1)
return next
const oldGroup = next.records[oldGroupIndex]
const oldRecordIndex = oldGroup.records.findIndex(r => r.id === updated.id)
const oldRecord = oldGroup.records[oldRecordIndex]
const oldAmount = oldRecord.amount
const newAmount = updated.amount
const oldType = oldRecord.category_type
const newType = updated.category_type
const oldDate = oldRecord.record_time
const newDate = updated.record_time
// record_time 没变 → 同分组更新
if (oldDate === newDate) {
oldGroup.records[oldRecordIndex] = updated
// 调整分组金额
if (oldType === 1)
oldGroup.income = sub(oldGroup.income, oldAmount)
if (oldType === 0)
oldGroup.expense = sub(oldGroup.expense, oldAmount)
if (newType === 1)
oldGroup.income = add(oldGroup.income, newAmount)
if (newType === 0)
oldGroup.expense = add(oldGroup.expense, newAmount)
// 调整总计
if (oldType === 1)
next.total_income = sub(next.total_income, oldAmount)
if (oldType === 0)
next.total_expense = sub(next.total_expense, oldAmount)
if (newType === 1)
next.total_income = add(next.total_income, newAmount)
if (newType === 0)
next.total_expense = add(next.total_expense, newAmount)
return next
}
// -------------------------
// 判断是否在当前月份内
// -------------------------
const currentMonth = searchParams.record_time // 例如 "2025-01"
const isInCurrentMonth = isInMonth(newDate, currentMonth)
// 1. 先移除旧分组 & 调整金额(无论是否在当前月份,都要做)
if (oldType === 1)
oldGroup.income = sub(oldGroup.income, oldAmount)
if (oldType === 0)
oldGroup.expense = sub(oldGroup.expense, oldAmount)
if (oldType === 1)
next.total_income = sub(next.total_income, oldAmount)
if (oldType === 0)
next.total_expense = sub(next.total_expense, oldAmount)
oldGroup.records.splice(oldRecordIndex, 1)
if (oldGroup.records.length === 0) {
next.records.splice(oldGroupIndex, 1)
}
// -------------------------
// 不在当前月份 → 不显示,直接返回 next
// -------------------------
if (!isInCurrentMonth) {
return next
}
// -------------------------
// 在当前月份 → 移动到新分组
// -------------------------
// 找新分组,如果没有就创建
let newGroup = next.records.find(g => g.record_time === newDate)
if (!newGroup) {
newGroup = {
record_time: newDate,
income: '0.00',
expense: '0.00',
records: [],
}
// 按日期倒序插入
const insertIndex = next.records.findIndex(g => g.record_time < newDate)
if (insertIndex === -1)
next.records.push(newGroup)
else
next.records.splice(insertIndex, 0, newGroup)
}
// 添加到新分组
newGroup.records.unshift(updated)
// 加金额
if (newType === 1)
newGroup.income = add(newGroup.income, newAmount)
if (newType === 0)
newGroup.expense = add(newGroup.expense, newAmount)
if (newType === 1)
next.total_income = add(next.total_income, newAmount)
if (newType === 0)
next.total_expense = add(next.total_expense, newAmount)
return next
})
}
function handleDeleteRecord(id: number) {
// 直接更新本地状态
setMoneyRecords((prev) => {
const next = structuredClone(prev)
// 遍历每个分组
next.records.forEach((group) => {
const index = group.records.findIndex(r => r.id === id)
if (index !== -1) {
const record = group.records[index]
// 更新分组金额
if (record.category_type === 1) {
group.income = sub(group.income, record.amount)
next.total_income = sub(next.total_income, record.amount)
}
else if (record.category_type === 0) {
group.expense = sub(group.expense, record.amount)
next.total_expense = sub(next.total_expense, record.amount)
}
// 删除记录
group.records.splice(index, 1)
}
})
// 删除空分组
next.records = next.records.filter(g => g.records.length > 0)
return next
})
// 异步请求删除 API
deleteMoneyRecordApi({ id }).catch((err) => {
console.log(err)
})
}
return (
<View
className="flex-1"
@@ -122,7 +284,7 @@ export default function Money() {
}}
>
<View>
<View className="flex flex-row justify-between items-center px-4">
<View className="flex flex-row justify-between items-center px-3">
<TouchableOpacity
onPress={() => setShowDatePicker(true)}
className="flex flex-row items-center gap-1"
@@ -198,8 +360,17 @@ export default function Money() {
</View>
</View>
<View className="flex-1 px-4 mt-2">
<TransactionsList records={moneyRecords.records} />
<View className="flex-1 mt-2">
<TransactionsList
records={moneyRecords.records}
onUpdate={(data) => {
setUpdateItem(data)
setShowAddDrawer(true)
}}
onDelete={(id) => {
handleDeleteRecord(id)
}}
/>
</View>
<Fab
@@ -213,12 +384,20 @@ export default function Money() {
<AddMoneyDrawer
isOpen={showAddDrawer}
updateData={updateItem}
onClose={() => {
setShowAddDrawer(false)
setUpdateItem(null)
}}
onConfirm={(res) => {
setShowAddDrawer(false)
handleAddMoneyRecord(res)
if (updateItem !== null) {
setUpdateItem(null)
handleUpdateMoneyRecord(res)
}
else {
handleAddMoneyRecord(res)
}
}}
/>
@@ -45,7 +45,7 @@ export default function User() {
</View>
<AButton onPress={() => {
router.replace('/auth/login')
router.replace('/auth')
}}
>
跳转到登陆页面
@@ -0,0 +1,214 @@
'use client';
import React from 'react';
import { Text, View } from 'react-native';
import { PrimitiveIcon, UIIcon } from '@gluestack-ui/core/icon/creator';
import { tva } from '@gluestack-ui/utils/nativewind-utils';
import {
withStyleContext,
useStyleContext,
} from '@gluestack-ui/utils/nativewind-utils';
import { cssInterop } from 'nativewind';
import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils';
import { Svg } from 'react-native-svg';
const SCOPE = 'BADGE';
const badgeStyle = tva({
base: 'flex-row items-center rounded-sm data-[disabled=true]:opacity-50 px-2 py-1',
variants: {
action: {
error: 'bg-background-error border-error-300',
warning: 'bg-background-warning border-warning-300',
success: 'bg-background-success border-success-300',
info: 'bg-background-info border-info-300',
muted: 'bg-background-muted border-background-300',
},
variant: {
solid: '',
outline: 'border',
},
size: {
sm: '',
md: '',
lg: '',
},
},
});
const badgeTextStyle = tva({
base: 'text-typography-700 font-body font-normal tracking-normal uppercase',
parentVariants: {
action: {
error: 'text-error-600',
warning: 'text-warning-600',
success: 'text-success-600',
info: 'text-info-600',
muted: 'text-background-800',
},
size: {
sm: 'text-2xs',
md: 'text-xs',
lg: 'text-sm',
},
},
variants: {
isTruncated: {
true: 'web:truncate',
},
bold: {
true: 'font-bold',
},
underline: {
true: 'underline',
},
strikeThrough: {
true: 'line-through',
},
sub: {
true: 'text-xs',
},
italic: {
true: 'italic',
},
highlight: {
true: 'bg-yellow-500',
},
},
});
const badgeIconStyle = tva({
base: 'fill-none',
parentVariants: {
action: {
error: 'text-error-600',
warning: 'text-warning-600',
success: 'text-success-600',
info: 'text-info-600',
muted: 'text-background-800',
},
size: {
sm: 'h-3 w-3',
md: 'h-3.5 w-3.5',
lg: 'h-4 w-4',
},
},
});
const ContextView = withStyleContext(View, SCOPE);
cssInterop(PrimitiveIcon, {
className: {
target: 'style',
nativeStyleToProp: {
height: true,
width: true,
fill: true,
color: 'classNameColor',
stroke: true,
},
},
});
type IBadgeProps = React.ComponentPropsWithoutRef<typeof ContextView> &
VariantProps<typeof badgeStyle>;
function Badge({
children,
action = 'muted',
variant = 'solid',
size = 'md',
className,
...props
}: { className?: string } & IBadgeProps) {
return (
<ContextView
className={badgeStyle({ action, variant, class: className })}
{...props}
context={{
action,
variant,
size,
}}
>
{children}
</ContextView>
);
}
type IBadgeTextProps = React.ComponentPropsWithoutRef<typeof Text> &
VariantProps<typeof badgeTextStyle>;
const BadgeText = React.forwardRef<
React.ComponentRef<typeof Text>,
IBadgeTextProps
>(function BadgeText({ children, className, size, ...props }, ref) {
const { size: parentSize, action: parentAction } = useStyleContext(SCOPE);
return (
<Text
ref={ref}
className={badgeTextStyle({
parentVariants: {
size: parentSize,
action: parentAction,
},
size,
class: className,
})}
{...props}
>
{children}
</Text>
);
});
type IBadgeIconProps = React.ComponentPropsWithoutRef<typeof PrimitiveIcon> &
VariantProps<typeof badgeIconStyle>;
const BadgeIcon = React.forwardRef<
React.ComponentRef<typeof Svg>,
IBadgeIconProps
>(function BadgeIcon({ className, size, ...props }, ref) {
const { size: parentSize, action: parentAction } = useStyleContext(SCOPE);
if (typeof size === 'number') {
return (
<UIIcon
ref={ref}
{...props}
className={badgeIconStyle({ class: className })}
size={size}
/>
);
} else if (
(props?.height !== undefined || props?.width !== undefined) &&
size === undefined
) {
return (
<UIIcon
ref={ref}
{...props}
className={badgeIconStyle({ class: className })}
/>
);
}
return (
<UIIcon
className={badgeIconStyle({
parentVariants: {
size: parentSize,
action: parentAction,
},
size,
class: className,
})}
{...props}
ref={ref}
/>
);
});
Badge.displayName = 'Badge';
BadgeText.displayName = 'BadgeText';
BadgeIcon.displayName = 'BadgeIcon';
export { Badge, BadgeIcon, BadgeText };
@@ -0,0 +1,220 @@
'use client'
import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils'
import type {
MotionComponentProps,
} from '@legendapp/motion'
import type { ViewStyle } from 'react-native'
import { createMenu } from '@gluestack-ui/core/menu/creator'
import { tva } from '@gluestack-ui/utils/nativewind-utils'
import {
AnimatePresence,
Motion,
} from '@legendapp/motion'
import { cssInterop } from 'nativewind'
import React from 'react'
import { Pressable, Text, View } from 'react-native'
type IMotionViewProps = React.ComponentProps<typeof View>
& MotionComponentProps<typeof View, ViewStyle, unknown, unknown, unknown>
const MotionView = Motion.View as React.ComponentType<IMotionViewProps>
const menuStyle = tva({
base: 'rounded-md bg-background-0 border border-outline-100 p-1 shadow-hard-5',
})
const menuItemStyle = tva({
base: 'min-w-[200px] p-3 flex-row items-center rounded data-[hover=true]:bg-background-50 data-[active=true]:bg-background-100 data-[focus=true]:bg-background-50 data-[focus=true]:web:outline-none data-[focus=true]:web:outline-0 data-[disabled=true]:opacity-40 data-[disabled=true]:web:cursor-not-allowed data-[focus-visible=true]:web:outline-2 data-[focus-visible=true]:web:outline-primary-700 data-[focus-visible=true]:web:outline data-[focus-visible=true]:web:cursor-pointer data-[disabled=true]:data-[focus=true]:bg-transparent',
})
const menuBackdropStyle = tva({
base: 'absolute top-0 bottom-0 left-0 right-0 web:cursor-default',
// add this classnames if you want to give background color to backdrop
// opacity-50 bg-background-500,
})
const menuSeparatorStyle = tva({
base: 'bg-background-200 h-px w-full',
})
const menuItemLabelStyle = tva({
base: 'text-typography-700 font-normal font-body',
variants: {
isTruncated: {
true: 'web:truncate',
},
bold: {
true: 'font-bold',
},
underline: {
true: 'underline',
},
strikeThrough: {
true: 'line-through',
},
size: {
'2xs': 'text-2xs',
'xs': 'text-xs',
'sm': 'text-sm',
'md': 'text-base',
'lg': 'text-lg',
'xl': 'text-xl',
'2xl': 'text-2xl',
'3xl': 'text-3xl',
'4xl': 'text-4xl',
'5xl': 'text-5xl',
'6xl': 'text-6xl',
},
sub: {
true: 'text-xs',
},
italic: {
true: 'italic',
},
highlight: {
true: 'bg-yellow-500',
},
},
})
const BackdropPressable = React.forwardRef<
React.ComponentRef<typeof Pressable>,
React.ComponentPropsWithoutRef<typeof Pressable>
& VariantProps<typeof menuBackdropStyle>
>(({ className, ...props }, ref) => {
return (
<Pressable
ref={ref}
className={menuBackdropStyle({
class: className,
})}
{...props}
/>
)
})
type IMenuItemProps = VariantProps<typeof menuItemStyle> & {
className?: string
} & React.ComponentPropsWithoutRef<typeof Pressable>
const Item = React.forwardRef<
React.ComponentRef<typeof Pressable>,
IMenuItemProps
>(({ className, ...props }, ref) => {
return (
<Pressable
ref={ref}
className={menuItemStyle({
class: className,
})}
{...props}
/>
)
})
const Separator = React.forwardRef<
React.ComponentRef<typeof View>,
React.ComponentPropsWithoutRef<typeof View>
& VariantProps<typeof menuSeparatorStyle>
>(({ className, ...props }, ref) => {
return (
<View
ref={ref}
className={menuSeparatorStyle({ class: className })}
{...props}
/>
)
})
export const UIMenu = createMenu({
Root: MotionView,
Item,
Label: Text,
Backdrop: BackdropPressable,
AnimatePresence,
Separator,
})
cssInterop(MotionView, { className: 'style' })
type IMenuProps = React.ComponentProps<typeof UIMenu>
& VariantProps<typeof menuStyle> & { className?: string }
type IMenuItemLabelProps = React.ComponentProps<typeof UIMenu.ItemLabel>
& VariantProps<typeof menuItemLabelStyle> & { className?: string }
const Menu = React.forwardRef<React.ComponentRef<typeof UIMenu>, IMenuProps>(
({ className, ...props }, ref) => {
return (
<UIMenu
ref={ref}
initial={{
opacity: 0,
scale: 0.8,
}}
animate={{
opacity: 1,
scale: 1,
}}
exit={{
opacity: 0,
scale: 0.8,
}}
transition={{
type: 'timing',
duration: 100,
}}
className={menuStyle({
class: className,
})}
{...props}
/>
)
},
)
const MenuItem = UIMenu.Item
const MenuItemLabel = React.forwardRef<
React.ComponentRef<typeof UIMenu.ItemLabel>,
IMenuItemLabelProps
>((
{
className,
isTruncated,
bold,
underline,
strikeThrough,
size = 'md',
sub,
italic,
highlight,
...props
},
ref,
) => {
return (
<UIMenu.ItemLabel
ref={ref}
className={menuItemLabelStyle({
isTruncated: isTruncated as boolean,
bold: bold as boolean,
underline: underline as boolean,
strikeThrough: strikeThrough as boolean,
size,
sub: sub as boolean,
italic: italic as boolean,
highlight: highlight as boolean,
class: className,
})}
{...props}
/>
)
})
const MenuSeparator = UIMenu.Separator
Menu.displayName = 'Menu'
MenuItem.displayName = 'MenuItem'
MenuItemLabel.displayName = 'MenuItemLabel'
MenuSeparator.displayName = 'MenuSeparator'
export { Menu, MenuItem, MenuItemLabel, MenuSeparator }
@@ -1,4 +1,12 @@
import type { InsertMoneyRecordParams, InsertMoneyRecordResult, QueryMoneyRecordsParams, QueryMoneyRecordsResult } from '@/types/money/record'
import type {
DeleteMoneyRecordParams,
InsertMoneyRecordParams,
InsertMoneyRecordResult,
QueryMoneyRecordsParams,
QueryMoneyRecordsResult,
UpdateMoneyRecordParams,
} from '@/types/money/record'
import alovaInstance from '../../index'
const API_PREFIX = '/money'
@@ -10,3 +18,11 @@ export function queryMoneyRecordsApi(params: QueryMoneyRecordsParams) {
export function insertMoneyRecordApi(params: InsertMoneyRecordParams) {
return alovaInstance.Post<ResponseData<InsertMoneyRecordResult>>(`${API_PREFIX}/insert`, params)
}
export function updateMoneyRecordApi(params: UpdateMoneyRecordParams) {
return alovaInstance.Post<ResponseData<any>>(`${API_PREFIX}/update`, params)
}
export function deleteMoneyRecordApi(params: DeleteMoneyRecordParams) {
return alovaInstance.Post<ResponseData<any>>(`${API_PREFIX}/delete`, params)
}
@@ -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,
@@ -40,5 +40,12 @@ export interface QueryMoneyRecordsResult {
// 新增请求参数
export type InsertMoneyRecordParams = Omit<MoneyRecordBase, 'id' | 'create_time'>
// 新增返回结果
export interface InsertMoneyRecordResult extends MoneyRecordBase {}
// 更新
export interface UpdateMoneyRecordParams extends Omit<MoneyRecordBase, 'create_time' | 'user_id'> {}
// 删除
export interface DeleteMoneyRecordParams {
id: number
}
+6 -12
View File
@@ -2,31 +2,25 @@
import Big from 'big.js'
/**
* 加法
* @param a string number
* @param b string number
* @returns string number
* 加法(保留两位小数)
*/
export function add(a: string | number, b: string | number): string {
try {
return new Big(a).plus(new Big(b)).toString()
return new Big(a).plus(new Big(b)).toFixed(2) // ⭐ 保留两位
}
catch {
return '0'
return '0.00'
}
}
/**
* 减法
* @param a string number
* @param b string number
* @returns string number
* 减法(保留两位小数)
*/
export function sub(a: string | number, b: string | number): string {
try {
return new Big(a).minus(new Big(b)).toString()
return new Big(a).minus(new Big(b)).toFixed(2) // ⭐ 保留两位
}
catch {
return '0'
return '0.00'
}
}
@@ -62,3 +62,9 @@ export function toDateTimeString(date: string | number | Date): string {
const d = dayjs(toDate(date))
return d.isValid() ? d.format('YYYY-MM-DD HH:mm:ss') : ''
}
export function isInMonth(date: string, month: string): boolean {
// date 形如 "2025-01-12"
// month 形如 "2025-01"
return dayjs(date).format('YYYY-MM') === month
}