From 7d73cfbc3e0a469273ecbc87f5024e869625184b Mon Sep 17 00:00:00 2001 From: imeepos Date: Wed, 28 Jan 2026 15:26:00 +0800 Subject: [PATCH] fix: bug --- app/_layout.tsx | 7 + app/membership.test.tsx | 136 +++ app/membership.tsx | 36 +- components/DynamicForm.tsx | 774 +++++++++--------- components/blocks/AuthForm.tsx | 42 +- .../blocks/__tests__/AuthForm.simple.test.tsx | 17 + components/blocks/__tests__/AuthForm.test.tsx | 92 +++ jest.setup.js | 113 ++- lib/fetch-logger.ts | 4 +- lib/uploadFile.ts | 17 +- locales/en-US.json | 1 + locales/zh-CN.json | 1 + 12 files changed, 826 insertions(+), 414 deletions(-) create mode 100644 app/membership.test.tsx create mode 100644 components/blocks/__tests__/AuthForm.simple.test.tsx create mode 100644 components/blocks/__tests__/AuthForm.test.tsx diff --git a/app/_layout.tsx b/app/_layout.tsx index 358d988..08e49d6 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -5,6 +5,13 @@ import { Stack, usePathname, useRouter } from 'expo-router' import { StatusBar } from 'expo-status-bar' import { useEffect } from 'react' import 'react-native-reanimated' +import { configureReanimatedLogger, ReanimatedLogLevel } from 'react-native-reanimated' + +// 禁用 Reanimated strict 模式警告(第三方库导致的警告) +configureReanimatedLogger({ + level: ReanimatedLogLevel.warn, + strict: false, +}) import { GestureHandlerRootView } from 'react-native-gesture-handler' import { View, ActivityIndicator, AppState, LogBox } from 'react-native' import * as Updates from 'expo-updates' diff --git a/app/membership.test.tsx b/app/membership.test.tsx new file mode 100644 index 0000000..c7c0e23 --- /dev/null +++ b/app/membership.test.tsx @@ -0,0 +1,136 @@ +import React from 'react' +import { render, fireEvent, waitFor } from '@testing-library/react-native' +import { View } from 'react-native' +import MembershipScreen from './membership' +import { useMembership } from '@/hooks/use-membership' + +jest.mock('expo-router', () => ({ + useRouter: jest.fn(() => ({ + back: jest.fn(), + push: jest.fn(), + })), +})) + +jest.mock('react-i18next', () => ({ + useTranslation: jest.fn(() => ({ + t: (key: string) => key, + })), +})) + +jest.mock('expo-status-bar', () => ({ + StatusBar: 'StatusBar', +})) + +jest.mock('react-native-safe-area-context', () => ({ + SafeAreaView: ({ children }: { children: React.ReactNode }) => {children}, + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), +})) + +jest.mock('expo-linear-gradient', () => ({ + LinearGradient: 'LinearGradient', +})) + +jest.mock('expo-image', () => ({ + Image: 'Image', +})) + +jest.mock('react-native-reanimated-carousel', () => 'Carousel') + +jest.mock('react-native-svg', () => ({ + __esModule: true, + default: 'Svg', + Svg: 'Svg', + Path: 'Path', + Defs: 'Defs', + LinearGradient: 'LinearGradient', + Stop: 'Stop', +})) + +jest.mock('@/components/icon', () => ({ + LeftArrowIcon: 'LeftArrowIcon', + OmitIcon: 'OmitIcon', + CheckIcon: 'CheckIcon', + UncheckedIcon: 'UncheckedIcon', + TermsIcon: 'TermsIcon', + PrivacyIcon: 'PrivacyIcon', +})) + +jest.mock('@/components/icon/checkMark', () => ({ + CheckMarkIcon: 'CheckMarkIcon', +})) + +jest.mock('@/components/drawer/PointsDrawer', () => 'PointsDrawer') +jest.mock('@/components/ui/dropdown', () => 'Dropdown') +jest.mock('@/components/GradientText', () => 'GradientText') + +jest.mock('@/hooks/use-membership') + +describe('MembershipScreen - 订阅按钮Loading状态', () => { + const mockUseMembership = useMembership as jest.MockedFunction + + beforeEach(() => { + jest.clearAllMocks() + mockUseMembership.mockReturnValue({ + creditPlans: [ + { amountInCents: 1000, credits: 750, popular: false }, + { amountInCents: 2000, credits: 1500, popular: true }, + ], + creditBalance: 100, + selectedPlanIndex: 0, + setSelectedPlanIndex: jest.fn(), + isLoadingSubscriptions: false, + isStripePricingLoading: false, + createSubscription: jest.fn(), + upgradeSubscription: jest.fn(), + restoreSubscription: jest.fn(), + rechargeToken: jest.fn(), + activeAuthSubscription: null, + hasActiveSubscription: false, + stripePricingData: { + pricing_table_items: [ + { price_id: 'price-1', product_id: 'prod-1' }, + { price_id: 'price-2', product_id: 'prod-2' }, + ], + }, + } as any) + }) + + it('应该在点击订阅按钮后显示loading状态', async () => { + const createSubscription = jest.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100))) + mockUseMembership.mockReturnValue({ + ...mockUseMembership(), + createSubscription, + } as any) + + const { getByText, getByTestId } = render() + + const checkbox = getByText('membership.agreementText').parent?.parent + if (checkbox) fireEvent.press(checkbox) + + const subscribeButton = getByText('membership.subscribeNow') + fireEvent.press(subscribeButton) + + await waitFor(() => { + expect(createSubscription).toHaveBeenCalled() + }) + }) + + it('应该在订阅过程中禁用按钮', async () => { + mockUseMembership.mockReturnValue({ + ...mockUseMembership(), + isLoadingSubscriptions: true, + } as any) + + const { getByText } = render() + + const subscribeButton = getByText('membership.subscribeNow').parent + expect(subscribeButton?.props.accessibilityState?.disabled).toBe(true) + }) + + it('应该在未同意协议时禁用按钮', () => { + const { getByText } = render() + + const subscribeButton = getByText('membership.subscribeNow').parent + expect(subscribeButton?.props.accessibilityState?.disabled).toBe(true) + }) +}) diff --git a/app/membership.tsx b/app/membership.tsx index c52e654..736f140 100644 --- a/app/membership.tsx +++ b/app/membership.tsx @@ -68,6 +68,7 @@ export default function MembershipScreen() { const { width: screenWidth } = useWindowDimensions() const [agreed, setAgreed] = useState(false) const [pointsDrawerVisible, setPointsDrawerVisible] = useState(false) + const [isSubscribing, setIsSubscribing] = useState(false) const { t } = useTranslation() @@ -138,24 +139,29 @@ export default function MembershipScreen() { const [currentImageIndex, setCurrentImageIndex] = useState(0) // 处理订阅按钮点击 - const handleSubscribe = () => { + const handleSubscribe = async () => { if (!agreed || !selectedPlan) return const planData = creditPlans[selectedPlanIndex] if (!planData) return - if (hasActiveSubscription) { - upgradeSubscription({ credits: planData.credits }) - } else if (activeAuthSubscription?.cancelAtPeriodEnd) { - restoreSubscription() - } else { - const pricingItem = stripePricingData?.pricing_table_items?.[selectedPlanIndex] - if (pricingItem) { - createSubscription({ - priceId: pricingItem.price_id, - productId: pricingItem.product_id, - }) + setIsSubscribing(true) + try { + if (hasActiveSubscription) { + await upgradeSubscription({ credits: planData.credits }) + } else if (activeAuthSubscription?.cancelAtPeriodEnd) { + await restoreSubscription() + } else { + const pricingItem = stripePricingData?.pricing_table_items?.[selectedPlanIndex] + if (pricingItem) { + await createSubscription({ + priceId: pricingItem.price_id, + productId: pricingItem.product_id, + }) + } } + } finally { + setIsSubscribing(false) } } @@ -393,7 +399,7 @@ export default function MembershipScreen() { {/* 立即开通按钮 */} @@ -410,10 +416,10 @@ export default function MembershipScreen() { end={{ x: 0, y: 0 }} style={[ styles.subscribeButton, - (!agreed || isLoadingSubscriptions || isStripePricingLoading) && styles.subscribeButtonDisabled, + (!agreed || isLoadingSubscriptions || isStripePricingLoading || isSubscribing) && styles.subscribeButtonDisabled, ]} > - {isLoadingSubscriptions || isStripePricingLoading ? ( + {isLoadingSubscriptions || isStripePricingLoading || isSubscribing ? ( ) : ( {t('membership.subscribeNow')} diff --git a/components/DynamicForm.tsx b/components/DynamicForm.tsx index 6ce715f..1b856b8 100644 --- a/components/DynamicForm.tsx +++ b/components/DynamicForm.tsx @@ -11,6 +11,7 @@ import { } from 'react-native' import { Image } from 'expo-image' import { useTranslation } from 'react-i18next' +import * as ImagePicker from 'expo-image-picker' import { Button } from './ui/button' import Text from './ui/Text' @@ -84,410 +85,419 @@ interface DynamicFormProps { export const DynamicForm = forwardRef( function DynamicForm({ formSchema, onSubmit, loading = false, onOpenDrawer, points }, ref) { - const { t } = useTranslation() - const startNodes = formSchema.startNodes || [] + const { t } = useTranslation() + const startNodes = formSchema.startNodes || [] - // Initialize form state for each node - const [formData, setFormData] = useState>(() => { - const initialData: Record = {} - startNodes.forEach((node) => { - if (node.type === 'text') { - // Get default value from data.text or output.texts[0] - const texts = node.data?.output?.texts - initialData[node.id] = texts?.[0] || node.data?.text || '' - } else if (node.type === 'select') { - // Get default value from output.selections - initialData[node.id] = node.data?.output?.selections || '' - } else if (node.type === 'image') { - // Get default value from output.images - const images = node.data?.output?.images - initialData[node.id] = images?.[0]?.url || '' - } else if (node.type === 'video') { - // Get default value from output.videos - const videos = node.data?.output?.videos - initialData[node.id] = videos?.[0]?.url || '' - } else { - initialData[node.id] = '' - } - }) - return initialData - }) - - const [drawerVisible, setDrawerVisible] = useState(false) - const [currentNodeId, setCurrentNodeId] = useState(null) - const [previewImages, setPreviewImages] = useState>(() => { - const initialPreviews: Record = {} - startNodes.forEach((node) => { - if (node.type === 'image') { - const images = node.data?.output?.images - if (images?.[0]?.url) { - initialPreviews[node.id] = images[0].url + // Initialize form state for each node + const [formData, setFormData] = useState>(() => { + const initialData: Record = {} + startNodes.forEach((node) => { + if (node.type === 'text') { + // Get default value from data.text or output.texts[0] + const texts = node.data?.output?.texts + initialData[node.id] = texts?.[0] || node.data?.text || '' + } else if (node.type === 'select') { + // Get default value from output.selections + initialData[node.id] = node.data?.output?.selections || '' + } else if (node.type === 'image') { + // Get default value from output.images + const images = node.data?.output?.images + initialData[node.id] = images?.[0]?.url || '' + } else if (node.type === 'video') { + // Get default value from output.videos + const videos = node.data?.output?.videos + initialData[node.id] = videos?.[0]?.url || '' + } else { + initialData[node.id] = '' } - } else if (node.type === 'video') { - const videos = node.data?.output?.videos - if (videos?.[0]?.url) { - initialPreviews[node.id] = videos[0].url + }) + return initialData + }) + + const [drawerVisible, setDrawerVisible] = useState(false) + const [currentNodeId, setCurrentNodeId] = useState(null) + const [previewImages, setPreviewImages] = useState>(() => { + const initialPreviews: Record = {} + startNodes.forEach((node) => { + if (node.type === 'image') { + const images = node.data?.output?.images + if (images?.[0]?.url) { + initialPreviews[node.id] = images[0].url + } + } else if (node.type === 'video') { + const videos = node.data?.output?.videos + if (videos?.[0]?.url) { + initialPreviews[node.id] = videos[0].url + } } - } + }) + return initialPreviews }) - return initialPreviews - }) - const [errors, setErrors] = useState>({}) + const [errors, setErrors] = useState>({}) - const updateFormData = useCallback((nodeId: string, value: string) => { - setFormData((prev) => ({ ...prev, [nodeId]: value })) - // Clear error for this field when user updates it - if (errors[nodeId]) { - setErrors((prev) => { - const newErrors = { ...prev } - delete newErrors[nodeId] - return newErrors - }) - } - }, [errors]) - - // 暴露方法给父组件 - useImperativeHandle(ref, () => ({ - updateFieldValue: (nodeId: string, value: string, previewUri?: string) => { - updateFormData(nodeId, value) - if (previewUri) { - setPreviewImages((prev) => ({ ...prev, [nodeId]: previewUri })) - } - }, - }), [updateFormData]) - - const handleSelectImage = useCallback(async (imageUri: string, mimeType?: string, fileName?: string) => { - if (!currentNodeId) return - - try { - const url = await uploadFile({ uri: imageUri, mimeType, fileName }) - updateFormData(currentNodeId, url) - setPreviewImages((prev) => ({ ...prev, [currentNodeId]: imageUri })) - } catch (error) { - console.error('Upload failed:', error) - Toast.show({ - title: t('dynamicForm.uploadFailed') || '上传失败,请重试' - }) - } finally { - setDrawerVisible(false) - setCurrentNodeId(null) - } - }, [currentNodeId, t, updateFormData]) - - // 直接从相册选择图片并上传 - const handlePickAndUploadImage = useCallback(async (nodeId: string) => { - try { - const [imageUri] = await imgPicker({ maxImages: 1 }) - console.log('[DynamicForm] 选择图片成功:', imageUri) - - // 上传图片 - const url = await uploadFile({ uri: imageUri }) - console.log('[DynamicForm] 上传成功,URL:', url) - updateFormData(nodeId, url) - setPreviewImages((prev) => ({ ...prev, [nodeId]: imageUri })) - } catch (error: any) { - // 用户取消选择不显示错误 - if (error?.message === '未选择任何图片') { - return - } - console.error('[DynamicForm] 上传失败:', { error, message: error?.message, stack: error?.stack }) - Toast.show(t('dynamicForm.uploadFailed') || '上传失败,请重试') - } - }, [t, updateFormData]) - - const handleSelectVideo = useCallback(async (videoUri: string, mimeType?: string, fileName?: string) => { - if (!currentNodeId) return - - try { - const url = await uploadFile({ uri: videoUri, mimeType, fileName }) - updateFormData(currentNodeId, url) - setPreviewImages((prev) => ({ ...prev, [currentNodeId]: videoUri })) - } catch (error) { - console.error('Upload failed:', error) - Toast.show({ - title: t('dynamicForm.uploadFailed') || '上传失败,请重试' - }) - } finally { - setDrawerVisible(false) - setCurrentNodeId(null) - } - }, [currentNodeId, t, updateFormData]) - - const validateForm = useCallback(() => { - const newErrors: Record = {} - - startNodes.forEach((node) => { - const value = formData[node.id] - if (!value || value.trim() === '') { - const label = node.data?.label || t('dynamicForm.field') || '字段' - newErrors[node.id] = `${label}${t('dynamicForm.required') || '不能为空'}` - } - }) - - setErrors(newErrors) - return Object.keys(newErrors).length === 0 - }, [formData, startNodes, t]) - - const handleSubmit = useCallback(async () => { - if (!validateForm()) { - Toast.show({ - title: t('dynamicForm.fillRequiredFields') || '请填写所有必填项' - }) - return - } - - Toast.showLoading() - try { - const result = await onSubmit(formData) - Toast.hideLoading() - - if (result.error) { - Toast.show({ - title: result.error.message || t('dynamicForm.submitFailed') || '提交失败' + const updateFormData = useCallback((nodeId: string, value: string) => { + setFormData((prev) => ({ ...prev, [nodeId]: value })) + // Clear error for this field when user updates it + if (errors[nodeId]) { + setErrors((prev) => { + const newErrors = { ...prev } + delete newErrors[nodeId] + return newErrors }) } - } catch (error) { - Toast.hideLoading() - Toast.show({ - title: t('dynamicForm.submitFailed') || '提交失败' + }, [errors]) + + // 暴露方法给父组件 + useImperativeHandle(ref, () => ({ + updateFieldValue: (nodeId: string, value: string, previewUri?: string) => { + updateFormData(nodeId, value) + if (previewUri) { + setPreviewImages((prev) => ({ ...prev, [nodeId]: previewUri })) + } + }, + }), [updateFormData]) + + const handleSelectImage = useCallback(async (imageUri: string, mimeType?: string, fileName?: string) => { + if (!currentNodeId) return + + try { + const url = await uploadFile({ uri: imageUri, mimeType, fileName }) + updateFormData(currentNodeId, url) + setPreviewImages((prev) => ({ ...prev, [currentNodeId]: imageUri })) + } catch (error) { + console.error('Upload failed:', error) + Toast.show({ + title: t('dynamicForm.uploadFailed') || '上传失败,请重试' + }) + } finally { + setDrawerVisible(false) + setCurrentNodeId(null) + } + }, [currentNodeId, t, updateFormData]) + + // 直接从相册选择图片并上传 + const handlePickAndUploadImage = useCallback(async (nodeId: string) => { + try { + const [asset] = await imgPicker({ maxImages: 1, type: ImagePicker.MediaTypeOptions.All, resultType: 'asset' }) + if (!asset) { + console.warn('No asset selected') + return + } + if (asset?.uri) { + Toast.showLoading({ title: '文件传输中...', duration: 0 }) + console.log('[DynamicForm] 选择图片成功:', asset) + // 上传图片 + const url = await uploadFile({ uri: asset.uri }) + console.log('[DynamicForm] 上传成功,URL:', url) + updateFormData(nodeId, url) + setPreviewImages((prev) => ({ ...prev, [nodeId]: url })) + } + + } catch (error: any) { + // 用户取消选择不显示错误 + if (error?.message === '未选择任何图片') { + return + } + console.error('[DynamicForm] 上传失败:', { error, message: error?.message, stack: error?.stack }) + Toast.show(t('dynamicForm.uploadFailed') || '上传失败,请重试') + } finally { + Toast.hideLoading() + } + }, [t, updateFormData]) + + const handleSelectVideo = useCallback(async (videoUri: string, mimeType?: string, fileName?: string) => { + if (!currentNodeId) return + + try { + const url = await uploadFile({ uri: videoUri, mimeType, fileName }) + updateFormData(currentNodeId, url) + setPreviewImages((prev) => ({ ...prev, [currentNodeId]: videoUri })) + } catch (error) { + console.error('Upload failed:', error) + Toast.show({ + title: t('dynamicForm.uploadFailed') || '上传失败,请重试' + }) + } finally { + setDrawerVisible(false) + setCurrentNodeId(null) + } + }, [currentNodeId, t, updateFormData]) + + const validateForm = useCallback(() => { + const newErrors: Record = {} + + startNodes.forEach((node) => { + const value = formData[node.id] + if (!value || value.trim() === '') { + const label = node.data?.label || t('dynamicForm.field') || '字段' + newErrors[node.id] = `${label}${t('dynamicForm.required') || '不能为空'}` + } }) + + setErrors(newErrors) + return Object.keys(newErrors).length === 0 + }, [formData, startNodes, t]) + + const handleSubmit = useCallback(async () => { + if (!validateForm()) { + Toast.show({ + title: t('dynamicForm.fillRequiredFields') || '请填写所有必填项' + }) + return + } + + Toast.showLoading() + try { + const result = await onSubmit(formData) + Toast.hideLoading() + + if (result.error) { + Toast.show({ + title: result.error.message || t('dynamicForm.submitFailed') || '提交失败' + }) + } + } catch (error) { + Toast.hideLoading() + Toast.show({ + title: t('dynamicForm.submitFailed') || '提交失败' + }) + } + }, [formData, onSubmit, validateForm, t]) + + const renderTextField = (node: StartNode) => { + const value = formData[node.id] || '' + const error = errors[node.id] + const placeholder = node.data?.description || t('dynamicForm.enterText') || '请输入文本' + + return ( + + {node.data?.label && ( + + {node.data.label} + * + + )} + updateFormData(node.id, text)} + placeholder={placeholder} + placeholderTextColor="#8A8A8A" + multiline + numberOfLines={4} + textAlignVertical="top" + /> + {error && {error}} + + ) } - }, [formData, onSubmit, validateForm, t]) - const renderTextField = (node: StartNode) => { - const value = formData[node.id] || '' - const error = errors[node.id] - const placeholder = node.data?.description || t('dynamicForm.enterText') || '请输入文本' + const renderImageField = (node: StartNode) => { + const value = formData[node.id] + const previewUri = previewImages[node.id] + const error = errors[node.id] + const label = node.data?.label || t('dynamicForm.image') || '图片' - return ( - - {node.data?.label && ( + return ( + - {node.data.label} + {label} * - )} - updateFormData(node.id, text)} - placeholder={placeholder} - placeholderTextColor="#8A8A8A" - multiline - numberOfLines={4} - textAlignVertical="top" - /> - {error && {error}} - - ) - } + handlePickAndUploadImage(node.id)} + > + {previewUri ? ( + + ) : ( + + + {t('dynamicForm.uploadImage') || '点击上传图片'} + + + )} + + {error && {error}} + + ) + } - const renderImageField = (node: StartNode) => { - const value = formData[node.id] - const previewUri = previewImages[node.id] - const error = errors[node.id] - const label = node.data?.label || t('dynamicForm.image') || '图片' + const renderVideoField = (node: StartNode) => { + const value = formData[node.id] + const previewUri = previewImages[node.id] + const error = errors[node.id] + const label = node.data?.label || t('dynamicForm.video') || '视频' - return ( - - - {label} - * - - handlePickAndUploadImage(node.id)} - > - {previewUri ? ( - - ) : ( - - - {t('dynamicForm.uploadImage') || '点击上传图片'} - - - )} - - {error && {error}} - - ) - } + return ( + + + {label} + * + + { + setCurrentNodeId(node.id) + if (onOpenDrawer) { + onOpenDrawer(node.id) + } else { + setDrawerVisible(true) + } + }} + > + {previewUri ? ( + + + {t('dynamicForm.videoUploaded') || '视频已上传'} + + + ) : ( + + + {t('dynamicForm.uploadVideo') || '点击上传视频'} + + + )} + + {error && {error}} + + ) + } - const renderVideoField = (node: StartNode) => { - const value = formData[node.id] - const previewUri = previewImages[node.id] - const error = errors[node.id] - const label = node.data?.label || t('dynamicForm.video') || '视频' + const renderSelectField = (node: StartNode) => { + const value = formData[node.id] + const error = errors[node.id] + const label = node.data?.label || t('dynamicForm.select') || '选择' + const options = node.data?.actionData?.options || [] + const placeholder = node.data?.actionData?.placeholder || t('dynamicForm.selectPlaceholder') || '请选择' + const allowMultiple = node.data?.actionData?.allowMultiple || false - return ( - - - {label} - * - - { - setCurrentNodeId(node.id) - if (onOpenDrawer) { - onOpenDrawer(node.id) - } else { - setDrawerVisible(true) - } - }} - > - {previewUri ? ( - - - {t('dynamicForm.videoUploaded') || '视频已上传'} - - - ) : ( - - - {t('dynamicForm.uploadVideo') || '点击上传视频'} - - - )} - - {error && {error}} - - ) - } - - const renderSelectField = (node: StartNode) => { - const value = formData[node.id] - const error = errors[node.id] - const label = node.data?.label || t('dynamicForm.select') || '选择' - const options = node.data?.actionData?.options || [] - const placeholder = node.data?.actionData?.placeholder || t('dynamicForm.selectPlaceholder') || '请选择' - const allowMultiple = node.data?.actionData?.allowMultiple || false - - return ( - - - {label} - * - - {allowMultiple ? ( - // Multi-select: render as a list of checkboxes - - {options.map((option, index) => { - const isSelected = value === option.value - return ( - updateFormData(node.id, option.value)} - > - - {option.label} - - - ) - })} - - ) : ( - // Single select: render as a list of radio buttons - - {options.map((option, index) => { - const isSelected = value === option.value - return ( - updateFormData(node.id, option.value)} - > - - - {isSelected && } - + return ( + + + {label} + * + + {allowMultiple ? ( + // Multi-select: render as a list of checkboxes + + {options.map((option, index) => { + const isSelected = value === option.value + return ( + updateFormData(node.id, option.value)} + > {option.label} - - - ) - })} - - )} - {error && {error}} - - ) - } - - const renderField = (node: StartNode) => { - switch (node.type) { - case 'text': - return renderTextField(node) - case 'image': - return renderImageField(node) - case 'video': - return renderVideoField(node) - case 'select': - return renderSelectField(node) - default: - return null - } - } - - if (startNodes.length === 0) { - return ( - - - {t('dynamicForm.noFields') || '暂无可填写的表单项'} - - - ) - } - - return ( - - - {startNodes.map(renderField)} - - - + + ) + })} + + ) : ( + // Single select: render as a list of radio buttons + + {options.map((option, index) => { + const isSelected = value === option.value + return ( + updateFormData(node.id, option.value)} + > + + + {isSelected && } + + + {option.label} + + + + ) + })} + + )} + {error && {error}} - - - ) + ) + } + + const renderField = (node: StartNode) => { + switch (node.type) { + case 'text': + return renderTextField(node) + case 'image': + return renderImageField(node) + case 'video': + return renderVideoField(node) + case 'select': + return renderSelectField(node) + default: + return null + } + } + + if (startNodes.length === 0) { + return ( + + + {t('dynamicForm.noFields') || '暂无可填写的表单项'} + + + ) + } + + return ( + + + {startNodes.map(renderField)} + + + + + + + ) } ) diff --git a/components/blocks/AuthForm.tsx b/components/blocks/AuthForm.tsx index db95343..151cc79 100644 --- a/components/blocks/AuthForm.tsx +++ b/components/blocks/AuthForm.tsx @@ -1,11 +1,12 @@ -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { ActivityIndicator, TextInput } from "react-native"; +import { ActivityIndicator, TextInput, Pressable } from "react-native"; import { authClient, setAuthToken, useSession } from "../../lib/auth"; import type { ApiError } from "../../lib/types"; import { Block } from "../ui"; import { Button } from "../ui/button"; import Text from "../ui/Text"; +import { storage } from "../../lib/storage"; type AuthMode = "login" | "register"; @@ -26,9 +27,23 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr const [password, setPassword] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(""); + const [rememberPassword, setRememberPassword] = useState(false); const isLogin = currentMode === "login"; + useEffect(() => { + const loadSavedCredentials = async () => { + const saved = await storage.getItem("saved_credentials"); + if (saved) { + const { username: savedUsername, password: savedPassword } = JSON.parse(saved); + setUsername(savedUsername); + setPassword(savedPassword); + setRememberPassword(true); + } + }; + loadSavedCredentials(); + }, []); + // 根据错误代码获取翻译后的错误信息 const getErrorMessage = (error: ApiError): string => { if (error.code) { @@ -77,7 +92,13 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr return; } - // 登录成功 + // 登录成功后保存凭证 + if (rememberPassword) { + await storage.setItem("saved_credentials", JSON.stringify({ username, password })); + } else { + await storage.removeItem("saved_credentials"); + } + onSuccess?.(); } else { const result = await signUp.email({ email, password, name: username }, { @@ -157,6 +178,21 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr secureTextEntry /> + {isLogin && ( + setRememberPassword(!rememberPassword)} + className="flex-row items-center mb-4" + > + + {rememberPassword && } + + {t("authForm.rememberPassword")} + + )} + {error ? ( {error} diff --git a/components/blocks/__tests__/AuthForm.simple.test.tsx b/components/blocks/__tests__/AuthForm.simple.test.tsx new file mode 100644 index 0000000..9fbccb4 --- /dev/null +++ b/components/blocks/__tests__/AuthForm.simple.test.tsx @@ -0,0 +1,17 @@ +import React from 'react' +import { Text, View } from 'react-native' +import { render } from '@testing-library/react-native' + +// 最简单的测试组件 +const SimpleComponent = () => ( + + Hello Test + +) + +describe('Simple Test', () => { + test('renders simple component', () => { + const { getByText } = render() + expect(getByText('Hello Test')).toBeTruthy() + }) +}) diff --git a/components/blocks/__tests__/AuthForm.test.tsx b/components/blocks/__tests__/AuthForm.test.tsx new file mode 100644 index 0000000..e268a95 --- /dev/null +++ b/components/blocks/__tests__/AuthForm.test.tsx @@ -0,0 +1,92 @@ +import React from 'react' +import { render, fireEvent, waitFor } from '@testing-library/react-native' +import { AuthForm } from '../AuthForm' +import { storage } from '@/lib/storage' + +// Mock entire UI module to avoid complex dependencies +jest.mock('@/components/ui', () => { + const mockReact = require('react') + const mockRN = require('react-native') + + const Block = ({ children, onClick, ...props }: any) => { + if (onClick) { + return mockReact.createElement(mockRN.Pressable, { onPress: onClick, ...props }, children) + } + return mockReact.createElement(mockRN.View, props, children) + } + + const Text = ({ children, onClick, ...props }: any) => { + if (onClick) { + return mockReact.createElement(mockRN.Pressable, { onPress: onClick }, + mockReact.createElement(mockRN.Text, props, children) + ) + } + return mockReact.createElement(mockRN.Text, props, children) + } + + return { + Block, + Text, + } +}) + +// Mock button component separately since it's imported from a different path +jest.mock('../../ui/button', () => { + const mockReact = require('react') + const mockRN = require('react-native') + + const Button = ({ children, onPress, disabled, ...props }: any) => ( + mockReact.createElement(mockRN.Pressable, { onPress, disabled, ...props }, children) + ) + + return { + Button, + } +}) + +// Mock ModalPortal to avoid react-native-css-interop issues +jest.mock('@/components/ui/ModalPortal', () => ({ + __esModule: true, + default: ({ children }: any) => children, +})) + +// Mock storage +jest.mock('@/lib/storage', () => ({ + storage: { + getItem: jest.fn(), + setItem: jest.fn(), + removeItem: jest.fn(), + }, +})) + +// Mock auth client +jest.mock('@/lib/auth', () => ({ + authClient: { + signIn: { + username: jest.fn(), + }, + signUp: { + email: jest.fn(), + }, + }, + setAuthToken: jest.fn(), + useSession: jest.fn(() => ({ data: null, isPending: false })), +})) + +// Mock react-i18next +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + }), +})) + +describe('AuthForm - Remember Password', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('renders remember password checkbox', () => { + const { getByText } = render() + expect(getByText('authForm.rememberPassword')).toBeTruthy() + }) +}) diff --git a/jest.setup.js b/jest.setup.js index 84cab50..5d09bd2 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -1,3 +1,8 @@ +// Mock react-native-css-interop FIRST to avoid appearance listener issues +jest.mock('react-native-css-interop', () => ({ + __esModule: true, +})) + // Mock react-native BEFORE any other imports to avoid flow type issues jest.mock('react-native', () => { const React = require('react') @@ -11,7 +16,11 @@ jest.mock('react-native', () => { Pressable: 'Pressable', TouchableOpacity: 'TouchableOpacity', TextInput: 'TextInput', - Platform: { OS: 'web' }, + ActivityIndicator: 'ActivityIndicator', + Platform: { + OS: 'web', + select: (obj) => obj.web || obj.default, + }, Animated: { View: 'Animated.View', Text: 'Animated.Text', @@ -49,11 +58,27 @@ jest.mock('react-native', () => { Linking: { openURL: jest.fn(), }, + Appearance: { + getColorScheme: jest.fn(() => 'light'), + addChangeListener: jest.fn(), + removeChangeListener: jest.fn(), + }, + BackHandler: { + addEventListener: jest.fn(() => ({ remove: jest.fn() })), + removeEventListener: jest.fn(), + }, } }) require('@testing-library/jest-native/extend-expect') +// Mock global Appearance for react-native-css-interop +global.Appearance = { + getColorScheme: jest.fn(() => 'light'), + addChangeListener: jest.fn(() => ({ remove: jest.fn() })), + removeChangeListener: jest.fn(), +} + // Initialize globals for react-native-reanimated global._tagToJSPropNamesMapping = {} global._WORKLET = false @@ -95,6 +120,18 @@ jest.mock('expo-linear-gradient', () => ({ LinearGradient: 'LinearGradient', })) +// Mock expo-blur +jest.mock('expo-blur', () => ({ + BlurView: 'BlurView', +})) + +// Mock react-native-safe-area-context +jest.mock('react-native-safe-area-context', () => ({ + useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 }), + SafeAreaProvider: ({ children }: any) => children, + SafeAreaView: 'SafeAreaView', +})) + // Mock react-native-gesture-handler jest.mock('react-native-gesture-handler', () => { const { View } = require('react-native') @@ -142,11 +179,34 @@ jest.mock('expo-splash-screen', () => ({ })) // Mock react-native-reanimated -jest.mock('react-native-reanimated', () => { - const Reanimated = require('react-native-reanimated/mock') - Reanimated.default.call = () => {} - return Reanimated -}) +jest.mock('react-native-reanimated', () => ({ + __esModule: true, + default: { + createAnimatedComponent: (Component) => Component, + call: () => {}, + }, + useSharedValue: (v) => ({ value: v }), + useAnimatedStyle: (fn) => fn(), + withTiming: (v) => v, + withSpring: (v) => v, + withDecay: (v) => v, + withRepeat: (v) => v, + withSequence: (...args) => args[0], + Easing: { + linear: (v) => v, + ease: (v) => v, + quad: (v) => v, + cubic: (v) => v, + }, + runOnJS: (fn) => fn, + runOnUI: (fn) => fn, +})) + +// Mock react-native-screens +jest.mock('react-native-screens', () => ({ + FullWindowOverlay: 'FullWindowOverlay', + enableScreens: jest.fn(), +})) // Mock nativewind jest.mock('nativewind', () => ({ @@ -225,8 +285,49 @@ jest.mock('@/components/icon', () => ({ LeftArrowIcon: () => null, UploadIcon: () => null, WhitePointsIcon: () => null, + RightArrowIcon: () => null, })) +// Mock UI components that have complex dependencies +jest.mock('@/components/ui/Text', () => { + const mockReact = require('react') + const mockRN = require('react-native') + const Text = mockReact.forwardRef(({ children, ...props }, ref) => ( + mockReact.createElement(mockRN.Text, { ref, ...props }, children) + )) + Text.displayName = 'Text' + return { + __esModule: true, + default: Text, + } +}) + +jest.mock('@/components/ui/Block', () => { + const mockReact = require('react') + const mockRN = require('react-native') + const Block = mockReact.forwardRef(({ children, onClick, ...props }, ref) => { + if (onClick) { + return mockReact.createElement(mockRN.Pressable, { ref, onPress: onClick, ...props }, children) + } + return mockReact.createElement(mockRN.View, { ref, ...props }, children) + }) + Block.displayName = 'Block' + return { + __esModule: true, + default: Block, + } +}) + +jest.mock('@/components/ui/button', () => { + const mockReact = require('react') + const mockRN = require('react-native') + return { + Button: mockReact.forwardRef(({ children, onPress, disabled, ...props }, ref) => ( + mockReact.createElement(mockRN.Pressable, { ref, onPress, disabled, ...props }, children) + )), + } +}) + jest.mock('@/components/skeleton/HomeSkeleton', () => ({ HomeSkeleton: () => null, })) diff --git a/lib/fetch-logger.ts b/lib/fetch-logger.ts index 77021b2..a4d6eed 100644 --- a/lib/fetch-logger.ts +++ b/lib/fetch-logger.ts @@ -58,8 +58,8 @@ export const createFetchWithLogger = (options: FetchLoggerOptions = {}) => { headers['authorization'] = `Bearer ${token}` } - // 确保 Content-Type 正确设置 - if (method !== 'GET' && method !== 'HEAD' && !headers['Content-Type']) { + // 确保 Content-Type 正确设置(FormData 不需要手动设置,让浏览器自动处理 boundary) + if (method !== 'GET' && method !== 'HEAD' && !headers['Content-Type'] && !(init?.body instanceof FormData)) { headers['Content-Type'] = 'application/json' } diff --git a/lib/uploadFile.ts b/lib/uploadFile.ts index c572fd2..e1c0358 100644 --- a/lib/uploadFile.ts +++ b/lib/uploadFile.ts @@ -5,22 +5,27 @@ import { Platform } from 'react-native' import { handleError } from '@/hooks/use-error' export async function uploadFile(params: { uri: string; mimeType?: string; fileName?: string }): Promise { - const { uri, mimeType, fileName } = params + const { uri, mimeType, fileName } = params || {} console.log('[uploadFile] 开始上传:', { uri, mimeType, fileName, platform: Platform.OS }) const formData = new FormData() - formData.append('file', { - uri: uri, - name: fileName || 'uploaded_file.jpg', + + // React Native 中 FormData 需要使用特定格式 + const fileObject = { + uri: Platform.OS === 'android' ? uri : uri.replace('file://', ''), type: mimeType || 'image/jpeg', - } as any) + name: fileName || 'uploaded_file.jpg', + } + + console.log('[uploadFile] FormData file object:', fileObject) + formData.append('file', fileObject as any) const fileController = root.get(FileController) const { data, error } = await handleError(async () => await fileController.uploadS3(formData)) if (error || !data?.data) { - console.error('[uploadFile] 上传失败:', { error, data, uri }) + console.error('[uploadFile] 上传失败:', { error, data, uri, formData }) throw error || new Error('上传失败') } diff --git a/locales/en-US.json b/locales/en-US.json index 450537f..cf69be6 100644 --- a/locales/en-US.json +++ b/locales/en-US.json @@ -221,6 +221,7 @@ "username": "Username", "email": "Email", "password": "Password", + "rememberPassword": "Remember Password", "fillCompleteInfo": "Please fill in all information", "fillEmail": "Please fill in email", "loginFailed": "Login failed", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 8564c80..44476f5 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -221,6 +221,7 @@ "username": "用户名", "email": "邮箱", "password": "密码", + "rememberPassword": "记住密码", "fillCompleteInfo": "请填写完整信息", "fillEmail": "请填写邮箱", "loginFailed": "登录失败",