fix: bug
This commit is contained in:
@@ -5,6 +5,13 @@ import { Stack, usePathname, useRouter } from 'expo-router'
|
|||||||
import { StatusBar } from 'expo-status-bar'
|
import { StatusBar } from 'expo-status-bar'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import 'react-native-reanimated'
|
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 { GestureHandlerRootView } from 'react-native-gesture-handler'
|
||||||
import { View, ActivityIndicator, AppState, LogBox } from 'react-native'
|
import { View, ActivityIndicator, AppState, LogBox } from 'react-native'
|
||||||
import * as Updates from 'expo-updates'
|
import * as Updates from 'expo-updates'
|
||||||
|
|||||||
136
app/membership.test.tsx
Normal file
136
app/membership.test.tsx
Normal file
@@ -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 }) => <View>{children}</View>,
|
||||||
|
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<typeof useMembership>
|
||||||
|
|
||||||
|
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(<MembershipScreen />)
|
||||||
|
|
||||||
|
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(<MembershipScreen />)
|
||||||
|
|
||||||
|
const subscribeButton = getByText('membership.subscribeNow').parent
|
||||||
|
expect(subscribeButton?.props.accessibilityState?.disabled).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('应该在未同意协议时禁用按钮', () => {
|
||||||
|
const { getByText } = render(<MembershipScreen />)
|
||||||
|
|
||||||
|
const subscribeButton = getByText('membership.subscribeNow').parent
|
||||||
|
expect(subscribeButton?.props.accessibilityState?.disabled).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -68,6 +68,7 @@ export default function MembershipScreen() {
|
|||||||
const { width: screenWidth } = useWindowDimensions()
|
const { width: screenWidth } = useWindowDimensions()
|
||||||
const [agreed, setAgreed] = useState(false)
|
const [agreed, setAgreed] = useState(false)
|
||||||
const [pointsDrawerVisible, setPointsDrawerVisible] = useState(false)
|
const [pointsDrawerVisible, setPointsDrawerVisible] = useState(false)
|
||||||
|
const [isSubscribing, setIsSubscribing] = useState(false)
|
||||||
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@@ -138,24 +139,29 @@ export default function MembershipScreen() {
|
|||||||
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
const [currentImageIndex, setCurrentImageIndex] = useState(0)
|
||||||
|
|
||||||
// 处理订阅按钮点击
|
// 处理订阅按钮点击
|
||||||
const handleSubscribe = () => {
|
const handleSubscribe = async () => {
|
||||||
if (!agreed || !selectedPlan) return
|
if (!agreed || !selectedPlan) return
|
||||||
|
|
||||||
const planData = creditPlans[selectedPlanIndex]
|
const planData = creditPlans[selectedPlanIndex]
|
||||||
if (!planData) return
|
if (!planData) return
|
||||||
|
|
||||||
if (hasActiveSubscription) {
|
setIsSubscribing(true)
|
||||||
upgradeSubscription({ credits: planData.credits })
|
try {
|
||||||
} else if (activeAuthSubscription?.cancelAtPeriodEnd) {
|
if (hasActiveSubscription) {
|
||||||
restoreSubscription()
|
await upgradeSubscription({ credits: planData.credits })
|
||||||
} else {
|
} else if (activeAuthSubscription?.cancelAtPeriodEnd) {
|
||||||
const pricingItem = stripePricingData?.pricing_table_items?.[selectedPlanIndex]
|
await restoreSubscription()
|
||||||
if (pricingItem) {
|
} else {
|
||||||
createSubscription({
|
const pricingItem = stripePricingData?.pricing_table_items?.[selectedPlanIndex]
|
||||||
priceId: pricingItem.price_id,
|
if (pricingItem) {
|
||||||
productId: pricingItem.product_id,
|
await createSubscription({
|
||||||
})
|
priceId: pricingItem.price_id,
|
||||||
|
productId: pricingItem.product_id,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
setIsSubscribing(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,7 +399,7 @@ export default function MembershipScreen() {
|
|||||||
<View style={[styles.subscribeContainer, { paddingBottom: Math.max(insets.bottom, 3) }]}>
|
<View style={[styles.subscribeContainer, { paddingBottom: Math.max(insets.bottom, 3) }]}>
|
||||||
{/* 立即开通按钮 */}
|
{/* 立即开通按钮 */}
|
||||||
<Pressable
|
<Pressable
|
||||||
disabled={!agreed || isLoadingSubscriptions || isStripePricingLoading}
|
disabled={!agreed || isLoadingSubscriptions || isStripePricingLoading || isSubscribing}
|
||||||
style={styles.subscribeButtonPressable}
|
style={styles.subscribeButtonPressable}
|
||||||
onPress={handleSubscribe}
|
onPress={handleSubscribe}
|
||||||
>
|
>
|
||||||
@@ -410,10 +416,10 @@ export default function MembershipScreen() {
|
|||||||
end={{ x: 0, y: 0 }}
|
end={{ x: 0, y: 0 }}
|
||||||
style={[
|
style={[
|
||||||
styles.subscribeButton,
|
styles.subscribeButton,
|
||||||
(!agreed || isLoadingSubscriptions || isStripePricingLoading) && styles.subscribeButtonDisabled,
|
(!agreed || isLoadingSubscriptions || isStripePricingLoading || isSubscribing) && styles.subscribeButtonDisabled,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{isLoadingSubscriptions || isStripePricingLoading ? (
|
{isLoadingSubscriptions || isStripePricingLoading || isSubscribing ? (
|
||||||
<ActivityIndicator color="#F5F5F5" />
|
<ActivityIndicator color="#F5F5F5" />
|
||||||
) : (
|
) : (
|
||||||
<Text style={currentPlan.recommended ? styles.subscribeButtonText : styles.subscribeButtonText1}>{t('membership.subscribeNow')}</Text>
|
<Text style={currentPlan.recommended ? styles.subscribeButtonText : styles.subscribeButtonText1}>{t('membership.subscribeNow')}</Text>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from 'react-native'
|
} from 'react-native'
|
||||||
import { Image } from 'expo-image'
|
import { Image } from 'expo-image'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import * as ImagePicker from 'expo-image-picker'
|
||||||
|
|
||||||
import { Button } from './ui/button'
|
import { Button } from './ui/button'
|
||||||
import Text from './ui/Text'
|
import Text from './ui/Text'
|
||||||
@@ -84,410 +85,419 @@ interface DynamicFormProps {
|
|||||||
|
|
||||||
export const DynamicForm = forwardRef<DynamicFormRef, DynamicFormProps>(
|
export const DynamicForm = forwardRef<DynamicFormRef, DynamicFormProps>(
|
||||||
function DynamicForm({ formSchema, onSubmit, loading = false, onOpenDrawer, points }, ref) {
|
function DynamicForm({ formSchema, onSubmit, loading = false, onOpenDrawer, points }, ref) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const startNodes = formSchema.startNodes || []
|
const startNodes = formSchema.startNodes || []
|
||||||
|
|
||||||
// Initialize form state for each node
|
// Initialize form state for each node
|
||||||
const [formData, setFormData] = useState<Record<string, string>>(() => {
|
const [formData, setFormData] = useState<Record<string, string>>(() => {
|
||||||
const initialData: Record<string, string> = {}
|
const initialData: Record<string, string> = {}
|
||||||
startNodes.forEach((node) => {
|
startNodes.forEach((node) => {
|
||||||
if (node.type === 'text') {
|
if (node.type === 'text') {
|
||||||
// Get default value from data.text or output.texts[0]
|
// Get default value from data.text or output.texts[0]
|
||||||
const texts = node.data?.output?.texts
|
const texts = node.data?.output?.texts
|
||||||
initialData[node.id] = texts?.[0] || node.data?.text || ''
|
initialData[node.id] = texts?.[0] || node.data?.text || ''
|
||||||
} else if (node.type === 'select') {
|
} else if (node.type === 'select') {
|
||||||
// Get default value from output.selections
|
// Get default value from output.selections
|
||||||
initialData[node.id] = node.data?.output?.selections || ''
|
initialData[node.id] = node.data?.output?.selections || ''
|
||||||
} else if (node.type === 'image') {
|
} else if (node.type === 'image') {
|
||||||
// Get default value from output.images
|
// Get default value from output.images
|
||||||
const images = node.data?.output?.images
|
const images = node.data?.output?.images
|
||||||
initialData[node.id] = images?.[0]?.url || ''
|
initialData[node.id] = images?.[0]?.url || ''
|
||||||
} else if (node.type === 'video') {
|
} else if (node.type === 'video') {
|
||||||
// Get default value from output.videos
|
// Get default value from output.videos
|
||||||
const videos = node.data?.output?.videos
|
const videos = node.data?.output?.videos
|
||||||
initialData[node.id] = videos?.[0]?.url || ''
|
initialData[node.id] = videos?.[0]?.url || ''
|
||||||
} else {
|
} else {
|
||||||
initialData[node.id] = ''
|
initialData[node.id] = ''
|
||||||
}
|
|
||||||
})
|
|
||||||
return initialData
|
|
||||||
})
|
|
||||||
|
|
||||||
const [drawerVisible, setDrawerVisible] = useState(false)
|
|
||||||
const [currentNodeId, setCurrentNodeId] = useState<string | null>(null)
|
|
||||||
const [previewImages, setPreviewImages] = useState<Record<string, string>>(() => {
|
|
||||||
const initialPreviews: Record<string, string> = {}
|
|
||||||
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
|
return initialData
|
||||||
if (videos?.[0]?.url) {
|
})
|
||||||
initialPreviews[node.id] = videos[0].url
|
|
||||||
|
const [drawerVisible, setDrawerVisible] = useState(false)
|
||||||
|
const [currentNodeId, setCurrentNodeId] = useState<string | null>(null)
|
||||||
|
const [previewImages, setPreviewImages] = useState<Record<string, string>>(() => {
|
||||||
|
const initialPreviews: Record<string, string> = {}
|
||||||
|
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<Record<string, string>>({})
|
||||||
})
|
|
||||||
const [errors, setErrors] = useState<Record<string, string>>({})
|
|
||||||
|
|
||||||
const updateFormData = useCallback((nodeId: string, value: string) => {
|
const updateFormData = useCallback((nodeId: string, value: string) => {
|
||||||
setFormData((prev) => ({ ...prev, [nodeId]: value }))
|
setFormData((prev) => ({ ...prev, [nodeId]: value }))
|
||||||
// Clear error for this field when user updates it
|
// Clear error for this field when user updates it
|
||||||
if (errors[nodeId]) {
|
if (errors[nodeId]) {
|
||||||
setErrors((prev) => {
|
setErrors((prev) => {
|
||||||
const newErrors = { ...prev }
|
const newErrors = { ...prev }
|
||||||
delete newErrors[nodeId]
|
delete newErrors[nodeId]
|
||||||
return newErrors
|
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<string, string> = {}
|
|
||||||
|
|
||||||
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) {
|
}, [errors])
|
||||||
Toast.hideLoading()
|
|
||||||
Toast.show({
|
// 暴露方法给父组件
|
||||||
title: t('dynamicForm.submitFailed') || '提交失败'
|
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<string, string> = {}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View key={node.id} style={styles.fieldContainer}>
|
||||||
|
{node.data?.label && (
|
||||||
|
<Text style={styles.label}>
|
||||||
|
{node.data.label}
|
||||||
|
<Text style={styles.required}> *</Text>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<TextInput
|
||||||
|
testID={`text-input-${node.id}`}
|
||||||
|
style={[styles.textInput, error && styles.textInputError]}
|
||||||
|
value={value}
|
||||||
|
onChangeText={(text) => updateFormData(node.id, text)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
placeholderTextColor="#8A8A8A"
|
||||||
|
multiline
|
||||||
|
numberOfLines={4}
|
||||||
|
textAlignVertical="top"
|
||||||
|
/>
|
||||||
|
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}, [formData, onSubmit, validateForm, t])
|
|
||||||
|
|
||||||
const renderTextField = (node: StartNode) => {
|
const renderImageField = (node: StartNode) => {
|
||||||
const value = formData[node.id] || ''
|
const value = formData[node.id]
|
||||||
const error = errors[node.id]
|
const previewUri = previewImages[node.id]
|
||||||
const placeholder = node.data?.description || t('dynamicForm.enterText') || '请输入文本'
|
const error = errors[node.id]
|
||||||
|
const label = node.data?.label || t('dynamicForm.image') || '图片'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View key={node.id} style={styles.fieldContainer}>
|
<View key={node.id} style={styles.fieldContainer}>
|
||||||
{node.data?.label && (
|
|
||||||
<Text style={styles.label}>
|
<Text style={styles.label}>
|
||||||
{node.data.label}
|
{label}
|
||||||
<Text style={styles.required}> *</Text>
|
<Text style={styles.required}> *</Text>
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
<Pressable
|
||||||
<TextInput
|
style={styles.uploadButton}
|
||||||
testID={`text-input-${node.id}`}
|
onPress={() => handlePickAndUploadImage(node.id)}
|
||||||
style={[styles.textInput, error && styles.textInputError]}
|
>
|
||||||
value={value}
|
{previewUri ? (
|
||||||
onChangeText={(text) => updateFormData(node.id, text)}
|
<Image
|
||||||
placeholder={placeholder}
|
source={previewUri}
|
||||||
placeholderTextColor="#8A8A8A"
|
style={styles.previewImage}
|
||||||
multiline
|
contentFit="cover"
|
||||||
numberOfLines={4}
|
/>
|
||||||
textAlignVertical="top"
|
) : (
|
||||||
/>
|
<View style={styles.uploadPlaceholder}>
|
||||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
<Text style={styles.uploadText}>
|
||||||
</View>
|
{t('dynamicForm.uploadImage') || '点击上传图片'}
|
||||||
)
|
</Text>
|
||||||
}
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const renderImageField = (node: StartNode) => {
|
const renderVideoField = (node: StartNode) => {
|
||||||
const value = formData[node.id]
|
const value = formData[node.id]
|
||||||
const previewUri = previewImages[node.id]
|
const previewUri = previewImages[node.id]
|
||||||
const error = errors[node.id]
|
const error = errors[node.id]
|
||||||
const label = node.data?.label || t('dynamicForm.image') || '图片'
|
const label = node.data?.label || t('dynamicForm.video') || '视频'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View key={node.id} style={styles.fieldContainer}>
|
<View key={node.id} style={styles.fieldContainer}>
|
||||||
<Text style={styles.label}>
|
<Text style={styles.label}>
|
||||||
{label}
|
{label}
|
||||||
<Text style={styles.required}> *</Text>
|
<Text style={styles.required}> *</Text>
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.uploadButton}
|
style={styles.uploadButton}
|
||||||
onPress={() => handlePickAndUploadImage(node.id)}
|
onPress={() => {
|
||||||
>
|
setCurrentNodeId(node.id)
|
||||||
{previewUri ? (
|
if (onOpenDrawer) {
|
||||||
<Image
|
onOpenDrawer(node.id)
|
||||||
source={previewUri}
|
} else {
|
||||||
style={styles.previewImage}
|
setDrawerVisible(true)
|
||||||
contentFit="cover"
|
}
|
||||||
/>
|
}}
|
||||||
) : (
|
>
|
||||||
<View style={styles.uploadPlaceholder}>
|
{previewUri ? (
|
||||||
<Text style={styles.uploadText}>
|
<View style={styles.videoPreview}>
|
||||||
{t('dynamicForm.uploadImage') || '点击上传图片'}
|
<Text style={styles.uploadText}>
|
||||||
</Text>
|
{t('dynamicForm.videoUploaded') || '视频已上传'}
|
||||||
</View>
|
</Text>
|
||||||
)}
|
</View>
|
||||||
</Pressable>
|
) : (
|
||||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
<View style={styles.uploadPlaceholder}>
|
||||||
</View>
|
<Text style={styles.uploadText}>
|
||||||
)
|
{t('dynamicForm.uploadVideo') || '点击上传视频'}
|
||||||
}
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const renderVideoField = (node: StartNode) => {
|
const renderSelectField = (node: StartNode) => {
|
||||||
const value = formData[node.id]
|
const value = formData[node.id]
|
||||||
const previewUri = previewImages[node.id]
|
const error = errors[node.id]
|
||||||
const error = errors[node.id]
|
const label = node.data?.label || t('dynamicForm.select') || '选择'
|
||||||
const label = node.data?.label || t('dynamicForm.video') || '视频'
|
const options = node.data?.actionData?.options || []
|
||||||
|
const placeholder = node.data?.actionData?.placeholder || t('dynamicForm.selectPlaceholder') || '请选择'
|
||||||
|
const allowMultiple = node.data?.actionData?.allowMultiple || false
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View key={node.id} style={styles.fieldContainer}>
|
<View key={node.id} style={styles.fieldContainer}>
|
||||||
<Text style={styles.label}>
|
<Text style={styles.label}>
|
||||||
{label}
|
{label}
|
||||||
<Text style={styles.required}> *</Text>
|
<Text style={styles.required}> *</Text>
|
||||||
</Text>
|
</Text>
|
||||||
<Pressable
|
{allowMultiple ? (
|
||||||
style={styles.uploadButton}
|
// Multi-select: render as a list of checkboxes
|
||||||
onPress={() => {
|
<View style={styles.optionsContainer}>
|
||||||
setCurrentNodeId(node.id)
|
{options.map((option, index) => {
|
||||||
if (onOpenDrawer) {
|
const isSelected = value === option.value
|
||||||
onOpenDrawer(node.id)
|
return (
|
||||||
} else {
|
<Pressable
|
||||||
setDrawerVisible(true)
|
key={index}
|
||||||
}
|
style={[styles.optionButton, isSelected && styles.optionButtonSelected]}
|
||||||
}}
|
onPress={() => updateFormData(node.id, option.value)}
|
||||||
>
|
>
|
||||||
{previewUri ? (
|
|
||||||
<View style={styles.videoPreview}>
|
|
||||||
<Text style={styles.uploadText}>
|
|
||||||
{t('dynamicForm.videoUploaded') || '视频已上传'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
<View style={styles.uploadPlaceholder}>
|
|
||||||
<Text style={styles.uploadText}>
|
|
||||||
{t('dynamicForm.uploadVideo') || '点击上传视频'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<View key={node.id} style={styles.fieldContainer}>
|
|
||||||
<Text style={styles.label}>
|
|
||||||
{label}
|
|
||||||
<Text style={styles.required}> *</Text>
|
|
||||||
</Text>
|
|
||||||
{allowMultiple ? (
|
|
||||||
// Multi-select: render as a list of checkboxes
|
|
||||||
<View style={styles.optionsContainer}>
|
|
||||||
{options.map((option, index) => {
|
|
||||||
const isSelected = value === option.value
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={index}
|
|
||||||
style={[styles.optionButton, isSelected && styles.optionButtonSelected]}
|
|
||||||
onPress={() => updateFormData(node.id, option.value)}
|
|
||||||
>
|
|
||||||
<Text style={[styles.optionText, isSelected && styles.optionTextSelected]}>
|
|
||||||
{option.label}
|
|
||||||
</Text>
|
|
||||||
</Pressable>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</View>
|
|
||||||
) : (
|
|
||||||
// Single select: render as a list of radio buttons
|
|
||||||
<View style={styles.optionsContainer}>
|
|
||||||
{options.map((option, index) => {
|
|
||||||
const isSelected = value === option.value
|
|
||||||
return (
|
|
||||||
<Pressable
|
|
||||||
key={index}
|
|
||||||
style={[styles.optionButton, isSelected && styles.optionButtonSelected]}
|
|
||||||
onPress={() => updateFormData(node.id, option.value)}
|
|
||||||
>
|
|
||||||
<View style={styles.radioContainer}>
|
|
||||||
<View style={[styles.radioCircle, isSelected && styles.radioCircleSelected]}>
|
|
||||||
{isSelected && <View style={styles.radioDot} />}
|
|
||||||
</View>
|
|
||||||
<Text style={[styles.optionText, isSelected && styles.optionTextSelected]}>
|
<Text style={[styles.optionText, isSelected && styles.optionTextSelected]}>
|
||||||
{option.label}
|
{option.label}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</Pressable>
|
||||||
</Pressable>
|
)
|
||||||
)
|
})}
|
||||||
})}
|
</View>
|
||||||
</View>
|
) : (
|
||||||
)}
|
// Single select: render as a list of radio buttons
|
||||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
<View style={styles.optionsContainer}>
|
||||||
</View>
|
{options.map((option, index) => {
|
||||||
)
|
const isSelected = value === option.value
|
||||||
}
|
return (
|
||||||
|
<Pressable
|
||||||
const renderField = (node: StartNode) => {
|
key={index}
|
||||||
switch (node.type) {
|
style={[styles.optionButton, isSelected && styles.optionButtonSelected]}
|
||||||
case 'text':
|
onPress={() => updateFormData(node.id, option.value)}
|
||||||
return renderTextField(node)
|
>
|
||||||
case 'image':
|
<View style={styles.radioContainer}>
|
||||||
return renderImageField(node)
|
<View style={[styles.radioCircle, isSelected && styles.radioCircleSelected]}>
|
||||||
case 'video':
|
{isSelected && <View style={styles.radioDot} />}
|
||||||
return renderVideoField(node)
|
</View>
|
||||||
case 'select':
|
<Text style={[styles.optionText, isSelected && styles.optionTextSelected]}>
|
||||||
return renderSelectField(node)
|
{option.label}
|
||||||
default:
|
</Text>
|
||||||
return null
|
</View>
|
||||||
}
|
</Pressable>
|
||||||
}
|
)
|
||||||
|
})}
|
||||||
if (startNodes.length === 0) {
|
</View>
|
||||||
return (
|
)}
|
||||||
<View style={styles.emptyContainer}>
|
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||||
<Text style={styles.emptyText}>
|
|
||||||
{t('dynamicForm.noFields') || '暂无可填写的表单项'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<KeyboardAvoidingView
|
|
||||||
style={styles.keyboardAvoidingView}
|
|
||||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
||||||
keyboardVerticalOffset={0}
|
|
||||||
>
|
|
||||||
<ScrollView
|
|
||||||
style={styles.scrollView}
|
|
||||||
contentContainerStyle={styles.scrollContent}
|
|
||||||
showsVerticalScrollIndicator={false}
|
|
||||||
keyboardShouldPersistTaps="handled"
|
|
||||||
>
|
|
||||||
{startNodes.map(renderField)}
|
|
||||||
|
|
||||||
<View style={styles.submitButtonContainer}>
|
|
||||||
<Button
|
|
||||||
testID="submit-button"
|
|
||||||
variant="gradient"
|
|
||||||
onPress={handleSubmit}
|
|
||||||
disabled={loading}
|
|
||||||
style={styles.submitButton}
|
|
||||||
className="px-0"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<ActivityIndicator color="#fff" />
|
|
||||||
) : (
|
|
||||||
<View style={styles.submitButtonContent}>
|
|
||||||
<Text style={styles.submitButtonText}>
|
|
||||||
{t('dynamicForm.submit') || '提交'}
|
|
||||||
</Text>
|
|
||||||
{points !== undefined && points > 0 && (
|
|
||||||
<View style={styles.pointsContainer}>
|
|
||||||
<Text style={styles.pointsText}>{points}</Text>
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
)
|
||||||
</KeyboardAvoidingView>
|
}
|
||||||
)
|
|
||||||
|
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 (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
{t('dynamicForm.noFields') || '暂无可填写的表单项'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
style={styles.keyboardAvoidingView}
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||||
|
keyboardVerticalOffset={0}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.scrollView}
|
||||||
|
contentContainerStyle={styles.scrollContent}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
{startNodes.map(renderField)}
|
||||||
|
|
||||||
|
<View style={styles.submitButtonContainer}>
|
||||||
|
<Button
|
||||||
|
testID="submit-button"
|
||||||
|
variant="gradient"
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={loading}
|
||||||
|
style={styles.submitButton}
|
||||||
|
className="px-0"
|
||||||
|
>
|
||||||
|
{loading ? (
|
||||||
|
<ActivityIndicator color="#fff" />
|
||||||
|
) : (
|
||||||
|
<View style={styles.submitButtonContent}>
|
||||||
|
<Text style={styles.submitButtonText}>
|
||||||
|
{t('dynamicForm.submit') || '提交'}
|
||||||
|
</Text>
|
||||||
|
{points !== undefined && points > 0 && (
|
||||||
|
<View style={styles.pointsContainer}>
|
||||||
|
<Text style={styles.pointsText}>{points}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import React, { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { authClient, setAuthToken, useSession } from "../../lib/auth";
|
||||||
import type { ApiError } from "../../lib/types";
|
import type { ApiError } from "../../lib/types";
|
||||||
import { Block } from "../ui";
|
import { Block } from "../ui";
|
||||||
import { Button } from "../ui/button";
|
import { Button } from "../ui/button";
|
||||||
import Text from "../ui/Text";
|
import Text from "../ui/Text";
|
||||||
|
import { storage } from "../../lib/storage";
|
||||||
|
|
||||||
type AuthMode = "login" | "register";
|
type AuthMode = "login" | "register";
|
||||||
|
|
||||||
@@ -26,9 +27,23 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [rememberPassword, setRememberPassword] = useState(false);
|
||||||
|
|
||||||
const isLogin = currentMode === "login";
|
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 => {
|
const getErrorMessage = (error: ApiError): string => {
|
||||||
if (error.code) {
|
if (error.code) {
|
||||||
@@ -77,7 +92,13 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 登录成功
|
// 登录成功后保存凭证
|
||||||
|
if (rememberPassword) {
|
||||||
|
await storage.setItem("saved_credentials", JSON.stringify({ username, password }));
|
||||||
|
} else {
|
||||||
|
await storage.removeItem("saved_credentials");
|
||||||
|
}
|
||||||
|
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
} else {
|
} else {
|
||||||
const result = await signUp.email({ email, password, name: username }, {
|
const result = await signUp.email({ email, password, name: username }, {
|
||||||
@@ -157,6 +178,21 @@ export function AuthForm({ mode = "login", onSuccess, onModeChange }: AuthFormPr
|
|||||||
secureTextEntry
|
secureTextEntry
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isLogin && (
|
||||||
|
<Pressable
|
||||||
|
onPress={() => setRememberPassword(!rememberPassword)}
|
||||||
|
className="flex-row items-center mb-4"
|
||||||
|
>
|
||||||
|
<Block
|
||||||
|
className="w-5 h-5 rounded border-2 border-white/30 mr-2 items-center justify-center"
|
||||||
|
style={{ backgroundColor: rememberPassword ? '#ffffff' : 'transparent' }}
|
||||||
|
>
|
||||||
|
{rememberPassword && <Text style={{ color: '#000', fontSize: 12 }}>✓</Text>}
|
||||||
|
</Block>
|
||||||
|
<Text className="text-white/70 text-sm">{t("authForm.rememberPassword")}</Text>
|
||||||
|
</Pressable>
|
||||||
|
)}
|
||||||
|
|
||||||
{error ? (
|
{error ? (
|
||||||
<Text style={{ color: '#ef4444' }} className="text-sm mb-4 text-center">
|
<Text style={{ color: '#ef4444' }} className="text-sm mb-4 text-center">
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
17
components/blocks/__tests__/AuthForm.simple.test.tsx
Normal file
17
components/blocks/__tests__/AuthForm.simple.test.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { Text, View } from 'react-native'
|
||||||
|
import { render } from '@testing-library/react-native'
|
||||||
|
|
||||||
|
// 最简单的测试组件
|
||||||
|
const SimpleComponent = () => (
|
||||||
|
<View>
|
||||||
|
<Text>Hello Test</Text>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('Simple Test', () => {
|
||||||
|
test('renders simple component', () => {
|
||||||
|
const { getByText } = render(<SimpleComponent />)
|
||||||
|
expect(getByText('Hello Test')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
92
components/blocks/__tests__/AuthForm.test.tsx
Normal file
92
components/blocks/__tests__/AuthForm.test.tsx
Normal file
@@ -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(<AuthForm />)
|
||||||
|
expect(getByText('authForm.rememberPassword')).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
113
jest.setup.js
113
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
|
// Mock react-native BEFORE any other imports to avoid flow type issues
|
||||||
jest.mock('react-native', () => {
|
jest.mock('react-native', () => {
|
||||||
const React = require('react')
|
const React = require('react')
|
||||||
@@ -11,7 +16,11 @@ jest.mock('react-native', () => {
|
|||||||
Pressable: 'Pressable',
|
Pressable: 'Pressable',
|
||||||
TouchableOpacity: 'TouchableOpacity',
|
TouchableOpacity: 'TouchableOpacity',
|
||||||
TextInput: 'TextInput',
|
TextInput: 'TextInput',
|
||||||
Platform: { OS: 'web' },
|
ActivityIndicator: 'ActivityIndicator',
|
||||||
|
Platform: {
|
||||||
|
OS: 'web',
|
||||||
|
select: (obj) => obj.web || obj.default,
|
||||||
|
},
|
||||||
Animated: {
|
Animated: {
|
||||||
View: 'Animated.View',
|
View: 'Animated.View',
|
||||||
Text: 'Animated.Text',
|
Text: 'Animated.Text',
|
||||||
@@ -49,11 +58,27 @@ jest.mock('react-native', () => {
|
|||||||
Linking: {
|
Linking: {
|
||||||
openURL: jest.fn(),
|
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')
|
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
|
// Initialize globals for react-native-reanimated
|
||||||
global._tagToJSPropNamesMapping = {}
|
global._tagToJSPropNamesMapping = {}
|
||||||
global._WORKLET = false
|
global._WORKLET = false
|
||||||
@@ -95,6 +120,18 @@ jest.mock('expo-linear-gradient', () => ({
|
|||||||
LinearGradient: 'LinearGradient',
|
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
|
// Mock react-native-gesture-handler
|
||||||
jest.mock('react-native-gesture-handler', () => {
|
jest.mock('react-native-gesture-handler', () => {
|
||||||
const { View } = require('react-native')
|
const { View } = require('react-native')
|
||||||
@@ -142,11 +179,34 @@ jest.mock('expo-splash-screen', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
// Mock react-native-reanimated
|
// Mock react-native-reanimated
|
||||||
jest.mock('react-native-reanimated', () => {
|
jest.mock('react-native-reanimated', () => ({
|
||||||
const Reanimated = require('react-native-reanimated/mock')
|
__esModule: true,
|
||||||
Reanimated.default.call = () => {}
|
default: {
|
||||||
return Reanimated
|
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
|
// Mock nativewind
|
||||||
jest.mock('nativewind', () => ({
|
jest.mock('nativewind', () => ({
|
||||||
@@ -225,8 +285,49 @@ jest.mock('@/components/icon', () => ({
|
|||||||
LeftArrowIcon: () => null,
|
LeftArrowIcon: () => null,
|
||||||
UploadIcon: () => null,
|
UploadIcon: () => null,
|
||||||
WhitePointsIcon: () => 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', () => ({
|
jest.mock('@/components/skeleton/HomeSkeleton', () => ({
|
||||||
HomeSkeleton: () => null,
|
HomeSkeleton: () => null,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ export const createFetchWithLogger = (options: FetchLoggerOptions = {}) => {
|
|||||||
headers['authorization'] = `Bearer ${token}`
|
headers['authorization'] = `Bearer ${token}`
|
||||||
}
|
}
|
||||||
|
|
||||||
// 确保 Content-Type 正确设置
|
// 确保 Content-Type 正确设置(FormData 不需要手动设置,让浏览器自动处理 boundary)
|
||||||
if (method !== 'GET' && method !== 'HEAD' && !headers['Content-Type']) {
|
if (method !== 'GET' && method !== 'HEAD' && !headers['Content-Type'] && !(init?.body instanceof FormData)) {
|
||||||
headers['Content-Type'] = 'application/json'
|
headers['Content-Type'] = 'application/json'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,22 +5,27 @@ import { Platform } from 'react-native'
|
|||||||
import { handleError } from '@/hooks/use-error'
|
import { handleError } from '@/hooks/use-error'
|
||||||
|
|
||||||
export async function uploadFile(params: { uri: string; mimeType?: string; fileName?: string }): Promise<string> {
|
export async function uploadFile(params: { uri: string; mimeType?: string; fileName?: string }): Promise<string> {
|
||||||
const { uri, mimeType, fileName } = params
|
const { uri, mimeType, fileName } = params || {}
|
||||||
|
|
||||||
console.log('[uploadFile] 开始上传:', { uri, mimeType, fileName, platform: Platform.OS })
|
console.log('[uploadFile] 开始上传:', { uri, mimeType, fileName, platform: Platform.OS })
|
||||||
|
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('file', {
|
|
||||||
uri: uri,
|
// React Native 中 FormData 需要使用特定格式
|
||||||
name: fileName || 'uploaded_file.jpg',
|
const fileObject = {
|
||||||
|
uri: Platform.OS === 'android' ? uri : uri.replace('file://', ''),
|
||||||
type: mimeType || 'image/jpeg',
|
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 fileController = root.get(FileController)
|
||||||
const { data, error } = await handleError(async () => await fileController.uploadS3(formData))
|
const { data, error } = await handleError(async () => await fileController.uploadS3(formData))
|
||||||
|
|
||||||
if (error || !data?.data) {
|
if (error || !data?.data) {
|
||||||
console.error('[uploadFile] 上传失败:', { error, data, uri })
|
console.error('[uploadFile] 上传失败:', { error, data, uri, formData })
|
||||||
throw error || new Error('上传失败')
|
throw error || new Error('上传失败')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -221,6 +221,7 @@
|
|||||||
"username": "Username",
|
"username": "Username",
|
||||||
"email": "Email",
|
"email": "Email",
|
||||||
"password": "Password",
|
"password": "Password",
|
||||||
|
"rememberPassword": "Remember Password",
|
||||||
"fillCompleteInfo": "Please fill in all information",
|
"fillCompleteInfo": "Please fill in all information",
|
||||||
"fillEmail": "Please fill in email",
|
"fillEmail": "Please fill in email",
|
||||||
"loginFailed": "Login failed",
|
"loginFailed": "Login failed",
|
||||||
|
|||||||
@@ -221,6 +221,7 @@
|
|||||||
"username": "用户名",
|
"username": "用户名",
|
||||||
"email": "邮箱",
|
"email": "邮箱",
|
||||||
"password": "密码",
|
"password": "密码",
|
||||||
|
"rememberPassword": "记住密码",
|
||||||
"fillCompleteInfo": "请填写完整信息",
|
"fillCompleteInfo": "请填写完整信息",
|
||||||
"fillEmail": "请填写邮箱",
|
"fillEmail": "请填写邮箱",
|
||||||
"loginFailed": "登录失败",
|
"loginFailed": "登录失败",
|
||||||
|
|||||||
Reference in New Issue
Block a user