u
This commit is contained in:
1 parent
6277fb2f9e
commit
8ecac0d7dd
270 files changed
+40438
-529
No files matched your search
@@ -0,0 +1,54 @@
|
||||
import { ScrollView, Text } from 'react-native'
|
||||
import Empty from '@/static/images/Empty.svg'
|
||||
import Error from '@/static/images/Error.svg'
|
||||
import NotFound from '@/static/images/NotFound.svg'
|
||||
|
||||
interface EmptyStateProps {
|
||||
type?: 'empty' | 'error' | '404'
|
||||
title?: string
|
||||
description?: string
|
||||
image?: React.ReactNode
|
||||
footer?: React.ReactNode
|
||||
}
|
||||
|
||||
const defaultImages = {
|
||||
empty: <Empty width={224} height={224} />,
|
||||
error: <Error width={224} height={224} />,
|
||||
404: <NotFound width={224} height={224} />,
|
||||
}
|
||||
|
||||
export default function EmptyState({
|
||||
type = 'empty',
|
||||
title,
|
||||
description,
|
||||
image,
|
||||
footer,
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<ScrollView
|
||||
contentContainerStyle={{
|
||||
flexGrow: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
backgroundColor: 'white',
|
||||
padding: 16,
|
||||
}}
|
||||
showsVerticalScrollIndicator={true}
|
||||
>
|
||||
{/* image 插槽优先 */}
|
||||
{image ?? defaultImages[type]}
|
||||
|
||||
{title && (
|
||||
<Text className="text-2xl font-semibold text-gray-900 mb-2">{title}</Text>
|
||||
)}
|
||||
|
||||
{description && (
|
||||
<Text className="text-base text-gray-600 text-center mb-6 leading-6">
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{footer}
|
||||
</ScrollView>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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} />
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import type { icons } from 'lucide-react-native'
|
||||
import type { ViewStyle } from 'react-native'
|
||||
import type { LucideIconProps } from './LucideIcon'
|
||||
import React from 'react'
|
||||
import {
|
||||
ButtonSpinner,
|
||||
ButtonText,
|
||||
Button as NativeButton,
|
||||
} from '@/components/ui/button'
|
||||
import LucideIcon from './LucideIcon'
|
||||
|
||||
type IconProp = (LucideIconProps & { icon: keyof typeof icons }) | React.ReactNode
|
||||
|
||||
interface CustomButtonProps {
|
||||
children?: React.ReactNode
|
||||
onPress?: () => void
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
className?: string
|
||||
style?: ViewStyle
|
||||
action?: 'primary' | 'secondary' | 'positive' | 'negative' | 'default'
|
||||
variant?: 'solid' | 'outline' | 'link'
|
||||
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
|
||||
leftIcon?: IconProp
|
||||
rightIcon?: IconProp
|
||||
}
|
||||
|
||||
const Button: React.FC<CustomButtonProps> = ({
|
||||
children,
|
||||
onPress,
|
||||
disabled = false,
|
||||
loading = false,
|
||||
className,
|
||||
style,
|
||||
action = 'primary',
|
||||
variant = 'solid',
|
||||
size = 'md',
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
}) => {
|
||||
const isDisabled = disabled || loading
|
||||
const hasChildren = !!children
|
||||
const renderLeftIcon = () => {
|
||||
if (loading)
|
||||
return <ButtonSpinner color="#fff" className={hasChildren ? 'mr-2' : ''} />
|
||||
|
||||
if (!leftIcon)
|
||||
return null
|
||||
|
||||
if (React.isValidElement(leftIcon)) {
|
||||
return <span className={hasChildren ? 'mr-2' : ''}>{leftIcon}</span>
|
||||
}
|
||||
|
||||
const { icon, ...props } = leftIcon as LucideIconProps & { icon: keyof typeof icons }
|
||||
return <LucideIcon icon={icon} className={hasChildren ? 'mr-2' : ''} {...props} />
|
||||
}
|
||||
|
||||
const renderRightIcon = () => {
|
||||
if (!rightIcon)
|
||||
return null
|
||||
|
||||
if (React.isValidElement(rightIcon)) {
|
||||
return <span className={hasChildren ? 'ml-2' : ''}>{rightIcon}</span>
|
||||
}
|
||||
|
||||
const { icon, ...props } = rightIcon as LucideIconProps & { icon: keyof typeof icons }
|
||||
return <LucideIcon icon={icon} className={hasChildren ? 'ml-2' : ''} {...props} />
|
||||
}
|
||||
|
||||
return (
|
||||
<NativeButton
|
||||
onPress={onPress}
|
||||
isDisabled={isDisabled}
|
||||
className={className}
|
||||
style={[style, { opacity: isDisabled ? 0.8 : 1 }]}
|
||||
action={action}
|
||||
variant={variant}
|
||||
size={size}
|
||||
>
|
||||
{renderLeftIcon()}
|
||||
{
|
||||
children && (
|
||||
<ButtonText>{children}</ButtonText>
|
||||
)
|
||||
}
|
||||
{renderRightIcon()}
|
||||
</NativeButton>
|
||||
)
|
||||
}
|
||||
|
||||
export default Button
|
||||
@@ -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,50 @@
|
||||
// components/ui/CustomCheckbox.tsx
|
||||
import type { ViewStyle } from 'react-native'
|
||||
import React from 'react'
|
||||
import {
|
||||
CheckboxIcon,
|
||||
CheckboxIndicator,
|
||||
CheckboxLabel,
|
||||
Checkbox as NativeCheckbox,
|
||||
} from '@/components/ui/checkbox'
|
||||
import { CheckIcon } from '@/components/ui/icon'
|
||||
|
||||
interface CustomCheckboxProps {
|
||||
value?: string
|
||||
isChecked: boolean
|
||||
onChange: (checked: boolean) => void
|
||||
label: React.ReactNode
|
||||
className?: string
|
||||
style?: ViewStyle
|
||||
disabled?: boolean
|
||||
icon?: React.ElementType
|
||||
}
|
||||
|
||||
const Checkbox: React.FC<CustomCheckboxProps> = ({
|
||||
value,
|
||||
isChecked,
|
||||
onChange,
|
||||
label,
|
||||
className,
|
||||
style,
|
||||
disabled,
|
||||
icon,
|
||||
}) => {
|
||||
return (
|
||||
<NativeCheckbox
|
||||
value={value ?? ''} // 避免 undefined
|
||||
isChecked={isChecked}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
style={style}
|
||||
isDisabled={disabled}
|
||||
>
|
||||
<CheckboxIndicator>
|
||||
<CheckboxIcon as={icon ?? CheckIcon} />
|
||||
</CheckboxIndicator>
|
||||
<CheckboxLabel>{label}</CheckboxLabel>
|
||||
</NativeCheckbox>
|
||||
)
|
||||
}
|
||||
|
||||
export default Checkbox
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { IDatePickerProps } from '@yz1311/react-native-wheel-picker'
|
||||
import { DatePicker } from '@yz1311/react-native-wheel-picker'
|
||||
import dayjs from 'dayjs'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Animated, Modal, StyleSheet, TouchableOpacity } from 'react-native'
|
||||
import { toDate } from '@/utils/time'
|
||||
// 定义 mode 类型
|
||||
export type DatePickerMode = 'year' | 'month' | 'date' | 'time' | 'datetime'
|
||||
|
||||
// 定义 format 类型
|
||||
export type DateFormatType = 'YYYY' | 'YYYY-MM' | 'YYYY-MM-DD' | 'HH:mm:ss' | 'YYYY-MM-DD HH:mm:ss'
|
||||
|
||||
interface ADatePickerProps extends Omit<IDatePickerProps, 'date'> {
|
||||
date?: Date | string
|
||||
show?: boolean
|
||||
onClose?: () => void
|
||||
onConfirm?: (value: string) => void
|
||||
mode?: DatePickerMode
|
||||
format?: DateFormatType
|
||||
}
|
||||
|
||||
const defaultFormatMap: Record<DatePickerMode, DateFormatType> = {
|
||||
year: 'YYYY',
|
||||
month: 'YYYY-MM',
|
||||
date: 'YYYY-MM-DD',
|
||||
time: 'HH:mm:ss',
|
||||
datetime: 'YYYY-MM-DD HH:mm:ss',
|
||||
}
|
||||
|
||||
export default function ADatePicker({
|
||||
date,
|
||||
show = false,
|
||||
onClose,
|
||||
onConfirm,
|
||||
mode = 'date',
|
||||
format,
|
||||
...restProps
|
||||
}: ADatePickerProps) {
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current
|
||||
const slideAnim = useRef(new Animated.Value(300)).current
|
||||
const [visible, setVisible] = useState(show)
|
||||
|
||||
// 使用传入 format 或默认 format
|
||||
const finalFormat = format || defaultFormatMap[mode]
|
||||
|
||||
useEffect(() => {
|
||||
if (show) {
|
||||
setVisible(true)
|
||||
fadeAnim.setValue(0)
|
||||
slideAnim.setValue(300)
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, { toValue: 1, duration: 250, useNativeDriver: true }),
|
||||
Animated.timing(slideAnim, { toValue: 0, duration: 250, useNativeDriver: true }),
|
||||
]).start()
|
||||
}
|
||||
else {
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, { toValue: 0, duration: 250, useNativeDriver: true }),
|
||||
Animated.timing(slideAnim, { toValue: 280, duration: 250, useNativeDriver: true }),
|
||||
]).start(() => setVisible(false))
|
||||
}
|
||||
}, [show])
|
||||
|
||||
const handleCancel = () => onClose?.()
|
||||
|
||||
const handleConfirm = (date: Date) => {
|
||||
const value = dayjs(date).format(finalFormat)
|
||||
onConfirm?.(value)
|
||||
}
|
||||
|
||||
if (!visible)
|
||||
return null
|
||||
|
||||
return (
|
||||
<Modal transparent visible={visible} animationType="none">
|
||||
<Animated.View style={[styles.overlay, { opacity: fadeAnim }]}>
|
||||
<TouchableOpacity style={{ flex: 1 }} activeOpacity={1} onPress={handleCancel} />
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
style={[styles.pickerContainer, { transform: [{ translateY: slideAnim }] }]}
|
||||
>
|
||||
<DatePicker
|
||||
{...restProps}
|
||||
date={toDate(date)}
|
||||
mode={mode}
|
||||
isModal={false}
|
||||
onPickerCancel={handleCancel}
|
||||
onPickerConfirm={handleConfirm}
|
||||
/>
|
||||
</Animated.View>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||
},
|
||||
pickerContainer: {
|
||||
backgroundColor: '#fff',
|
||||
overflow: 'hidden',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
width: '100%',
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { ViewStyle } from 'react-native'
|
||||
import React from 'react'
|
||||
import { Text, View } from 'react-native'
|
||||
|
||||
interface DividerProps {
|
||||
direction?: 'horizontal' | 'vertical'
|
||||
text?: string | React.ReactNode
|
||||
textPosition?: 'left' | 'center' | 'right'
|
||||
style?: ViewStyle
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function Divider({
|
||||
direction = 'horizontal',
|
||||
text,
|
||||
textPosition = 'center',
|
||||
style,
|
||||
className,
|
||||
}: DividerProps) {
|
||||
const isString = typeof text === 'string'
|
||||
|
||||
// 垂直分割线
|
||||
if (direction === 'vertical') {
|
||||
return (
|
||||
<View
|
||||
style={style}
|
||||
className={`w-px bg-gray-300 mx-2 h-4 ${className ?? ''}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 水平,无文字
|
||||
if (!text) {
|
||||
return (
|
||||
<View
|
||||
style={style}
|
||||
className={`h-px bg-gray-300 my-2 w-full ${className ?? ''}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 水平 + 文本
|
||||
return (
|
||||
<View
|
||||
style={style}
|
||||
className={`flex-row items-center my-2 ${className ?? ''}`}
|
||||
>
|
||||
{/* 左线 */}
|
||||
<View
|
||||
className={`h-px bg-gray-300 ${
|
||||
textPosition === 'left' ? 'w-2' : 'flex-1'
|
||||
}`}
|
||||
/>
|
||||
|
||||
{/* 文本(字符串 → 自动包裹 Text;node → 直接渲染) */}
|
||||
<View className="mx-3">
|
||||
{isString
|
||||
? (
|
||||
<Text className="text-gray-500 text-sm">{text}</Text>
|
||||
)
|
||||
: (
|
||||
text
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* 右线 */}
|
||||
<View
|
||||
className={`h-px bg-gray-300 ${
|
||||
textPosition === 'right' ? 'w-2' : 'flex-1'
|
||||
}`}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { ScrollView } from 'react-native'
|
||||
import React, { createContext, forwardRef, useCallback, useImperativeHandle, useRef } from 'react'
|
||||
|
||||
export interface FormRef {
|
||||
validate: () => boolean
|
||||
scrollToField: (name: string) => void
|
||||
}
|
||||
|
||||
interface FormProps {
|
||||
children: React.ReactNode
|
||||
form: Record<string, any>
|
||||
scrollRef?: React.RefObject<ScrollView | null>
|
||||
}
|
||||
|
||||
interface FormContextType {
|
||||
form: Record<string, any>
|
||||
registerValidator: (name: string, validator: () => boolean) => void
|
||||
unregisterValidator: (name: string) => void
|
||||
registerFieldPosition: (name: string, y: number) => void
|
||||
}
|
||||
|
||||
export const FormContext = createContext<FormContextType | undefined>(undefined)
|
||||
|
||||
const Form = forwardRef<FormRef, FormProps>(({ children, form, scrollRef }, ref) => {
|
||||
const validatorsRef = useRef<Array<{ name: string, validator: () => boolean }>>([])
|
||||
const fieldPositions = useRef<Map<string, number>>(new Map())
|
||||
|
||||
const registerValidator = useCallback((name: string, validator: () => boolean) => {
|
||||
validatorsRef.current.push({ name, validator })
|
||||
}, [])
|
||||
|
||||
const unregisterValidator = useCallback((name: string) => {
|
||||
validatorsRef.current = validatorsRef.current.filter(v => v.name !== name)
|
||||
}, [])
|
||||
|
||||
const registerFieldPosition = useCallback((name: string, y: number) => {
|
||||
fieldPositions.current.set(name, y)
|
||||
}, [])
|
||||
|
||||
const validate = (): boolean => {
|
||||
let firstErrorField: string | null = null
|
||||
let isValid = true
|
||||
|
||||
for (const { name, validator } of validatorsRef.current) {
|
||||
if (!validator() && !firstErrorField) {
|
||||
firstErrorField = name
|
||||
isValid = false
|
||||
}
|
||||
}
|
||||
|
||||
if (!isValid && firstErrorField) {
|
||||
scrollToField(firstErrorField)
|
||||
}
|
||||
|
||||
return isValid
|
||||
}
|
||||
|
||||
const scrollToField = (name: string) => {
|
||||
const y = fieldPositions.current.get(name)
|
||||
if (y !== undefined && scrollRef?.current) {
|
||||
scrollRef.current.scrollTo({ y, animated: true })
|
||||
}
|
||||
}
|
||||
|
||||
useImperativeHandle(ref, () => ({ validate, scrollToField }))
|
||||
|
||||
return (
|
||||
<FormContext.Provider
|
||||
value={{
|
||||
form,
|
||||
registerValidator,
|
||||
unregisterValidator,
|
||||
registerFieldPosition,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</FormContext.Provider>
|
||||
)
|
||||
})
|
||||
|
||||
export default Form
|
||||
@@ -0,0 +1,164 @@
|
||||
import React, { useContext, useEffect, useState } from 'react'
|
||||
import {
|
||||
FormControl,
|
||||
FormControlError,
|
||||
FormControlErrorIcon,
|
||||
FormControlErrorText,
|
||||
FormControlLabel,
|
||||
FormControlLabelText,
|
||||
} from '@/components/ui/form-control'
|
||||
import { AlertCircleIcon } from '@/components/ui/icon'
|
||||
import { FormContext } from './Form'
|
||||
|
||||
interface RuleType {
|
||||
validator?: (val: any) => boolean
|
||||
pattern?: RegExp
|
||||
message?: string
|
||||
len?: number
|
||||
min?: number
|
||||
max?: number
|
||||
required?: boolean
|
||||
whitespace?: boolean
|
||||
}
|
||||
|
||||
interface FormItemProps<T> {
|
||||
name?: keyof T
|
||||
label?: string
|
||||
rules?: RuleType[]
|
||||
children: React.ReactNode
|
||||
isRequired?: boolean
|
||||
}
|
||||
|
||||
function FormItem<T extends Record<string, any>>({
|
||||
name,
|
||||
label,
|
||||
rules = [],
|
||||
children,
|
||||
isRequired = false,
|
||||
}: FormItemProps<T>) {
|
||||
const context = useContext(FormContext)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!context)
|
||||
return
|
||||
|
||||
const currentName = String(name)
|
||||
|
||||
// 如果 isRequired 为 true,且 rules 中没有 required: true,则自动加
|
||||
const hasRequiredInRules = rules.some(rule => rule.required)
|
||||
const allRules = [...rules]
|
||||
if (isRequired && !hasRequiredInRules) {
|
||||
allRules.unshift({
|
||||
required: true,
|
||||
message: label ? `${label} 是必填项` : `${currentName} 是必填项`,
|
||||
})
|
||||
}
|
||||
|
||||
const validateFn = () => {
|
||||
const value = context.form[currentName]
|
||||
|
||||
for (const rule of allRules) {
|
||||
// 1. required 校验
|
||||
if (rule.required) {
|
||||
const empty
|
||||
= value === undefined
|
||||
|| value === null
|
||||
|| value === ''
|
||||
|| (Array.isArray(value) && value.length === 0)
|
||||
|| (rule.whitespace && typeof value === 'string' && value.trim() === '')
|
||||
if (empty) {
|
||||
setError(rule.message || `${label || currentName} 校验不通过`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 自定义 validator 返回 true/false
|
||||
if (rule.validator !== undefined) {
|
||||
const ok = rule.validator(value)
|
||||
if (!ok) {
|
||||
setError(rule.message || `${label || currentName} 校验不通过`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 3. pattern 正则
|
||||
if (rule.pattern && typeof value === 'string') {
|
||||
if (!rule.pattern.test(value)) {
|
||||
setError(rule.message || `${label || currentName} 格式不正确`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 4. len
|
||||
if (rule.len !== undefined) {
|
||||
if (
|
||||
(typeof value === 'string' && value.length !== rule.len)
|
||||
|| (typeof value === 'number' && value !== rule.len)
|
||||
|| (Array.isArray(value) && value.length !== rule.len)
|
||||
) {
|
||||
setError(rule.message || `${label || currentName} 长度/值必须为 ${rule.len}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 5. min
|
||||
if (rule.min !== undefined) {
|
||||
if (
|
||||
(typeof value === 'string' && value.length < rule.min)
|
||||
|| (typeof value === 'number' && value < rule.min)
|
||||
|| (Array.isArray(value) && value.length < rule.min)
|
||||
) {
|
||||
setError(rule.message || `${label || currentName} 最小值/长度为 ${rule.min}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 6. max
|
||||
if (rule.max !== undefined) {
|
||||
if (
|
||||
(typeof value === 'string' && value.length > rule.max)
|
||||
|| (typeof value === 'number' && value > rule.max)
|
||||
|| (Array.isArray(value) && value.length > rule.max)
|
||||
) {
|
||||
setError(rule.message || `${label || currentName} 最大值/长度为 ${rule.max}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setError('')
|
||||
return true
|
||||
}
|
||||
|
||||
context.registerValidator(currentName, validateFn)
|
||||
|
||||
return () => {
|
||||
context.unregisterValidator(currentName)
|
||||
}
|
||||
}, [context, name, label, rules, isRequired])
|
||||
|
||||
const handleLayout = (event: any) => {
|
||||
const { y } = event.nativeEvent.layout
|
||||
context?.registerFieldPosition(String(name), y)
|
||||
}
|
||||
|
||||
return (
|
||||
<FormControl isInvalid={!!error} isRequired={isRequired} className="w-full mb-4" onLayout={handleLayout}>
|
||||
{label && (
|
||||
<FormControlLabel>
|
||||
<FormControlLabelText>{label}</FormControlLabelText>
|
||||
</FormControlLabel>
|
||||
)}
|
||||
{children}
|
||||
{error && (
|
||||
<FormControlError>
|
||||
<FormControlErrorIcon as={AlertCircleIcon} className="text-red-500" />
|
||||
<FormControlErrorText className="text-red-500">{error}</FormControlErrorText>
|
||||
</FormControlError>
|
||||
)}
|
||||
</FormControl>
|
||||
)
|
||||
}
|
||||
|
||||
export default FormItem
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ComponentType } from 'react'
|
||||
import type { StyleProp, TextInputProps, ViewStyle } from 'react-native'
|
||||
import React, { forwardRef, useState } from 'react'
|
||||
import { Pressable } from 'react-native'
|
||||
import { EyeIcon, EyeOffIcon } from '@/components/ui/icon'
|
||||
import { InputField, InputIcon, InputSlot, Input as NativeInput } from '@/components/ui/input'
|
||||
|
||||
export interface AInputProps extends Omit<TextInputProps, 'onChange'> {
|
||||
isPassword?: boolean
|
||||
isDisabled?: boolean
|
||||
isReadOnly?: boolean
|
||||
isInvalid?: boolean
|
||||
isHovered?: boolean
|
||||
isFocused?: boolean
|
||||
variant?: 'outline' | 'rounded' | 'underlined'
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
leftIcon?: ComponentType
|
||||
className?: string
|
||||
style?: StyleProp<ViewStyle>
|
||||
}
|
||||
|
||||
const AInput = forwardRef<React.ComponentRef<typeof InputField>, AInputProps>(
|
||||
({
|
||||
isPassword = false,
|
||||
isDisabled = false,
|
||||
isReadOnly = false,
|
||||
isInvalid = false,
|
||||
isHovered = false,
|
||||
isFocused = false,
|
||||
variant = 'outline',
|
||||
size = 'md',
|
||||
leftIcon,
|
||||
className,
|
||||
style,
|
||||
...rest
|
||||
}, ref) => {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
|
||||
return (
|
||||
<NativeInput
|
||||
variant={variant}
|
||||
size={size}
|
||||
isDisabled={isDisabled}
|
||||
isReadOnly={isReadOnly}
|
||||
isInvalid={isInvalid}
|
||||
isHovered={isHovered}
|
||||
isFocused={isFocused}
|
||||
className={className}
|
||||
style={style}
|
||||
>
|
||||
{leftIcon && (
|
||||
<InputSlot className="pl-3">
|
||||
<InputIcon as={leftIcon} />
|
||||
</InputSlot>
|
||||
)}
|
||||
|
||||
<InputField
|
||||
ref={ref}
|
||||
type={isPassword ? (showPassword ? 'text' : 'password') : 'text'}
|
||||
{...rest}
|
||||
/>
|
||||
|
||||
{isPassword && (
|
||||
<InputSlot className="pr-3">
|
||||
<Pressable onPress={() => setShowPassword(prev => !prev)}>
|
||||
<InputIcon as={showPassword ? EyeIcon : EyeOffIcon} />
|
||||
</Pressable>
|
||||
</InputSlot>
|
||||
)}
|
||||
</NativeInput>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
export default AInput
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { icons } from 'lucide-react-native'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { Text, TouchableOpacity } from 'react-native'
|
||||
import LucideIcon from './LucideIcon'
|
||||
|
||||
export interface ListItemProps {
|
||||
icon: keyof typeof icons
|
||||
text: string
|
||||
href?: string
|
||||
onPress?: () => void
|
||||
showRightIcon?: boolean
|
||||
}
|
||||
|
||||
export default function ListItem({ icon, text, href, onPress, showRightIcon = true }: ListItemProps) {
|
||||
const router = useRouter()
|
||||
|
||||
const handlePress = () => {
|
||||
if (onPress) {
|
||||
onPress()
|
||||
return
|
||||
}
|
||||
|
||||
if (href) {
|
||||
router.navigate(href)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
className="flex flex-row items-center py-2"
|
||||
onPress={handlePress}
|
||||
>
|
||||
<LucideIcon icon={icon} size={18} strokeWidth={1.5} />
|
||||
|
||||
<Text className="text-base ml-3">{text}</Text>
|
||||
|
||||
{
|
||||
showRightIcon && (
|
||||
<LucideIcon
|
||||
icon="ChevronRight"
|
||||
className="ml-auto"
|
||||
color="#ccc"
|
||||
size={22}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</TouchableOpacity>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { GestureResponderEvent, ViewStyle } from 'react-native'
|
||||
import { icons } from 'lucide-react-native'
|
||||
import React from 'react'
|
||||
import { Pressable } from 'react-native'
|
||||
|
||||
export interface LucideIconProps {
|
||||
icon: keyof typeof icons
|
||||
size?: number
|
||||
color?: string
|
||||
strokeWidth?: number
|
||||
onPress?: (event: GestureResponderEvent) => void
|
||||
style?: ViewStyle
|
||||
clickable?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function LucideIcon({
|
||||
icon,
|
||||
size = 16,
|
||||
color = '#000',
|
||||
strokeWidth = 2,
|
||||
onPress,
|
||||
style,
|
||||
clickable = true,
|
||||
className,
|
||||
}: LucideIconProps) {
|
||||
const IconComponent = icons[icon]
|
||||
|
||||
if (!IconComponent)
|
||||
return null
|
||||
|
||||
if (!clickable) {
|
||||
return <IconComponent size={size} color={color} strokeWidth={strokeWidth} style={style} className={className} />
|
||||
}
|
||||
|
||||
// clickable 为 true,包 Pressable
|
||||
return (
|
||||
<Pressable onPress={onPress} style={style} className={className}>
|
||||
<IconComponent size={size} color={color} strokeWidth={strokeWidth} />
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import type { icons } from 'lucide-react-native'
|
||||
import type { StyleProp, TextStyle, ViewStyle } from 'react-native'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Platform, Pressable, Text, View } from 'react-native'
|
||||
import PagerView from 'react-native-pager-view'
|
||||
import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'
|
||||
import LucideIcon from './LucideIcon'
|
||||
|
||||
export interface SegmentedTabItem {
|
||||
key: string
|
||||
label: string
|
||||
icon?: keyof typeof icons
|
||||
content?: React.ReactNode
|
||||
}
|
||||
|
||||
interface SegmentedTabsProps {
|
||||
tabs: SegmentedTabItem[]
|
||||
value?: number
|
||||
defaultIndex?: number
|
||||
onChange?: (index: number) => void
|
||||
|
||||
className?: string
|
||||
style?: StyleProp<ViewStyle>
|
||||
|
||||
tabHeight?: number
|
||||
borderRadius?: number
|
||||
spacing?: number
|
||||
|
||||
sliderColor?: string
|
||||
sliderStyle?: StyleProp<ViewStyle>
|
||||
|
||||
selectedTextStyle?: StyleProp<TextStyle>
|
||||
unselectedTextStyle?: StyleProp<TextStyle>
|
||||
selectedColor?: string
|
||||
unselectedColor?: string
|
||||
fontSize?: number
|
||||
iconSize?: number
|
||||
|
||||
width?: number
|
||||
}
|
||||
|
||||
export default function SegmentedTabs({
|
||||
tabs,
|
||||
value,
|
||||
defaultIndex = 0,
|
||||
onChange,
|
||||
className,
|
||||
style,
|
||||
tabHeight = 36,
|
||||
borderRadius = 2,
|
||||
spacing = 4,
|
||||
sliderColor = '#fff',
|
||||
sliderStyle,
|
||||
selectedTextStyle,
|
||||
unselectedTextStyle,
|
||||
selectedColor = '#000',
|
||||
unselectedColor = '#666',
|
||||
fontSize = 14,
|
||||
iconSize = 14,
|
||||
width,
|
||||
}: SegmentedTabsProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(value ?? defaultIndex)
|
||||
const [headerMeasuredWidth, setHeaderMeasuredWidth] = useState(0)
|
||||
const [headerMeasuredHeight, setHeaderMeasuredHeight] = useState(tabHeight)
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
const pagerRef = useRef<PagerView>(null)
|
||||
const sliderX = useSharedValue(0)
|
||||
const initialized = useRef(false)
|
||||
|
||||
const actualHeaderWidth = width ?? headerMeasuredWidth
|
||||
const tabWidth = actualHeaderWidth ? actualHeaderWidth / tabs.length : 0
|
||||
const sliderHeight = Math.max(headerMeasuredHeight - spacing * 2, 0)
|
||||
const sliderWidth = tabWidth - spacing * 2
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== undefined)
|
||||
setSelectedIndex(value)
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || tabWidth === 0)
|
||||
return
|
||||
const target = selectedIndex * tabWidth + spacing
|
||||
|
||||
if (!initialized.current) {
|
||||
sliderX.value = target
|
||||
initialized.current = true
|
||||
}
|
||||
else {
|
||||
sliderX.value = withSpring(target, { damping: 18, stiffness: 150, mass: 1 })
|
||||
}
|
||||
}, [selectedIndex, tabWidth, ready])
|
||||
|
||||
const animatedSliderStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateX: sliderX.value }],
|
||||
}))
|
||||
|
||||
const handlePress = (index: number) => {
|
||||
if (value === undefined)
|
||||
setSelectedIndex(index)
|
||||
onChange?.(index)
|
||||
pagerRef.current?.setPage(index)
|
||||
}
|
||||
|
||||
const handlePageSelected = (e: any) => {
|
||||
const index = e.nativeEvent.position
|
||||
setSelectedIndex(index)
|
||||
onChange?.(index)
|
||||
}
|
||||
|
||||
const handleHeaderLayout = (event: any) => {
|
||||
const { width, height } = event.nativeEvent.layout
|
||||
setHeaderMeasuredWidth(width)
|
||||
setHeaderMeasuredHeight(height || tabHeight)
|
||||
if (width > 0 && !ready)
|
||||
setReady(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1 }}>
|
||||
{/* Tab Header */}
|
||||
<View
|
||||
className={className}
|
||||
style={[
|
||||
{
|
||||
width,
|
||||
flexDirection: 'row',
|
||||
backgroundColor: '#f2f2f2',
|
||||
borderRadius,
|
||||
height: tabHeight,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
style,
|
||||
]}
|
||||
onLayout={handleHeaderLayout}
|
||||
>
|
||||
{ready && (
|
||||
<Animated.View
|
||||
style={[
|
||||
{
|
||||
position: 'absolute',
|
||||
top: spacing,
|
||||
left: 0,
|
||||
width: sliderWidth,
|
||||
height: sliderHeight,
|
||||
borderRadius,
|
||||
backgroundColor: sliderColor,
|
||||
...Platform.select({ android: { elevation: 6 } }),
|
||||
},
|
||||
sliderStyle,
|
||||
animatedSliderStyle,
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tabs.map((tab, i) => {
|
||||
const isActive = i === selectedIndex
|
||||
return (
|
||||
<Pressable
|
||||
key={tab.key}
|
||||
onPress={() => handlePress(i)}
|
||||
style={{
|
||||
width: tabWidth,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: spacing,
|
||||
}}
|
||||
>
|
||||
{tab.icon && (
|
||||
<LucideIcon
|
||||
icon={tab.icon}
|
||||
size={iconSize}
|
||||
color={isActive ? selectedColor : unselectedColor}
|
||||
/>
|
||||
)}
|
||||
<Text
|
||||
style={[
|
||||
{ color: isActive ? selectedColor : unselectedColor, fontSize, fontWeight: 'bold' },
|
||||
isActive ? selectedTextStyle : unselectedTextStyle,
|
||||
]}
|
||||
>
|
||||
{tab.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* PagerView 内容 */}
|
||||
<PagerView
|
||||
ref={pagerRef}
|
||||
style={{ flex: 1, marginTop: 16 }}
|
||||
initialPage={defaultIndex}
|
||||
onPageSelected={handlePageSelected}
|
||||
>
|
||||
{tabs.map(tab => (
|
||||
<View key={tab.key} style={{ flex: 1 }}>
|
||||
{tab.content}
|
||||
</View>
|
||||
))}
|
||||
</PagerView>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import React, { useMemo, useRef, useState } from 'react'
|
||||
import { Pressable, Text, View } from 'react-native'
|
||||
import PagerView from 'react-native-pager-view'
|
||||
|
||||
interface TabItem {
|
||||
label: string
|
||||
key: string
|
||||
content?: React.ReactNode
|
||||
}
|
||||
|
||||
interface TabProps {
|
||||
data?: TabItem[]
|
||||
defaultIndex?: number
|
||||
onChange?: (index: number) => void
|
||||
tabBarClass?: string
|
||||
tabItemClass?: string
|
||||
activeTabItemClass?: string
|
||||
}
|
||||
|
||||
export default function Tab({
|
||||
data,
|
||||
defaultIndex = 0,
|
||||
onChange,
|
||||
tabBarClass = 'flex flex-row gap-2 overflow-hidden',
|
||||
tabItemClass = 'items-center justify-center rounded-md px-4 py-2 bg-gray-200',
|
||||
activeTabItemClass = 'items-center justify-center rounded-md px-4 py-2 bg-gray-800',
|
||||
}: TabProps) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(defaultIndex)
|
||||
const pagerRef = useRef<PagerView>(null)
|
||||
|
||||
if (!data || data.length === 0)
|
||||
return null
|
||||
|
||||
// 判断是否至少有一个 tab 含 content
|
||||
const hasContent = useMemo(
|
||||
() => data.some(item => item.content),
|
||||
[data],
|
||||
)
|
||||
|
||||
const handlePress = (index: number) => {
|
||||
setSelectedIndex(index)
|
||||
onChange?.(index)
|
||||
if (hasContent)
|
||||
pagerRef.current?.setPage(index)
|
||||
}
|
||||
|
||||
const onPageSelected = (e: any) => {
|
||||
const index = e.nativeEvent.position
|
||||
setSelectedIndex(index)
|
||||
onChange?.(index)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className="flex-1">
|
||||
{/* tab bar */}
|
||||
<View className={tabBarClass}>
|
||||
{data.map((item, index) => {
|
||||
const isActive = index === selectedIndex
|
||||
return (
|
||||
<Pressable
|
||||
key={item.key}
|
||||
onPress={() => handlePress(index)}
|
||||
className={isActive ? activeTabItemClass : tabItemClass}
|
||||
>
|
||||
<Text className={isActive ? 'text-white' : 'text-gray-700'}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
|
||||
{hasContent && (
|
||||
<PagerView
|
||||
ref={pagerRef}
|
||||
style={{ flex: 1, marginTop: 16 }}
|
||||
initialPage={defaultIndex}
|
||||
onPageSelected={onPageSelected}
|
||||
>
|
||||
{data.map(item => (
|
||||
<View key={item.key} style={{ flex: 1 }}>
|
||||
{item.content}
|
||||
</View>
|
||||
))}
|
||||
</PagerView>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import Button from './Button'
|
||||
import { Cell, CellGroup } from './Cell'
|
||||
import Checkbox from './Checkbox'
|
||||
import DatePicker from './DatePicker'
|
||||
import Divider from './Divider'
|
||||
import Form, { FormRef } from './Form'
|
||||
import FormItem from './FormItem'
|
||||
import Input from './Input'
|
||||
import ListItem from './ListItem'
|
||||
import LucideIcon from './LucideIcon'
|
||||
import Modal from './Modal'
|
||||
import SegmentedTabs from './SegmentedTabs'
|
||||
import Tab from './Tab'
|
||||
|
||||
export {
|
||||
Button as AButton,
|
||||
Cell as ACell,
|
||||
CellGroup as ACellGroup,
|
||||
Checkbox as ACheckbox,
|
||||
DatePicker as ADatePicker,
|
||||
Divider as ADivider,
|
||||
Form as AForm,
|
||||
FormItem as AFormItem,
|
||||
FormRef as AFormRef,
|
||||
Input as AInput,
|
||||
ListItem as AListItem,
|
||||
Modal as AModal,
|
||||
SegmentedTabs as ASegmentedTabs,
|
||||
Tab as ATab,
|
||||
LucideIcon,
|
||||
}
|
||||
Reference in new issue
Block a user