diff --git a/code/base-project/base-app/src/app/(tabs)/money/components/BalanceCard.tsx b/code/base-project/base-app/src/app/(tabs)/money/components/BalanceCard.tsx index 5add6f48..220f7b29 100644 --- a/code/base-project/base-app/src/app/(tabs)/money/components/BalanceCard.tsx +++ b/code/base-project/base-app/src/app/(tabs)/money/components/BalanceCard.tsx @@ -12,9 +12,9 @@ function BalanceCard() { {/* 总余额 */} - 总余额 (CNY) - - ¥ 12,322.67 + 总余额 + + 12,322.67 @@ -22,12 +22,12 @@ function BalanceCard() { {/* 收入 */} - + 收入 ↑ +10% - - ¥ 12,232.00 + + 12,232.00 @@ -36,12 +36,12 @@ function BalanceCard() { {/* 支出 */} - + 支出 ↓ -20% - - ¥ 1,980.00 + + 1,980.00 diff --git a/code/base-project/base-app/src/app/(tabs)/money/components/MoneyIcon.tsx b/code/base-project/base-app/src/app/(tabs)/money/components/MoneyIcon.tsx index 6247202b..7e920257 100644 --- a/code/base-project/base-app/src/app/(tabs)/money/components/MoneyIcon.tsx +++ b/code/base-project/base-app/src/app/(tabs)/money/components/MoneyIcon.tsx @@ -14,7 +14,7 @@ const iconMap: Record = { const DefaultIcon = IconLingshi // 根据类别 name 返回图标组件 -export function getCategoryIcon(name: string, size = 24,type: , color?: string) { +export function getCategoryIcon(name: string, size = 24, color?: string) { const IconComponent = iconMap[name] || DefaultIcon return } diff --git a/code/base-project/base-app/src/app/(tabs)/money/components/TransactionsList.tsx b/code/base-project/base-app/src/app/(tabs)/money/components/TransactionsList.tsx index 273fbf39..7f89d975 100644 --- a/code/base-project/base-app/src/app/(tabs)/money/components/TransactionsList.tsx +++ b/code/base-project/base-app/src/app/(tabs)/money/components/TransactionsList.tsx @@ -39,7 +39,7 @@ const sections: DaySection[] = [ { id: '5', title: '电影票', description: '周末电影', amount: 45, type: 'expense' }, { id: '6', title: '兼职收入', description: '家教', amount: 1200, type: 'income' }, ], - }, + } ] export default function StaticTransactions() { @@ -70,12 +70,12 @@ export default function StaticTransactions() { {section.data.map(item => ( {/* 图标 */} {/* */} - + {/* 标题 + 描述 */} diff --git a/code/base-project/base-app/src/app/(tabs)/money/index.tsx b/code/base-project/base-app/src/app/(tabs)/money/index.tsx index d8d6be1c..4264c4fd 100644 --- a/code/base-project/base-app/src/app/(tabs)/money/index.tsx +++ b/code/base-project/base-app/src/app/(tabs)/money/index.tsx @@ -1,59 +1,126 @@ +import type { LayoutChangeEvent } from 'react-native' import { LinearGradient } from 'expo-linear-gradient' -import React, { useState } from 'react' -import { ScrollView, Text, View } from 'react-native' +import React, { useRef, useState } from 'react' +import { Text, View } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { LucideIcon } from '@/components/common/ao' - -import BalanceCard from './components/BalanceCard' -import TabSelector from './components/TabSelector' -import TransactionsList from './components/TransactionsList' +import { IconJiaotong } from '@/components/iconfont' +import { Grid, GridItem } from '@/components/ui/grid' +import { Progress, ProgressFilledTrack } from '@/components/ui/progress' export default function Money() { const insets = useSafeAreaInsets() - const [selected, setSelected] = useState(0) - + const [gradientHeight, setGradientHeight] = useState(200) // 默认渐变高度 + const gridLayout = useRef({ y: 0, height: 0 }) + const onGridLayout = (e: LayoutChangeEvent) => { + const { y, height } = e.nativeEvent.layout + gridLayout.current = { y, height } + setGradientHeight(y + height / 2 + 16 + insets.top) + } return ( - - {/* 顶部固定栏 */} - + + + {/* 页面内容 */} + - 2025/11/10 - + 2025/11/10 + - + - 筛选 - setSelected(tab)} - /> - + + + + 余额 + ↑20% + + + 22313.00 + + - {/* 可滚动内容 */} - - - - - + + + + 收入 + ↑20% + + 11.0 + + + + 支出 + ↑20% + + 11.0 + + + + 结余 + ↑20% + + 11.0 + + + + + 预算 + + + + + + + + 点击设置 + + + + + + {[...Array.from({ length: 5 })].map((_, i) => ( + + + 账单 + + ))} + + + + + ) } diff --git a/code/base-project/base-app/src/app/(tabs)/money/index2.tsx b/code/base-project/base-app/src/app/(tabs)/money/index2.tsx new file mode 100644 index 00000000..6a6e3712 --- /dev/null +++ b/code/base-project/base-app/src/app/(tabs)/money/index2.tsx @@ -0,0 +1,90 @@ +// import { LinearGradient } from 'expo-linear-gradient' +// import React from 'react' +// import { Dimensions, View } from 'react-native' +// import { useSafeAreaInsets } from 'react-native-safe-area-context' + +// export default function Money() { +// const insets = useSafeAreaInsets() +// const { height } = Dimensions.get('window') +// const gradientHeight = height / 3 // 渐变高度 1/3 + +// return ( +// +// {/* 渐变层 */} +// +// {/* 可以在这里放置顶部卡片或统计信息 */} +// +// +// ) +// } +import { LinearGradient } from 'expo-linear-gradient' +import React, { useState } from 'react' +import { ScrollView, Text, View } from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import { LucideIcon } from '@/components/common/ao' + +import BalanceCard from './components/BalanceCard' +import TabSelector from './components/TabSelector' +import TransactionsList from './components/TransactionsList' + +export default function Money() { + const insets = useSafeAreaInsets() + const [selected, setSelected] = useState(0) + + return ( + + + + + 2025/11/10 + + + + + 筛选 + + + + setSelected(tab)} + /> + + + {/* 可滚动内容 */} + + + + + + ) +} diff --git a/code/base-project/base-app/src/components/ui/grid/index.tsx b/code/base-project/base-app/src/components/ui/grid/index.tsx new file mode 100644 index 00000000..ecff1b1d --- /dev/null +++ b/code/base-project/base-app/src/components/ui/grid/index.tsx @@ -0,0 +1,283 @@ +import React, { + useState, + createContext, + useContext, + useMemo, + forwardRef, +} from 'react'; +import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils'; +import { View, Dimensions, Platform, ViewProps } from 'react-native'; +import { gridStyle, gridItemStyle } from './styles'; +import { cssInterop } from 'nativewind'; +import { + useBreakpointValue, + getBreakPointValue, +} from '@gluestack-ui/utils/hooks'; +const { width: DEVICE_WIDTH } = Dimensions.get('window'); +const GridContext = createContext({}); +function arrangeChildrenIntoRows({ + childrenArray, + colSpanArr, + numColumns, +}: { + childrenArray: React.ReactNode[]; + colSpanArr: number[]; + numColumns: number; +}) { + let currentRow = 1; + let currentRowTotalColSpan = 0; + // store how many items in each row + const rowItemsCount: { + [key: number]: number[]; + } = {}; + for (let i = 0; i < childrenArray.length; i++) { + const colSpan = colSpanArr[i]; + // if current row is full, go to next row + if (currentRowTotalColSpan + colSpan > numColumns) { + currentRow++; + currentRowTotalColSpan = colSpan; + } else { + // if current row is not full, add colSpan to current row + currentRowTotalColSpan += colSpan; + } + rowItemsCount[currentRow] = rowItemsCount[currentRow] + ? [...rowItemsCount[currentRow], i] + : [i]; + } + return rowItemsCount; +} +function generateResponsiveNumColumns({ gridClass }: { gridClass: string }) { + const gridClassNamePattern = /\b(?:\w+:)?grid-cols-?\d+\b/g; + const numColumns = gridClass?.match(gridClassNamePattern); + if (!numColumns) { + return 12; + } + const regex = /^(?:(\w+):)?grid-cols-?(\d+)$/; + const result: any = {}; + numColumns.forEach((classname) => { + const match = classname.match(regex); + if (match) { + const prefix = match[1] || 'default'; + const value = parseInt(match[2], 10); + result[prefix] = value; + } + }); + return result; +} +function generateResponsiveColSpans({ + gridItemClassName, +}: { + gridItemClassName: string; +}) { + const gridClassNamePattern = /\b(?:\w+:)?col-span-?\d+\b/g; + const colSpan: any = gridItemClassName?.match(gridClassNamePattern); + if (!colSpan) { + return 1; + } + const regex = /^(?:(\w+):)?col-span-?(\d+)$/; + const result: any = {}; + colSpan.forEach((classname: any) => { + const match = classname.match(regex); + if (match) { + const prefix = match[1] || 'default'; + const value = parseInt(match[2], 10); + result[prefix] = value; + } + }); + return result; +} +type IGridProps = ViewProps & + VariantProps & { + gap?: number; + rowGap?: number; + columnGap?: number; + flexDirection?: 'row' | 'column' | 'row-reverse' | 'column-reverse'; + padding?: number; + paddingLeft?: number; + paddingRight?: number; + paddingStart?: number; + paddingEnd?: number; + borderWidth?: number; + borderLeftWidth?: number; + borderRightWidth?: number; + _extra: { + className: string; + }; + }; +const Grid = forwardRef, IGridProps>( + function Grid({ className, _extra, children, ...props }, ref) { + const [calculatedWidth, setCalculatedWidth] = useState(null); + const gridClass = _extra?.className; + const obj = generateResponsiveNumColumns({ gridClass }); + const responsiveNumColumns: any = useBreakpointValue(obj); + const itemsPerRow = useMemo(() => { + // get the colSpan of each child + const colSpanArr = React.Children.map(children, (child: any) => { + const gridItemClassName = child?.props?._extra?.className; + const colSpan2 = getBreakPointValue( + generateResponsiveColSpans({ gridItemClassName }), + DEVICE_WIDTH + ); + const colSpan = colSpan2 ? colSpan2 : 1; + if (colSpan > responsiveNumColumns) { + return responsiveNumColumns; + } + return colSpan; + }); + const childrenArray = React.Children.toArray(children); + const rowItemsCount = arrangeChildrenIntoRows({ + childrenArray, + colSpanArr, + numColumns: responsiveNumColumns, + }); + return rowItemsCount; + }, [responsiveNumColumns, children]); + const childrenWithProps = React.Children.map(children, (child, index) => { + if (React.isValidElement(child)) { + return React.cloneElement(child, { key: index, index: index } as any); + } + return child; + }); + const gridClassMerged = `${Platform.select({ + web: gridClass ?? '', + })}`; + const contextValue = useMemo(() => { + return { + calculatedWidth, + numColumns: responsiveNumColumns, + itemsPerRow, + flexDirection: props?.flexDirection || 'row', + gap: props?.gap || 0, + columnGap: props?.columnGap || 0, + }; + }, [calculatedWidth, itemsPerRow, responsiveNumColumns, props]); + const borderLeftWidth = props?.borderLeftWidth || props?.borderWidth || 0; + const borderRightWidth = props?.borderRightWidth || props?.borderWidth || 0; + const borderWidthToSubtract = borderLeftWidth + borderRightWidth; + return ( + + { + const paddingLeftToSubtract = + props?.paddingStart || props?.paddingLeft || props?.padding || 0; + const paddingRightToSubtract = + props?.paddingEnd || props?.paddingRight || props?.padding || 0; + const gridWidth = + Math.floor(event.nativeEvent.layout.width) - + paddingLeftToSubtract - + paddingRightToSubtract - + borderWidthToSubtract; + setCalculatedWidth(gridWidth); + }} + {...props} + > + {calculatedWidth && childrenWithProps} + + + ); + } +); +cssInterop(Grid, { + className: { + target: 'style', + nativeStyleToProp: { + gap: 'gap', + rowGap: 'rowGap', + columnGap: 'columnGap', + flexDirection: 'flexDirection', + padding: 'padding', + paddingLeft: 'paddingLeft', + paddingRight: 'paddingRight', + paddingStart: 'paddingStart', + paddingEnd: 'paddingEnd', + borderWidth: 'borderWidth', + borderLeftWidth: 'borderLeftWidth', + borderRightWidth: 'borderRightWidth', + }, + }, +}); +type IGridItemProps = ViewProps & + VariantProps & { + index?: number; + _extra: { + className: string; + }; + }; +const GridItem = forwardRef, IGridItemProps>( + function GridItem({ className, _extra, ...props }, ref) { + const { + calculatedWidth, + numColumns, + itemsPerRow, + flexDirection, + gap, + columnGap, + } = useContext(GridContext); + const gridItemClass = _extra?.className; + const responsiveColSpan = (useBreakpointValue( + generateResponsiveColSpans({ gridItemClassName: gridItemClass }) + ) ?? 1) as number; + const flexBasisValue = useMemo(() => { + if (!calculatedWidth || !numColumns || responsiveColSpan <= 0) { + return 'auto'; + } + if (flexDirection?.includes('column')) { + return 'auto'; + } + // Find which row this item is in + const row = Object.keys(itemsPerRow).find((key) => { + return itemsPerRow[key].includes(props?.index); + }); + if (!row) { + return 'auto'; + } + const rowColsCount = itemsPerRow[row]?.length || 1; + const space = columnGap || gap || 0; + // Calculate available width accounting for gaps + const totalGapWidth = space * (rowColsCount - 1); + const availableWidth = calculatedWidth - totalGapWidth; + // Calculate the width for this item based on its column span + const itemWidth = (availableWidth * responsiveColSpan) / numColumns; + // Return the width directly instead of percentage for better native compatibility + return Math.max(0, Math.floor(itemWidth)); + }, [ + calculatedWidth, + responsiveColSpan, + numColumns, + columnGap, + gap, + flexDirection, + itemsPerRow, + props?.index, + ]); + return ( + + ); + } +); +Grid.displayName = 'Grid'; +GridItem.displayName = 'GridItem'; +export { Grid, GridItem }; diff --git a/code/base-project/base-app/src/components/ui/grid/index.web.tsx b/code/base-project/base-app/src/components/ui/grid/index.web.tsx new file mode 100644 index 00000000..cad120ef --- /dev/null +++ b/code/base-project/base-app/src/components/ui/grid/index.web.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import { gridStyle, gridItemStyle } from './styles'; + +import type { VariantProps } from '@gluestack-ui/utils/nativewind-utils'; + +type IGridProps = React.ComponentPropsWithoutRef<'div'> & + VariantProps & { + gap?: number; + rowGap?: number; + columnGap?: number; + flexDirection?: 'row' | 'column' | 'row-reverse' | 'column-reverse'; + padding?: number; + paddingLeft?: number; + paddingRight?: number; + paddingStart?: number; + paddingEnd?: number; + _extra: { + className: string; + }; + }; + +const Grid = React.forwardRef(function Grid( + { className, _extra, ...props }, + ref +) { + const gridClass = _extra?.className; + const finalGridClass = gridClass ?? ''; + return ( +
+ ); +}); + +type IGridItemProps = React.ComponentPropsWithoutRef<'div'> & + VariantProps & { + index?: number; + _extra: { + className: string; + }; + }; +const GridItem = React.forwardRef( + function GridItem({ className, _extra, ...props }, ref) { + const gridItemClass = _extra?.className; + + const finalGridItemClass = gridItemClass ?? ''; + return ( +
+ ); + } +); + +Grid.displayName = 'Grid'; +GridItem.displayName = 'GridItem'; + +export { Grid, GridItem }; diff --git a/code/base-project/base-app/src/components/ui/grid/styles.tsx b/code/base-project/base-app/src/components/ui/grid/styles.tsx new file mode 100644 index 00000000..0fe60e24 --- /dev/null +++ b/code/base-project/base-app/src/components/ui/grid/styles.tsx @@ -0,0 +1,15 @@ +import { tva } from '@gluestack-ui/utils/nativewind-utils'; +import { isWeb } from '@gluestack-ui/utils/nativewind-utils'; + +const gridBaseStyle = isWeb + ? 'grid grid-cols-12' + : 'box-border flex-row flex-wrap justify-start'; +const gridItemBaseStyle = isWeb ? 'w-auto col-span-1' : ''; + +export const gridStyle = tva({ + base: `w-full ${gridBaseStyle}`, +}); + +export const gridItemStyle = tva({ + base: `w-full ${gridItemBaseStyle}`, +}); diff --git a/code/base-project/base-app/src/components/ui/progress/index.tsx b/code/base-project/base-app/src/components/ui/progress/index.tsx new file mode 100644 index 00000000..baa63b07 --- /dev/null +++ b/code/base-project/base-app/src/components/ui/progress/index.tsx @@ -0,0 +1,169 @@ +'use client'; +import React from 'react'; +import { createProgress } from '@gluestack-ui/core/progress/creator'; +import { View } from 'react-native'; +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'; + +const SCOPE = 'PROGRESS'; +export const UIProgress = createProgress({ + Root: withStyleContext(View, SCOPE), + FilledTrack: View, +}); + +cssInterop(UIProgress, { className: 'style' }); +cssInterop(UIProgress.FilledTrack, { className: 'style' }); + +const progressStyle = tva({ + base: 'bg-background-300 rounded-full w-full', + variants: { + orientation: { + horizontal: 'w-full', + vertical: 'h-full', + }, + size: { + 'xs': 'h-1', + 'sm': 'h-2', + 'md': 'h-3', + 'lg': 'h-4', + 'xl': 'h-5', + '2xl': 'h-6', + }, + }, + compoundVariants: [ + { + orientation: 'vertical', + size: 'xs', + class: 'h-full w-1 justify-end', + }, + { + orientation: 'vertical', + size: 'sm', + class: 'h-full w-2 justify-end', + }, + { + orientation: 'vertical', + size: 'md', + class: 'h-full w-3 justify-end', + }, + { + orientation: 'vertical', + size: 'lg', + class: 'h-full w-4 justify-end', + }, + + { + orientation: 'vertical', + size: 'xl', + class: 'h-full w-5 justify-end', + }, + { + orientation: 'vertical', + size: '2xl', + class: 'h-full w-6 justify-end', + }, + ], +}); + +const progressFilledTrackStyle = tva({ + base: 'bg-primary-500 rounded-full', + parentVariants: { + orientation: { + horizontal: 'w-full', + vertical: 'h-full', + }, + size: { + 'xs': 'h-1', + 'sm': 'h-2', + 'md': 'h-3', + 'lg': 'h-4', + 'xl': 'h-5', + '2xl': 'h-6', + }, + }, + parentCompoundVariants: [ + { + orientation: 'vertical', + size: 'xs', + class: 'h-full w-1', + }, + { + orientation: 'vertical', + size: 'sm', + class: 'h-full w-2', + }, + { + orientation: 'vertical', + size: 'md', + class: 'h-full w-3', + }, + { + orientation: 'vertical', + size: 'lg', + class: 'h-full w-4', + }, + + { + orientation: 'vertical', + size: 'xl', + class: 'h-full w-5', + }, + { + orientation: 'vertical', + size: '2xl', + class: 'h-full w-6', + }, + ], +}); + +type IProgressProps = VariantProps & + React.ComponentProps; +type IProgressFilledTrackProps = VariantProps & + React.ComponentProps; + +const Progress = React.forwardRef< + React.ComponentRef, + IProgressProps +>(function Progress( + { className, size = 'md', orientation = 'horizontal', ...props }, + ref +) { + return ( + + ); +}); + +const ProgressFilledTrack = React.forwardRef< + React.ComponentRef, + IProgressFilledTrackProps +>(function ProgressFilledTrack({ className, ...props }, ref) { + const { size: parentSize, orientation: parentOrientation } = + useStyleContext(SCOPE); + + return ( + + ); +}); + +export { Progress, ProgressFilledTrack }; diff --git a/code/base-project/base-app/test2.html b/code/base-project/base-app/test2.html index 6851468c..15185663 100644 --- a/code/base-project/base-app/test2.html +++ b/code/base-project/base-app/test2.html @@ -1,118 +1,142 @@ - + - -记账卡片优化版 +分类共享交互演示 -
-
账户余额
-
¥ 8,520.00
+

记账分类共享交互演示

-
-
-
收入
-
¥ 12,000.00
-
+15% ↑
-
-
-
支出
-
¥ 3,480.00
-
-5% ↓
-
+
+
+

用户 A 视图

+
    +
    +
    +

    用户 B 视图

    +
      +

      操作区(模拟修改)

      +

      选择用户修改系统分类或隐藏/重命名:

      + + + + + + + + + +