From 01865d94c24c74404d39822b0beb86771ddb7a57 Mon Sep 17 00:00:00 2001 From: imeepos Date: Mon, 26 Jan 2026 13:04:14 +0800 Subject: [PATCH] feat: add image picker utility and integrate image upload in DynamicForm component --- bun.lock | 3 ++ components/DynamicForm.tsx | 29 ++++++++--- components/ui/Img.tsx | 102 +++++++++++++++++++++++++++++++------ lib/imgPicker.ts | 82 +++++++++++++++++++++++++++++ package.json | 1 + 5 files changed, 194 insertions(+), 23 deletions(-) create mode 100644 lib/imgPicker.ts diff --git a/bun.lock b/bun.lock index 24cc7b3..eee4e8a 100644 --- a/bun.lock +++ b/bun.lock @@ -35,6 +35,7 @@ "expo-linear-gradient": "^15.0.8", "expo-linking": "^8.0.10", "expo-media-library": "~18.2.0", + "expo-native-alipay": "^0.1.1", "expo-network": "^8.0.8", "expo-router": "~6.0.15", "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-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-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=="], diff --git a/components/DynamicForm.tsx b/components/DynamicForm.tsx index 6eae660..1e5efe0 100644 --- a/components/DynamicForm.tsx +++ b/components/DynamicForm.tsx @@ -15,6 +15,7 @@ import { useTranslation } from 'react-i18next' import { Button } from './ui/button' import Text from './ui/Text' import { uploadFile } from '@/lib/uploadFile' +import { imgPicker } from '@/lib/imgPicker' import Toast from './ui/Toast' export type NodeType = 'text' | 'image' | 'video' | 'select' @@ -173,6 +174,25 @@ export const DynamicForm = forwardRef( } }, [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) => { if (!currentNodeId) return @@ -275,14 +295,7 @@ export const DynamicForm = forwardRef( { - setCurrentNodeId(node.id) - if (onOpenDrawer) { - onOpenDrawer(node.id) - } else { - setDrawerVisible(true) - } - }} + onPress={() => handlePickAndUploadImage(node.id)} > {previewUri ? ( void @@ -8,6 +8,16 @@ interface ImgProps extends ExpoImageProps { className?: string src?: 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((props, ref) => { @@ -19,15 +29,34 @@ const Img = forwardRef((props, ref) => { src, errorSource, source: propSource, + width = 256, + isWebP = true, + isCompression = false, + cacheKey, + placeholderSrc, + onLoad, ...reset } = props + const [isLoaded, setIsLoaded] = useState(false) + + useEffect(() => { + setIsLoaded(false) + }, [src]) + // 判断是否为网络图片 const isNetworkImage = (uri: string | number): boolean => { if (typeof uri === 'number') return false 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(() => { // 如果提供了source属性,优先使用 @@ -41,40 +70,83 @@ const Img = forwardRef((props, ref) => { } else { // 网络图片或本地文件路径 if (isNetworkImage(src)) { + const finalUrl = (isCompression && compressionUrl) ? compressionUrl : src return { - uri: src, - cache: 'immutable', // 使用expo-image的缓存机制 + uri: finalUrl, + cacheKey: cacheKey || finalUrl, } } else { // 本地文件路径 return { uri: src } } } - }, [src, propSource]) + }, [src, propSource, cacheKey, isCompression, compressionUrl]) - const imgProps = { - style, - className, - ref, - source: imageSource, - errorSource, - ...reset, + const handleLoad = (e: any) => { + setIsLoaded(true) + onLoad?.(e) } const handlePress = () => { onClick && onClick() } + // 渲染图片内容 + const renderImage = () => { + // 无占位图时直接返回原图 + if (!placeholderSrc) { + return ( + + ) + } + + // 有占位图时使用层叠布局 + return ( + + {/* 占位图层 - 加载完成后隐藏 */} + + + {/* 真实图片层 */} + + + ) + } + if (onClick) { return ( - + {renderImage()} ) } - return + return renderImage() }) Img.displayName = 'Img' -export default Img +export default memo(Img) diff --git a/lib/imgPicker.ts b/lib/imgPicker.ts new file mode 100644 index 0000000..521e684 --- /dev/null +++ b/lib/imgPicker.ts @@ -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 +export async function imgPicker(params: PickerAssetParams): Promise +export async function imgPicker( + params: PickerUriParams | PickerAssetParams +): Promise { + 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 diff --git a/package.json b/package.json index 0603323..9c03d30 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "expo-linear-gradient": "^15.0.8", "expo-linking": "^8.0.10", "expo-media-library": "~18.2.0", + "expo-native-alipay": "^0.1.1", "expo-network": "^8.0.8", "expo-router": "~6.0.15", "expo-secure-store": "^15.0.8",