This commit is contained in:
imeepos
2026-01-28 15:26:00 +08:00
parent 4bf364d955
commit 7d73cfbc3e
12 changed files with 826 additions and 414 deletions

View File

@@ -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'

136
app/membership.test.tsx Normal file
View 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)
})
})

View File

@@ -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() {
<View style={[styles.subscribeContainer, { paddingBottom: Math.max(insets.bottom, 3) }]}>
{/* 立即开通按钮 */}
<Pressable
disabled={!agreed || isLoadingSubscriptions || isStripePricingLoading}
disabled={!agreed || isLoadingSubscriptions || isStripePricingLoading || isSubscribing}
style={styles.subscribeButtonPressable}
onPress={handleSubscribe}
>
@@ -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 ? (
<ActivityIndicator color="#F5F5F5" />
) : (
<Text style={currentPlan.recommended ? styles.subscribeButtonText : styles.subscribeButtonText1}>{t('membership.subscribeNow')}</Text>

View File

@@ -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<DynamicFormRef, DynamicFormProps>(
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<Record<string, string>>(() => {
const initialData: Record<string, string> = {}
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<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
// Initialize form state for each node
const [formData, setFormData] = useState<Record<string, string>>(() => {
const initialData: Record<string, string> = {}
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<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) => {
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<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') || '提交失败'
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<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 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 (
<View key={node.id} style={styles.fieldContainer}>
{node.data?.label && (
return (
<View key={node.id} style={styles.fieldContainer}>
<Text style={styles.label}>
{node.data.label}
{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>
)
}
<Pressable
style={styles.uploadButton}
onPress={() => handlePickAndUploadImage(node.id)}
>
{previewUri ? (
<Image
source={previewUri}
style={styles.previewImage}
contentFit="cover"
/>
) : (
<View style={styles.uploadPlaceholder}>
<Text style={styles.uploadText}>
{t('dynamicForm.uploadImage') || '点击上传图片'}
</Text>
</View>
)}
</Pressable>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
)
}
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 (
<View key={node.id} style={styles.fieldContainer}>
<Text style={styles.label}>
{label}
<Text style={styles.required}> *</Text>
</Text>
<Pressable
style={styles.uploadButton}
onPress={() => handlePickAndUploadImage(node.id)}
>
{previewUri ? (
<Image
source={previewUri}
style={styles.previewImage}
contentFit="cover"
/>
) : (
<View style={styles.uploadPlaceholder}>
<Text style={styles.uploadText}>
{t('dynamicForm.uploadImage') || '点击上传图片'}
</Text>
</View>
)}
</Pressable>
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
)
}
return (
<View key={node.id} style={styles.fieldContainer}>
<Text style={styles.label}>
{label}
<Text style={styles.required}> *</Text>
</Text>
<Pressable
style={styles.uploadButton}
onPress={() => {
setCurrentNodeId(node.id)
if (onOpenDrawer) {
onOpenDrawer(node.id)
} else {
setDrawerVisible(true)
}
}}
>
{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 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 (
<View key={node.id} style={styles.fieldContainer}>
<Text style={styles.label}>
{label}
<Text style={styles.required}> *</Text>
</Text>
<Pressable
style={styles.uploadButton}
onPress={() => {
setCurrentNodeId(node.id)
if (onOpenDrawer) {
onOpenDrawer(node.id)
} else {
setDrawerVisible(true)
}
}}
>
{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>
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>
</View>
</Pressable>
)
})}
</View>
)}
{error && <Text style={styles.errorText}>{error}</Text>}
</View>
)
}
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>
</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]}>
{option.label}
</Text>
</View>
</Pressable>
)
})}
</View>
)}
{error && <Text style={styles.errorText}>{error}</Text>}
</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>
)
}
)

View File

@@ -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 && (
<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 ? (
<Text style={{ color: '#ef4444' }} className="text-sm mb-4 text-center">
{error}

View 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()
})
})

View 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()
})
})

View File

@@ -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,
}))

View File

@@ -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'
}

View File

@@ -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<string> {
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('上传失败')
}

View File

@@ -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",

View File

@@ -221,6 +221,7 @@
"username": "用户名",
"email": "邮箱",
"password": "密码",
"rememberPassword": "记住密码",
"fillCompleteInfo": "请填写完整信息",
"fillEmail": "请填写邮箱",
"loginFailed": "登录失败",