feat: add image picker utility and integrate image upload in DynamicForm component
This commit is contained in:
parent
3fd445bb6e
commit
01865d94c2
3
bun.lock
3
bun.lock
|
|
@ -35,6 +35,7 @@
|
||||||
"expo-linear-gradient": "^15.0.8",
|
"expo-linear-gradient": "^15.0.8",
|
||||||
"expo-linking": "^8.0.10",
|
"expo-linking": "^8.0.10",
|
||||||
"expo-media-library": "~18.2.0",
|
"expo-media-library": "~18.2.0",
|
||||||
|
"expo-native-alipay": "^0.1.1",
|
||||||
"expo-network": "^8.0.8",
|
"expo-network": "^8.0.8",
|
||||||
"expo-router": "~6.0.15",
|
"expo-router": "~6.0.15",
|
||||||
"expo-secure-store": "^15.0.8",
|
"expo-secure-store": "^15.0.8",
|
||||||
|
|
@ -1250,6 +1251,8 @@
|
||||||
|
|
||||||
"expo-modules-core": ["expo-modules-core@3.0.29", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-LzipcjGqk8gvkrOUf7O2mejNWugPkf3lmd9GkqL9WuNyeN2fRwU0Dn77e3ZUKI3k6sI+DNwjkq4Nu9fNN9WS7Q=="],
|
"expo-modules-core": ["expo-modules-core@3.0.29", "", { "dependencies": { "invariant": "^2.2.4" }, "peerDependencies": { "react": "*", "react-native": "*" } }, "sha512-LzipcjGqk8gvkrOUf7O2mejNWugPkf3lmd9GkqL9WuNyeN2fRwU0Dn77e3ZUKI3k6sI+DNwjkq4Nu9fNN9WS7Q=="],
|
||||||
|
|
||||||
|
"expo-native-alipay": ["expo-native-alipay@0.1.1", "", { "peerDependencies": { "expo": "*", "react": "*", "react-native": "*" } }, "sha512-kaVv9fT6nobcD/FQDeOxC86pPT8vemeOA46Tw+q4HYOiyil8jZrOUKbtvIgvPo9rI+ecGudC0a1JUHkkt19H2Q=="],
|
||||||
|
|
||||||
"expo-network": ["expo-network@8.0.8", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-dgrL8UHAmWofqeY4UEjWskCl/RoQAM0DG6PZR8xz2WZt+6aQEboQgFRXowCfhbKZ71d16sNuKXtwBEsp2DtdNw=="],
|
"expo-network": ["expo-network@8.0.8", "", { "peerDependencies": { "expo": "*", "react": "*" } }, "sha512-dgrL8UHAmWofqeY4UEjWskCl/RoQAM0DG6PZR8xz2WZt+6aQEboQgFRXowCfhbKZ71d16sNuKXtwBEsp2DtdNw=="],
|
||||||
|
|
||||||
"expo-router": ["expo-router@6.0.21", "", { "dependencies": { "@expo/metro-runtime": "^6.1.2", "@expo/schema-utils": "^0.1.8", "@radix-ui/react-slot": "1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/native": "^7.1.8", "@react-navigation/native-stack": "^7.3.16", "client-only": "^0.0.1", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-server": "^1.0.5", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.1.6", "semver": "~7.6.3", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "use-latest-callback": "^0.2.1", "vaul": "^1.1.2" }, "peerDependencies": { "@react-navigation/drawer": "^7.5.0", "@testing-library/react-native": ">= 12.0.0", "expo": "*", "expo-constants": "^18.0.12", "expo-linking": "^8.0.11", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-screens": "*", "react-native-web": "*", "react-server-dom-webpack": "~19.0.3 || ~19.1.4 || ~19.2.3" }, "optionalPeers": ["@react-navigation/drawer", "@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-wjTUjrnWj6gRYjaYl1kYfcRnNE4ZAQ0kz0+sQf6/mzBd/OU6pnOdD7WrdAW3pTTpm52Q8sMoeX98tNQEddg2uA=="],
|
"expo-router": ["expo-router@6.0.21", "", { "dependencies": { "@expo/metro-runtime": "^6.1.2", "@expo/schema-utils": "^0.1.8", "@radix-ui/react-slot": "1.2.0", "@radix-ui/react-tabs": "^1.1.12", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/native": "^7.1.8", "@react-navigation/native-stack": "^7.3.16", "client-only": "^0.0.1", "debug": "^4.3.4", "escape-string-regexp": "^4.0.0", "expo-server": "^1.0.5", "fast-deep-equal": "^3.1.3", "invariant": "^2.2.4", "nanoid": "^3.3.8", "query-string": "^7.1.3", "react-fast-compare": "^3.2.2", "react-native-is-edge-to-edge": "^1.1.6", "semver": "~7.6.3", "server-only": "^0.0.1", "sf-symbols-typescript": "^2.1.0", "shallowequal": "^1.1.0", "use-latest-callback": "^0.2.1", "vaul": "^1.1.2" }, "peerDependencies": { "@react-navigation/drawer": "^7.5.0", "@testing-library/react-native": ">= 12.0.0", "expo": "*", "expo-constants": "^18.0.12", "expo-linking": "^8.0.11", "react": "*", "react-dom": "*", "react-native": "*", "react-native-gesture-handler": "*", "react-native-reanimated": "*", "react-native-safe-area-context": ">= 5.4.0", "react-native-screens": "*", "react-native-web": "*", "react-server-dom-webpack": "~19.0.3 || ~19.1.4 || ~19.2.3" }, "optionalPeers": ["@react-navigation/drawer", "@testing-library/react-native", "react-dom", "react-native-gesture-handler", "react-native-reanimated", "react-native-web", "react-server-dom-webpack"] }, "sha512-wjTUjrnWj6gRYjaYl1kYfcRnNE4ZAQ0kz0+sQf6/mzBd/OU6pnOdD7WrdAW3pTTpm52Q8sMoeX98tNQEddg2uA=="],
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next'
|
||||||
import { Button } from './ui/button'
|
import { Button } from './ui/button'
|
||||||
import Text from './ui/Text'
|
import Text from './ui/Text'
|
||||||
import { uploadFile } from '@/lib/uploadFile'
|
import { uploadFile } from '@/lib/uploadFile'
|
||||||
|
import { imgPicker } from '@/lib/imgPicker'
|
||||||
import Toast from './ui/Toast'
|
import Toast from './ui/Toast'
|
||||||
|
|
||||||
export type NodeType = 'text' | 'image' | 'video' | 'select'
|
export type NodeType = 'text' | 'image' | 'video' | 'select'
|
||||||
|
|
@ -173,6 +174,25 @@ export const DynamicForm = forwardRef<DynamicFormRef, DynamicFormProps>(
|
||||||
}
|
}
|
||||||
}, [currentNodeId, t, updateFormData])
|
}, [currentNodeId, t, updateFormData])
|
||||||
|
|
||||||
|
// 直接从相册选择图片并上传
|
||||||
|
const handlePickAndUploadImage = useCallback(async (nodeId: string) => {
|
||||||
|
try {
|
||||||
|
const [imageUri] = await imgPicker({ maxImages: 1 })
|
||||||
|
|
||||||
|
// 上传图片
|
||||||
|
const url = await uploadFile({ uri: imageUri })
|
||||||
|
updateFormData(nodeId, url)
|
||||||
|
setPreviewImages((prev) => ({ ...prev, [nodeId]: imageUri }))
|
||||||
|
} catch (error: any) {
|
||||||
|
// 用户取消选择不显示错误
|
||||||
|
if (error?.message === '未选择任何图片') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
console.error('Pick and upload failed:', error)
|
||||||
|
Toast.show(t('dynamicForm.uploadFailed') || '上传失败,请重试')
|
||||||
|
}
|
||||||
|
}, [t, updateFormData])
|
||||||
|
|
||||||
const handleSelectVideo = useCallback(async (videoUri: string, mimeType?: string, fileName?: string) => {
|
const handleSelectVideo = useCallback(async (videoUri: string, mimeType?: string, fileName?: string) => {
|
||||||
if (!currentNodeId) return
|
if (!currentNodeId) return
|
||||||
|
|
||||||
|
|
@ -275,14 +295,7 @@ export const DynamicForm = forwardRef<DynamicFormRef, DynamicFormProps>(
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.uploadButton}
|
style={styles.uploadButton}
|
||||||
onPress={() => {
|
onPress={() => handlePickAndUploadImage(node.id)}
|
||||||
setCurrentNodeId(node.id)
|
|
||||||
if (onOpenDrawer) {
|
|
||||||
onOpenDrawer(node.id)
|
|
||||||
} else {
|
|
||||||
setDrawerVisible(true)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{previewUri ? (
|
{previewUri ? (
|
||||||
<Image
|
<Image
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { forwardRef, useMemo } from 'react'
|
import React, { forwardRef, memo, useEffect, useMemo, useState } from 'react'
|
||||||
import { Image as ExpoImage, ImageProps as ExpoImageProps } from 'expo-image'
|
import { Image as ExpoImage, ImageProps as ExpoImageProps } from 'expo-image'
|
||||||
import { TouchableOpacity, TouchableOpacityProps } from 'react-native'
|
import { TouchableOpacity, TouchableOpacityProps, View } from 'react-native'
|
||||||
|
|
||||||
interface ImgProps extends ExpoImageProps {
|
interface ImgProps extends ExpoImageProps {
|
||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
|
|
@ -8,6 +8,16 @@ interface ImgProps extends ExpoImageProps {
|
||||||
className?: string
|
className?: string
|
||||||
src?: string | number
|
src?: string | number
|
||||||
errorSource?: string | number
|
errorSource?: string | number
|
||||||
|
/** 图片宽度,用于 CDN 压缩 */
|
||||||
|
width?: number
|
||||||
|
/** 是否使用 WebP 格式(默认 true) */
|
||||||
|
isWebP?: boolean
|
||||||
|
/** 是否启用 CDN 压缩(默认 false) */
|
||||||
|
isCompression?: boolean
|
||||||
|
/** 自定义缓存键,用于需要重定向的 URL */
|
||||||
|
cacheKey?: string
|
||||||
|
/** 占位图 URL,在真实图片加载完成前显示 */
|
||||||
|
placeholderSrc?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const Img = forwardRef<ExpoImage, ImgProps>((props, ref) => {
|
const Img = forwardRef<ExpoImage, ImgProps>((props, ref) => {
|
||||||
|
|
@ -19,15 +29,34 @@ const Img = forwardRef<ExpoImage, ImgProps>((props, ref) => {
|
||||||
src,
|
src,
|
||||||
errorSource,
|
errorSource,
|
||||||
source: propSource,
|
source: propSource,
|
||||||
|
width = 256,
|
||||||
|
isWebP = true,
|
||||||
|
isCompression = false,
|
||||||
|
cacheKey,
|
||||||
|
placeholderSrc,
|
||||||
|
onLoad,
|
||||||
...reset
|
...reset
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsLoaded(false)
|
||||||
|
}, [src])
|
||||||
|
|
||||||
// 判断是否为网络图片
|
// 判断是否为网络图片
|
||||||
const isNetworkImage = (uri: string | number): boolean => {
|
const isNetworkImage = (uri: string | number): boolean => {
|
||||||
if (typeof uri === 'number') return false
|
if (typeof uri === 'number') return false
|
||||||
return uri.startsWith('http://') || uri.startsWith('https://')
|
return uri.startsWith('http://') || uri.startsWith('https://')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CDN 压缩 URL(使用 Cloudflare 图片优化服务)
|
||||||
|
const compressionUrl = useMemo((): string | undefined => {
|
||||||
|
if (!src || typeof src !== 'string' || !isNetworkImage(src)) return undefined
|
||||||
|
const format = isWebP ? 'webp' : 'jpg'
|
||||||
|
return `https://bowong.cc/cdn-cgi/image/width=${width},quality=75,format=${format}/${src}`
|
||||||
|
}, [width, isWebP, src])
|
||||||
|
|
||||||
// 构建图片源
|
// 构建图片源
|
||||||
const imageSource = useMemo(() => {
|
const imageSource = useMemo(() => {
|
||||||
// 如果提供了source属性,优先使用
|
// 如果提供了source属性,优先使用
|
||||||
|
|
@ -41,40 +70,83 @@ const Img = forwardRef<ExpoImage, ImgProps>((props, ref) => {
|
||||||
} else {
|
} else {
|
||||||
// 网络图片或本地文件路径
|
// 网络图片或本地文件路径
|
||||||
if (isNetworkImage(src)) {
|
if (isNetworkImage(src)) {
|
||||||
|
const finalUrl = (isCompression && compressionUrl) ? compressionUrl : src
|
||||||
return {
|
return {
|
||||||
uri: src,
|
uri: finalUrl,
|
||||||
cache: 'immutable', // 使用expo-image的缓存机制
|
cacheKey: cacheKey || finalUrl,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 本地文件路径
|
// 本地文件路径
|
||||||
return { uri: src }
|
return { uri: src }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [src, propSource])
|
}, [src, propSource, cacheKey, isCompression, compressionUrl])
|
||||||
|
|
||||||
const imgProps = {
|
const handleLoad = (e: any) => {
|
||||||
style,
|
setIsLoaded(true)
|
||||||
className,
|
onLoad?.(e)
|
||||||
ref,
|
|
||||||
source: imageSource,
|
|
||||||
errorSource,
|
|
||||||
...reset,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePress = () => {
|
const handlePress = () => {
|
||||||
onClick && onClick()
|
onClick && onClick()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 渲染图片内容
|
||||||
|
const renderImage = () => {
|
||||||
|
// 无占位图时直接返回原图
|
||||||
|
if (!placeholderSrc) {
|
||||||
|
return (
|
||||||
|
<ExpoImage
|
||||||
|
ref={ref}
|
||||||
|
style={style}
|
||||||
|
source={imageSource}
|
||||||
|
cachePolicy="disk"
|
||||||
|
recyclingKey={typeof src === 'string' ? src : undefined}
|
||||||
|
transition={{ duration: 200, effect: 'cross-dissolve' }}
|
||||||
|
onLoad={onLoad}
|
||||||
|
{...reset}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有占位图时使用层叠布局
|
||||||
|
return (
|
||||||
|
<View style={[style, { overflow: 'hidden' }]}>
|
||||||
|
{/* 占位图层 - 加载完成后隐藏 */}
|
||||||
|
<ExpoImage
|
||||||
|
style={{ position: 'absolute', width: '100%', height: '100%' }}
|
||||||
|
source={{ uri: placeholderSrc }}
|
||||||
|
cachePolicy="disk"
|
||||||
|
contentFit={reset.contentFit || 'cover'}
|
||||||
|
transition={{ duration: 100, effect: 'curl-up' }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 真实图片层 */}
|
||||||
|
<ExpoImage
|
||||||
|
ref={ref}
|
||||||
|
style={{ width: '100%', height: '100%', opacity: isLoaded ? 1 : 0 }}
|
||||||
|
source={imageSource}
|
||||||
|
cachePolicy="disk"
|
||||||
|
recyclingKey={typeof src === 'string' ? src : undefined}
|
||||||
|
transition={{ duration: 300, effect: 'cross-dissolve' }}
|
||||||
|
onLoad={handleLoad}
|
||||||
|
contentFit={reset.contentFit || 'cover'}
|
||||||
|
{...reset}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (onClick) {
|
if (onClick) {
|
||||||
return (
|
return (
|
||||||
<TouchableOpacity onPress={handlePress} {...touchProps}>
|
<TouchableOpacity onPress={handlePress} {...touchProps}>
|
||||||
<ExpoImage {...imgProps} />
|
{renderImage()}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return <ExpoImage {...imgProps} />
|
return renderImage()
|
||||||
})
|
})
|
||||||
|
|
||||||
Img.displayName = 'Img'
|
Img.displayName = 'Img'
|
||||||
export default Img
|
export default memo(Img)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
import * as ImagePicker from 'expo-image-picker'
|
||||||
|
import Toast from '@/components/ui/Toast'
|
||||||
|
|
||||||
|
// 定义图片选择器参数类型
|
||||||
|
type PickerBaseParams = Omit<
|
||||||
|
ImagePicker.ImagePickerOptions,
|
||||||
|
'mediaTypes' | 'allowsMultipleSelection' | 'selectionLimit'
|
||||||
|
> & {
|
||||||
|
/** 最大选择图片数量,默认 1 */
|
||||||
|
maxImages?: number
|
||||||
|
/** 媒体类型,默认仅图片 */
|
||||||
|
type?: ImagePicker.MediaTypeOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
type PickerUriParams = PickerBaseParams & {
|
||||||
|
/** 返回类型:uri 返回字符串数组 */
|
||||||
|
resultType?: 'uri'
|
||||||
|
}
|
||||||
|
|
||||||
|
type PickerAssetParams = PickerBaseParams & {
|
||||||
|
/** 返回类型:asset 返回完整资源对象 */
|
||||||
|
resultType: 'asset'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片选择器工具
|
||||||
|
* 直接打开系统相册选择图片
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* // 选择单张图片,返回 URI
|
||||||
|
* const [uri] = await imgPicker({ maxImages: 1 })
|
||||||
|
*
|
||||||
|
* // 选择多张图片,返回 URI 数组
|
||||||
|
* const uris = await imgPicker({ maxImages: 9 })
|
||||||
|
*
|
||||||
|
* // 返回完整资源对象(包含宽高等信息)
|
||||||
|
* const assets = await imgPicker({ maxImages: 1, resultType: 'asset' })
|
||||||
|
*/
|
||||||
|
export async function imgPicker(params: PickerUriParams): Promise<string[]>
|
||||||
|
export async function imgPicker(params: PickerAssetParams): Promise<ImagePicker.ImagePickerAsset[]>
|
||||||
|
export async function imgPicker(
|
||||||
|
params: PickerUriParams | PickerAssetParams
|
||||||
|
): Promise<string[] | ImagePicker.ImagePickerAsset[]> {
|
||||||
|
const {
|
||||||
|
maxImages = 1,
|
||||||
|
type = ImagePicker.MediaTypeOptions.Images,
|
||||||
|
resultType = 'uri',
|
||||||
|
...rest
|
||||||
|
} = params
|
||||||
|
|
||||||
|
const isMultiple = maxImages > 1
|
||||||
|
|
||||||
|
// 请求相册权限
|
||||||
|
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync()
|
||||||
|
if (status !== 'granted') {
|
||||||
|
Toast.show('请开启相册权限')
|
||||||
|
throw new Error('请开启相册权限')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开相册选择图片
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: type,
|
||||||
|
quality: 0.9,
|
||||||
|
allowsMultipleSelection: isMultiple,
|
||||||
|
selectionLimit: maxImages,
|
||||||
|
...rest,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 用户取消选择
|
||||||
|
if (result.canceled || !result.assets?.length) {
|
||||||
|
throw new Error('未选择任何图片')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据 resultType 返回不同格式
|
||||||
|
if (resultType === 'uri') {
|
||||||
|
return result.assets.map((asset) => asset.uri)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.assets
|
||||||
|
}
|
||||||
|
|
||||||
|
export default imgPicker
|
||||||
|
|
@ -49,6 +49,7 @@
|
||||||
"expo-linear-gradient": "^15.0.8",
|
"expo-linear-gradient": "^15.0.8",
|
||||||
"expo-linking": "^8.0.10",
|
"expo-linking": "^8.0.10",
|
||||||
"expo-media-library": "~18.2.0",
|
"expo-media-library": "~18.2.0",
|
||||||
|
"expo-native-alipay": "^0.1.1",
|
||||||
"expo-network": "^8.0.8",
|
"expo-network": "^8.0.8",
|
||||||
"expo-router": "~6.0.15",
|
"expo-router": "~6.0.15",
|
||||||
"expo-secure-store": "^15.0.8",
|
"expo-secure-store": "^15.0.8",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue