This commit is contained in:
imeepos
2026-01-28 15:57:40 +08:00
parent 7d73cfbc3e
commit efd4aba8c1
12 changed files with 676 additions and 262 deletions

View File

@@ -35,6 +35,14 @@ jest.mock('@/components/icon', () => ({
jest.mock('@/components/ErrorState', () => 'ErrorState') jest.mock('@/components/ErrorState', () => 'ErrorState')
jest.mock('@/components/LoadingState', () => 'LoadingState') jest.mock('@/components/LoadingState', () => 'LoadingState')
// Mock home blocks
jest.mock('@/components/blocks/home', () => ({
TitleBar: 'TitleBar',
HeroSlider: 'HeroSlider',
TabNavigation: 'TabNavigation',
TemplateCard: 'TemplateCard',
}))
// Mock react-native RefreshControl (directly from react-native) // Mock react-native RefreshControl (directly from react-native)
jest.mock('react-native', () => jest.mock('react-native', () =>
Object.assign({}, jest.requireActual('react-native'), { Object.assign({}, jest.requireActual('react-native'), {
@@ -108,6 +116,69 @@ jest.mock('@/hooks/use-user-balance', () => ({
})), })),
})) }))
jest.mock('@/hooks/use-categories-with-tags', () => ({
useCategoriesWithTags: jest.fn(() => ({
load: jest.fn(),
data: {
categories: [
{
id: 'cat1',
name: 'Test Category',
},
],
},
loading: false,
error: null,
})),
}))
jest.mock('@/hooks/use-category-templates', () => ({
useCategoryTemplates: jest.fn(() => ({
templates: [
{
id: 'template1',
title: 'Test Template',
webpPreviewUrl: 'https://example.com/preview.webp',
previewUrl: 'https://example.com/preview.jpg',
coverImageUrl: 'https://example.com/cover.png',
aspectRatio: '1:1',
},
],
loading: false,
loadingMore: false,
execute: jest.fn(),
loadMore: jest.fn(),
hasMore: false,
})),
}))
jest.mock('@/hooks/use-sticky-tabs', () => ({
useStickyTabs: jest.fn(() => ({
isSticky: false,
tabsHeight: 0,
titleBarHeightRef: { current: 0 },
handleScroll: jest.fn(),
handleTabsLayout: jest.fn(),
handleTitleBarLayout: jest.fn(),
})),
}))
jest.mock('@/hooks/use-tab-navigation', () => ({
useTabNavigation: jest.fn(() => ({
activeIndex: 0,
selectedCategoryId: 'cat1',
tabs: [{ id: 'cat1', name: 'Test Category' }],
selectTab: jest.fn(),
selectCategoryById: jest.fn(),
})),
}))
jest.mock('@/hooks/use-template-filter', () => ({
useTemplateFilter: jest.fn(({ templates }) => ({
filteredTemplates: templates,
})),
}))
const renderWithProviders = (component: React.ReactElement) => { const renderWithProviders = (component: React.ReactElement) => {
return render(component) return render(component)
} }
@@ -152,4 +223,132 @@ describe('HomeScreen', () => {
expect(getByText('1234')).toBeTruthy() expect(getByText('1234')).toBeTruthy()
}) })
}) })
describe('Loading States', () => {
it('should show only one loading indicator when categories are loading', async () => {
const { useCategoriesWithTags } = require('@/hooks/use-categories-with-tags')
const { useCategoryTemplates } = require('@/hooks/use-category-templates')
useCategoriesWithTags.mockReturnValue({
load: jest.fn(),
data: null,
loading: true,
error: null,
})
useCategoryTemplates.mockReturnValue({
templates: [],
loading: false,
loadingMore: false,
execute: jest.fn(),
loadMore: jest.fn(),
hasMore: false,
})
const { UNSAFE_getAllByType } = renderWithProviders(<HomeScreen />)
await waitFor(() => {
// 应该只有一个 LoadingState 组件
const loadingStates = UNSAFE_getAllByType('LoadingState' as any)
expect(loadingStates.length).toBe(1)
})
})
it('should show only one loading indicator when templates are loading', async () => {
const { useCategoriesWithTags } = require('@/hooks/use-categories-with-tags')
const { useCategoryTemplates } = require('@/hooks/use-category-templates')
useCategoriesWithTags.mockReturnValue({
load: jest.fn(),
data: {
categories: [{ id: 'cat1', name: 'Test Category' }],
},
loading: false,
error: null,
})
useCategoryTemplates.mockReturnValue({
templates: [],
loading: true,
loadingMore: false,
execute: jest.fn(),
loadMore: jest.fn(),
hasMore: false,
})
const { UNSAFE_queryAllByType } = renderWithProviders(<HomeScreen />)
await waitFor(() => {
// 应该只有一个 ActivityIndicator
const activityIndicators = UNSAFE_queryAllByType('ActivityIndicator' as any)
expect(activityIndicators.length).toBeLessThanOrEqual(1)
})
})
it('should NOT show multiple loading indicators simultaneously', async () => {
const { useCategoriesWithTags } = require('@/hooks/use-categories-with-tags')
const { useCategoryTemplates } = require('@/hooks/use-category-templates')
// 模拟两个都在加载的情况
useCategoriesWithTags.mockReturnValue({
load: jest.fn(),
data: null,
loading: true,
error: null,
})
useCategoryTemplates.mockReturnValue({
templates: [],
loading: true,
loadingMore: false,
execute: jest.fn(),
loadMore: jest.fn(),
hasMore: false,
})
const { UNSAFE_queryAllByType } = renderWithProviders(<HomeScreen />)
await waitFor(() => {
// 即使两个都在加载,也应该只显示一个 loading
const loadingStates = UNSAFE_queryAllByType('LoadingState' as any)
const activityIndicators = UNSAFE_queryAllByType('ActivityIndicator' as any)
// 总共的 loading 指示器不应该超过 1 个
const totalLoadingIndicators = loadingStates.length + activityIndicators.length
expect(totalLoadingIndicators).toBeLessThanOrEqual(1)
})
})
it('should show unified loading state during initial load', async () => {
const { useCategoriesWithTags } = require('@/hooks/use-categories-with-tags')
const { useCategoryTemplates } = require('@/hooks/use-category-templates')
useCategoriesWithTags.mockReturnValue({
load: jest.fn(),
data: null,
loading: true,
error: null,
})
useCategoryTemplates.mockReturnValue({
templates: [],
loading: true,
loadingMore: false,
execute: jest.fn(),
loadMore: jest.fn(),
hasMore: false,
})
const { UNSAFE_queryAllByType } = renderWithProviders(<HomeScreen />)
await waitFor(() => {
// 应该只显示一个统一的 loading 状态
const allLoadingComponents = [
...UNSAFE_queryAllByType('LoadingState' as any),
...UNSAFE_queryAllByType('ActivityIndicator' as any),
]
expect(allLoadingComponents.length).toBe(1)
})
})
})
}) })

View File

@@ -1,7 +1,24 @@
import React from 'react' import React from 'react'
import { render, waitFor } from '@testing-library/react-native' import { render, waitFor, fireEvent } from '@testing-library/react-native'
import My from '../my' import My from '../my'
// Mock react-native-safe-area-context FIRST
jest.mock('react-native-safe-area-context', () => ({
SafeAreaProvider: ({ children }: any) => children,
SafeAreaView: ({ children }: any) => children,
useSafeAreaInsets: () => ({ top: 0, right: 0, bottom: 0, left: 0 }),
}))
// Mock expo-status-bar
jest.mock('expo-status-bar', () => ({
StatusBar: 'StatusBar',
}))
// Mock expo-image
jest.mock('expo-image', () => ({
Image: 'Image',
}))
jest.mock('expo-router', () => ({ jest.mock('expo-router', () => ({
useRouter: () => ({ useRouter: () => ({
push: jest.fn(), push: jest.fn(),
@@ -36,6 +53,40 @@ jest.mock('@/hooks/use-user-balance', () => ({
}), }),
})) }))
jest.mock('@/components/skeleton/MySkeleton', () => ({
MySkeleton: () => 'MySkeleton',
}))
jest.mock('@/components/icon', () => ({
PointsIcon: () => 'PointsIcon',
SearchIcon: () => 'SearchIcon',
SettingsIcon: () => 'SettingsIcon',
}))
jest.mock('@/components/drawer/EditProfileDrawer', () => ({
__esModule: true,
default: () => 'EditProfileDrawer',
}))
jest.mock('@/components/ui/dropdown', () => ({
__esModule: true,
default: () => 'Dropdown',
}))
jest.mock('@/components/ui/Toast', () => ({
__esModule: true,
default: {
show: jest.fn(),
showLoading: jest.fn(),
hideLoading: jest.fn(),
showActionSheet: jest.fn(),
},
}))
jest.mock('expo-image', () => ({
Image: 'Image',
}))
const mockRefetch = jest.fn() const mockRefetch = jest.fn()
const mockLoadMore = jest.fn() const mockLoadMore = jest.fn()
@@ -65,21 +116,66 @@ describe('My Page - Pagination', () => {
it('should refresh with limit 20 on pull to refresh', async () => { it('should refresh with limit 20 on pull to refresh', async () => {
const { useTemplateGenerations } = require('@/hooks') const { useTemplateGenerations } = require('@/hooks')
const mockOnRefresh = jest.fn() const mockRefetchAsync = jest.fn().mockResolvedValue({ data: [], error: null })
useTemplateGenerations.mockReturnValue({ useTemplateGenerations.mockReturnValue({
generations: [{ id: '1', status: 'completed' }], generations: [{ id: '1', status: 'completed' }],
loading: false, loading: false,
loadingMore: false, loadingMore: false,
refetch: mockRefetch, refetch: mockRefetchAsync,
loadMore: mockLoadMore, loadMore: mockLoadMore,
hasMore: true, hasMore: true,
}) })
render(<My />) const { getByTestId } = render(<My />)
// 模拟下拉刷新
const scrollView = getByTestId('my-scroll-view')
const refreshControl = scrollView.props.refreshControl
// 触发刷新
await refreshControl.props.onRefresh()
await waitFor(() => { await waitFor(() => {
expect(mockRefetch).toHaveBeenCalledWith({ page: 1, limit: 20 }) expect(mockRefetchAsync).toHaveBeenCalledWith({ page: 1, limit: 20 })
})
})
it('should stop showing refreshing indicator after refresh completes', async () => {
const { useTemplateGenerations } = require('@/hooks')
let resolveRefetch: any
const mockRefetchAsync = jest.fn(() => new Promise((resolve) => {
resolveRefetch = resolve
}))
useTemplateGenerations.mockReturnValue({
generations: [{ id: '1', status: 'completed' }],
loading: false,
loadingMore: false,
refetch: mockRefetchAsync,
loadMore: mockLoadMore,
hasMore: true,
})
const { getByTestId } = render(<My />)
const scrollView = getByTestId('my-scroll-view')
const refreshControl = scrollView.props.refreshControl
// 开始刷新
const refreshPromise = refreshControl.props.onRefresh()
// 刷新中应该显示 refreshing
await waitFor(() => {
expect(refreshControl.props.refreshing).toBe(true)
})
// 完成刷新
resolveRefetch({ data: [], error: null })
await refreshPromise
// 刷新完成后应该隐藏 refreshing
await waitFor(() => {
expect(refreshControl.props.refreshing).toBe(false)
}) })
}) })
@@ -99,10 +195,22 @@ describe('My Page - Pagination', () => {
hasMore: true, hasMore: true,
}) })
render(<My />) const { getByTestId } = render(<My />)
const scrollView = getByTestId('my-scroll-view')
// 模拟滚动到底部
const scrollEvent = {
nativeEvent: {
layoutMeasurement: { height: 800 },
contentOffset: { y: 1000 },
contentSize: { height: 1800 }, // 距离底部 < 100px
},
}
fireEvent.scroll(scrollView, scrollEvent)
await waitFor(() => { await waitFor(() => {
expect(mockRefetch).toHaveBeenCalledWith({ page: 1, limit: 20 }) expect(mockLoadMore).toHaveBeenCalled()
}) })
}) })

View File

@@ -107,14 +107,18 @@ export default function HomeScreen() {
}, [params.categoryId]) }, [params.categoryId])
// 状态判断 - 使用 useMemo 缓存 // 状态判断 - 使用 useMemo 缓存
const showLoading = categoriesLoading // 统一 loading 状态:只要有任何一个在加载,就显示 loading
const isLoading = useMemo(() =>
categoriesLoading || templatesLoading,
[categoriesLoading, templatesLoading]
)
const showEmptyState = useMemo(() => const showEmptyState = useMemo(() =>
!categoriesLoading && categoriesData?.categories?.length === 0, !isLoading && categoriesData?.categories?.length === 0,
[categoriesLoading, categoriesData?.categories?.length] [isLoading, categoriesData?.categories?.length]
) )
const showEmptyTemplates = useMemo(() => const showEmptyTemplates = useMemo(() =>
!categoriesLoading && !templatesLoading && !showEmptyState && filteredTemplates.length === 0, !isLoading && !showEmptyState && filteredTemplates.length === 0,
[categoriesLoading, templatesLoading, showEmptyState, filteredTemplates.length] [isLoading, showEmptyState, filteredTemplates.length]
) )
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
@@ -129,10 +133,10 @@ export default function HomeScreen() {
// 加载更多处理 // 加载更多处理
const handleEndReached = useCallback(() => { const handleEndReached = useCallback(() => {
if (!loadingMore && hasMore && !templatesLoading) { if (!loadingMore && hasMore && !isLoading) {
loadMore() loadMore()
} }
}, [loadingMore, hasMore, templatesLoading, loadMore]) }, [loadingMore, hasMore, isLoading, loadMore])
// 导航处理 - 使用 useCallback memoize // 导航处理 - 使用 useCallback memoize
const handlePointsPress = useCallback(() => router.push('/membership' as any), [router]) const handlePointsPress = useCallback(() => router.push('/membership' as any), [router])
@@ -178,7 +182,7 @@ export default function HomeScreen() {
/> />
{/* 标签导航 */} {/* 标签导航 */}
{!showLoading && !showEmptyState && ( {!isLoading && !showEmptyState && (
<View <View
onLayout={(e) => handleTabsLayout(e.nativeEvent.layout.y, e.nativeEvent.layout.height)} onLayout={(e) => handleTabsLayout(e.nativeEvent.layout.y, e.nativeEvent.layout.height)}
style={isSticky ? { opacity: 0, height: tabsHeight } : undefined} style={isSticky ? { opacity: 0, height: tabsHeight } : undefined}
@@ -193,8 +197,8 @@ export default function HomeScreen() {
</View> </View>
)} )}
{/* 加载状态 */} {/* 统一的加载状态 */}
{showLoading && <LoadingState />} {isLoading && <LoadingState />}
{/* 错误状态 */} {/* 错误状态 */}
{categoriesError && ( {categoriesError && (
@@ -210,18 +214,11 @@ export default function HomeScreen() {
{showEmptyTemplates && ( {showEmptyTemplates && (
<ErrorState message="该分类暂无模板" onRetry={() => loadTemplates({ categoryId: selectedCategoryId! })} /> <ErrorState message="该分类暂无模板" onRetry={() => loadTemplates({ categoryId: selectedCategoryId! })} />
)} )}
{/* 模板加载中 */}
{templatesLoading && !showLoading && (
<View style={styles.templatesLoadingContainer}>
<ActivityIndicator size="large" color="#FFFFFF" />
</View>
)}
</> </>
), [ ), [
activatesData?.activities, activatesData?.activities,
handleActivityPress, handleActivityPress,
showLoading, isLoading,
showEmptyState, showEmptyState,
isSticky, isSticky,
tabsHeight, tabsHeight,
@@ -231,7 +228,6 @@ export default function HomeScreen() {
handleArrowPress, handleArrowPress,
categoriesError, categoriesError,
showEmptyTemplates, showEmptyTemplates,
templatesLoading,
selectedCategoryId, selectedCategoryId,
handleTabsLayout, handleTabsLayout,
loadCategories, loadCategories,
@@ -257,7 +253,7 @@ export default function HomeScreen() {
) )
// 只有在非加载状态且有数据时才显示列表 // 只有在非加载状态且有数据时才显示列表
const showTemplateList = !showLoading && !showEmptyState && !showEmptyTemplates && !templatesLoading const showTemplateList = !isLoading && !showEmptyState && !showEmptyTemplates
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top']}>
@@ -273,7 +269,7 @@ export default function HomeScreen() {
/> />
{/* 吸顶标签导航 */} {/* 吸顶标签导航 */}
{isSticky && !showLoading && !showEmptyState && ( {isSticky && !isLoading && !showEmptyState && (
<View style={[styles.stickyTabsWrapper, { top: titleBarHeightRef.current + insets.top }]}> <View style={[styles.stickyTabsWrapper, { top: titleBarHeightRef.current + insets.top }]}>
<TabNavigation <TabNavigation
tabs={tabs} tabs={tabs}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import { import {
View, View,
Text, Text,
@@ -68,6 +68,16 @@ export default function My() {
hasMore, hasMore,
} = useTemplateGenerations() } = useTemplateGenerations()
// 调试日志
useEffect(() => {
console.log('📊 作品列表状态:', {
总数: generations.length,
加载中: loading,
加载更多中: loadingMore,
还有更多: hasMore
})
}, [generations.length, loading, loadingMore, hasMore])
// 初始化加载作品列表 // 初始化加载作品列表
useEffect(() => { useEffect(() => {
refetch({ page: 1, limit: 20 }) refetch({ page: 1, limit: 20 })
@@ -77,25 +87,41 @@ export default function My() {
// 下拉刷新状态 // 下拉刷新状态
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
// 防止重复触发加载更多
const isLoadingMoreRef = useRef(false)
// 下拉刷新处理 // 下拉刷新处理
const onRefresh = useCallback(async () => { const onRefresh = useCallback(async () => {
setRefreshing(true) setRefreshing(true)
await refetch({ page: 1, limit: 20 }) try {
setRefreshing(false) await refetch({ page: 1, limit: 20 })
} finally {
setRefreshing(false)
}
}, [refetch]) }, [refetch])
// 加载更多处理 // 加载更多处理
const handleEndReached = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => { const handleScroll = useCallback((event: NativeSyntheticEvent<NativeScrollEvent>) => {
const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent const { layoutMeasurement, contentOffset, contentSize } = event.nativeEvent
const paddingToBottom = 100 // 距离底部100px时触发加载更多 const paddingToBottom = 200 // 距离底部200px时触发加载更多
if (
layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom && const isCloseToBottom =
!loadingMore && layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom
hasMore
) { // 使用 ref 防止重复触发
loadMore() if (isCloseToBottom && !loadingMore && hasMore && !loading && !isLoadingMoreRef.current) {
console.log('🔄 触发加载更多', {
layoutHeight: layoutMeasurement.height,
offsetY: contentOffset.y,
contentHeight: contentSize.height,
distance: contentSize.height - (layoutMeasurement.height + contentOffset.y)
})
isLoadingMoreRef.current = true
loadMore().finally(() => {
isLoadingMoreRef.current = false
})
} }
}, [loadingMore, hasMore, loadMore]) }, [loadingMore, hasMore, loadMore, loading])
// 处理设置菜单选择 // 处理设置菜单选择
const handleSettingsSelect = async (value: string) => { const handleSettingsSelect = async (value: string) => {
@@ -155,7 +181,7 @@ export default function My() {
] ]
return ( return (
<SafeAreaView style={styles.container} edges={['top']}> <SafeAreaView style={styles.container} edges={['top', 'bottom']}>
<StatusBar style="light" /> <StatusBar style="light" />
<RNStatusBar barStyle="light-content" /> <RNStatusBar barStyle="light-content" />
@@ -219,11 +245,12 @@ export default function My() {
<MySkeleton /> <MySkeleton />
) : ( ) : (
<ScrollView <ScrollView
testID="my-scroll-view"
style={styles.scrollView} style={styles.scrollView}
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
onScroll={handleEndReached} onScroll={handleScroll}
scrollEventThrottle={400} scrollEventThrottle={16}
refreshControl={ refreshControl={
<RefreshControl <RefreshControl
refreshing={refreshing} refreshing={refreshing}
@@ -356,6 +383,7 @@ const styles = StyleSheet.create({
scrollContent: { scrollContent: {
backgroundColor: '#090A0B', backgroundColor: '#090A0B',
paddingHorizontal: GALLERY_HORIZONTAL_PADDING, paddingHorizontal: GALLERY_HORIZONTAL_PADDING,
paddingBottom: 20,
}, },
profileSection: { profileSection: {
flexDirection: 'row', flexDirection: 'row',

View File

@@ -59,14 +59,27 @@ interface Plan {
price: number price: number
recommended?: boolean recommended?: boolean
points: number // 每月积分 points: number // 每月积分
features: string[] // 功能列表 currency: string // 货币类型
featureList: string[] // 功能列表(翻译 key
}
// 货币符号转换函数
function toUnit(currency: string): string {
switch (currency.toLowerCase()) {
case 'hkd':
return 'HK$'
case 'cny':
return '¥'
default:
return '$'
}
} }
export default function MembershipScreen() { export default function MembershipScreen() {
const router = useRouter() const router = useRouter()
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const { width: screenWidth } = useWindowDimensions() const { width: screenWidth } = useWindowDimensions()
const [agreed, setAgreed] = useState(false) const [agreed, setAgreed] = useState(true)
const [pointsDrawerVisible, setPointsDrawerVisible] = useState(false) const [pointsDrawerVisible, setPointsDrawerVisible] = useState(false)
const [isSubscribing, setIsSubscribing] = useState(false) const [isSubscribing, setIsSubscribing] = useState(false)
@@ -89,38 +102,26 @@ export default function MembershipScreen() {
stripePricingData, stripePricingData,
} = useMembership() } = useMembership()
// 映射 API 数据到 UI 格式 // 映射 API 数据到 UI 格式
const plans: Plan[] = creditPlans.map((plan, index) => ({ const plans: Plan[] = creditPlans.map((plan, index) => ({
id: `plan-${index}` as PlanType, id: `plan-${index}` as PlanType,
name: index === 0 ? 'Plus' : index === 1 ? 'Pro' : 'Plus', name: plan.name,
price: plan.amountInCents / 100, price: plan.amountInCents / 100,
recommended: plan.popular, recommended: plan.popular,
points: plan.credits, points: plan.credits,
features: [ currency: plan.currency,
t('membership.features.points750'), featureList: plan.featureList,
t('membership.features.textToVideo'),
t('membership.features.superClearImage'),
t('membership.features.superDiscount'),
...(index > 0 ? [
t('membership.features.sora2ProTemplate'),
t('membership.features.removeWatermark'),
t('membership.features.allTemplates'),
] : []),
...(index > 1 ? [
t('membership.features.higherQuality'),
t('membership.features.prioritySupport'),
] : []),
],
})) }))
const selectedPlan = plans[selectedPlanIndex] || plans[0] const selectedPlan = plans[selectedPlanIndex] || plans[0]
// 下拉菜单选项 // 下拉菜单选项
const menuOptions = [ const menuOptions = [
{ label: t('membership.terms'), value: 'terms' }, { label: t('membership.terms'), value: 'terms' },
{ label: t('membership.privacy'), value: 'privacy' }, { label: t('membership.privacy'), value: 'privacy' },
] ]
// 处理菜单选择 // 处理菜单选择
const handleMenuSelect = (value: string) => { const handleMenuSelect = (value: string) => {
if (value === 'terms') { if (value === 'terms') {
@@ -129,7 +130,7 @@ export default function MembershipScreen() {
router.push('/privacy') router.push('/privacy')
} }
} }
// 轮播图相关 // 轮播图相关
const carouselImages = [ const carouselImages = [
require('@/assets/images/membership.png'), require('@/assets/images/membership.png'),
@@ -203,37 +204,37 @@ export default function MembershipScreen() {
</Pressable> </Pressable>
<View style={styles.headerSpacer} /> <View style={styles.headerSpacer} />
<View style={styles.pointsContainer}> <View style={styles.pointsContainer}>
<Pressable <Pressable
style={styles.pointsPill} style={styles.pointsPill}
onPress={() => setPointsDrawerVisible(true)} onPress={() => setPointsDrawerVisible(true)}
> >
<Text style={styles.pointsLabel}>{t('membership.myPoints')}</Text> <Text style={styles.pointsLabel}>{t('membership.myPoints')}</Text>
<MembershipPointsIcon /> <MembershipPointsIcon />
<Text style={styles.pointsValue}>{creditBalance}</Text> <Text style={styles.pointsValue}>{creditBalance}</Text>
</Pressable> </Pressable>
<View style={styles.settingsButtonContainer}> <View style={styles.settingsButtonContainer}>
<Dropdown <Dropdown
options={menuOptions} options={menuOptions}
onSelect={(value) => handleMenuSelect(value)} onSelect={(value) => handleMenuSelect(value)}
offsetTop={10} offsetTop={10}
renderTrigger={(selectedOption, isOpen, toggle) => ( renderTrigger={(selectedOption, isOpen, toggle) => (
<Pressable style={styles.settingsButton} onPress={toggle}> <Pressable style={styles.settingsButton} onPress={toggle}>
<OmitIcon /> <OmitIcon />
</Pressable> </Pressable>
)} )}
dropdownStyle={styles.menuDropdown} dropdownStyle={styles.menuDropdown}
renderOption={(option, isSelected) => ( renderOption={(option, isSelected) => (
<View style={styles.menuOption}> <View style={styles.menuOption}>
{option.value === 'terms' ? ( {option.value === 'terms' ? (
<TermsIcon /> <TermsIcon />
) : ( ) : (
<PrivacyIcon /> <PrivacyIcon />
)} )}
<Text style={styles.menuOptionText}>{option.label}</Text> <Text style={styles.menuOptionText}>{option.label}</Text>
</View> </View>
)} )}
/> />
</View> </View>
</View> </View>
</View> </View>
<ScrollView <ScrollView
@@ -241,46 +242,46 @@ export default function MembershipScreen() {
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
> >
<View style={styles.imageContainer}> <View style={styles.imageContainer}>
<Carousel <Carousel
width={screenWidth} width={screenWidth}
height={screenWidth / 1.1} height={screenWidth / 1.1}
data={carouselImages} data={carouselImages}
renderItem={({ item }) => ( renderItem={({ item }) => (
<Image <Image
source={item} source={item}
style={[styles.membershipImage, { width: screenWidth, height: screenWidth / 1.1 }]} style={[styles.membershipImage, { width: screenWidth, height: screenWidth / 1.1 }]}
contentFit="cover" contentFit="cover"
/> />
)} )}
autoPlay autoPlay
autoPlayInterval={2000} autoPlayInterval={2000}
loop loop
onSnapToItem={(index) => setCurrentImageIndex(index)} onSnapToItem={(index) => setCurrentImageIndex(index)}
enabled={false} enabled={false}
windowSize={1} windowSize={1}
mode="parallax" mode="parallax"
/> />
<View style={styles.dotsContainer}> <View style={styles.dotsContainer}>
{carouselImages.map((_, index) => ( {carouselImages.map((_, index) => (
<View <View
key={index} key={index}
style={[ style={[
styles.dot, styles.dot,
index === currentImageIndex && styles.dotActive, index === currentImageIndex && styles.dotActive,
]} ]}
/> />
))} ))}
</View>
<LinearGradient
colors={['#090A0B00', '#090A0B']}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={styles.imageGradient}
pointerEvents="none"
/>
</View> </View>
<LinearGradient
colors={['#090A0B00', '#090A0B']}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={styles.imageGradient}
pointerEvents="none"
/>
</View>
{/* 订阅计划标题 */} {/* 订阅计划标题 */}
<Text style={styles.sectionTitle}>{t('membership.subscriptionPlan')}</Text> <Text style={styles.sectionTitle}>{t('membership.subscriptionPlan')}</Text>
@@ -289,121 +290,128 @@ export default function MembershipScreen() {
{plans.map((plan, index) => { {plans.map((plan, index) => {
const isSelected = selectedPlanIndex === index const isSelected = selectedPlanIndex === index
return ( return (
<Pressable <Pressable
key={plan.id} key={plan.id}
style={[ style={[
styles.planCardWrapper, styles.planCardWrapper,
index > 0 && styles.planCardSpacing, index > 0 && styles.planCardSpacing,
]} ]}
onPress={() => setSelectedPlanIndex(index)} onPress={() => setSelectedPlanIndex(index)}
> >
{isSelected ? ( {isSelected ? (
<LinearGradient <LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']} colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }} start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }} end={{ x: 1, y: 0 }}
style={styles.planCardGradient} style={styles.planCardGradient}
> >
<View style={styles.planCard}> <View style={styles.planCard}>
{plan.recommended && ( {plan.recommended && (
<View style={styles.recommendedBadge}> <View style={styles.recommendedBadge}>
<Text style={styles.recommendedText}>{t('membership.mostRecommended')}</Text> <Text style={styles.recommendedText}>{t('membership.mostRecommended')}</Text>
</View>
)}
<Text style={styles.planName}>{plan.name}</Text>
<View style={styles.priceContainer}>
<GradientText
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.priceSymbol}
>
{toUnit(plan.currency)}
</GradientText>
<GradientText
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.priceValue}>{plan.price}</GradientText>
<Text style={styles.priceUnit}>{t('membership.perMonth')}</Text>
</View>
</View>
</LinearGradient>
) : (
<View style={styles.planCardWrapperUnselected}>
<View style={styles.planCard}>
{plan.recommended && (
<View style={styles.recommendedBadge}>
<Text style={styles.recommendedText}>{t('membership.mostRecommended')}</Text>
</View>
)}
<Text style={styles.planName}>{plan.name}</Text>
<View style={styles.priceContainer}>
<GradientText
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.priceSymbol}
>
{toUnit(plan.currency)}
</GradientText>
<GradientText
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.priceValue}>{plan.price}</GradientText>
<Text style={styles.priceUnit}>{t('membership.perMonth')}</Text>
</View> </View>
)}
<Text style={styles.planName}>{plan.name}</Text>
<View style={styles.priceContainer}>
<GradientText
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.priceSymbol}
>
$
</GradientText>
<GradientText
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.priceValue}>{plan.price}</GradientText>
<Text style={styles.priceUnit}>{t('membership.perMonth')}</Text>
</View> </View>
</View> </View>
</LinearGradient> )}
) : ( </Pressable>
<View style={styles.planCardWrapperUnselected}>
<View style={styles.planCard}>
{plan.recommended && (
<View style={styles.recommendedBadge}>
<Text style={styles.recommendedText}>{t('membership.mostRecommended')}</Text>
</View>
)}
<Text style={styles.planName}>{plan.name}</Text>
<View style={styles.priceContainer}>
<GradientText
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.priceSymbol}
>
$
</GradientText>
<GradientText
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={styles.priceValue}>{plan.price}</GradientText>
<Text style={styles.priceUnit}>{t('membership.perMonth')}</Text>
</View>
</View>
</View>
)}
</Pressable>
) )
})} })}
</View> </View>
{/* 卡片所属的信息 */} {/* 卡片所属的信息 */}
<View style={styles.pointsMonthlyContainer}> <View style={styles.pointsMonthlyContainer}>
{/* 积分每月显示 */} {/* 积分每月显示 */}
<View style={styles.pointsMonthlyCard}> <View style={styles.pointsMonthlyCard}>
<View style={styles.pointsMonthlyHeader}> <View style={styles.pointsMonthlyHeader}>
<MembershipPointsIcon /> <MembershipPointsIcon />
<Text style={styles.pointsMonthlyValue}> <Text style={styles.pointsMonthlyValue}>
{currentPlan.points.toLocaleString()} {currentPlan.points.toLocaleString()}
</Text> </Text>
<Text style={styles.pointsMonthlyLabel}>{t('membership.pointsPerMonth')}</Text> <Text style={styles.pointsMonthlyLabel}>{t('membership.pointsPerMonth')}</Text>
</View>
<View style={styles.progressBar}>
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[styles.progressFill, { width: `${progressPercentage}%` }]}
/>
</View>
<Text style={styles.pointsMonthlyNote}>{t('membership.pointsAutoRenew')}</Text>
</View>
{/* 功能列表 */}
<View >
{currentPlan.features.map((feature, index) => (
<View key={index} style={styles.featureItem}>
<CheckMarkIcon />
<Text style={styles.featureText}>{feature}</Text>
</View> </View>
))} <View style={styles.progressBar}>
<LinearGradient
colors={['#FF9966', '#FF6699', '#9966FF']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={[styles.progressFill, { width: `${progressPercentage}%` }]}
/>
</View>
<Text style={styles.pointsMonthlyNote}>{t('membership.pointsAutoRenew')}</Text>
</View>
{/* 功能列表 */}
<View >
{currentPlan.featureList.map((featureKey, index) => {
const translatedText = t(featureKey)
// 如果没有对应的翻译键(翻译结果等于键本身),不展示该项
if (translatedText === featureKey) {
return null
}
return (
<View key={index} style={styles.featureItem}>
<CheckMarkIcon />
<Text style={styles.featureText}>{translatedText}</Text>
</View>
)
})}
</View>
</View> </View>
</View>
</ScrollView> </ScrollView>
{/* 固定在底部的订阅容器 */} {/* 固定在底部的订阅容器 */}
<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 || isSubscribing} disabled={!agreed || isLoadingSubscriptions || isStripePricingLoading || isSubscribing}
style={styles.subscribeButtonPressable} style={styles.subscribeButtonPressable}
onPress={handleSubscribe} onPress={handleSubscribe}
> >
<LinearGradient <LinearGradient
colors={currentPlan.recommended colors={currentPlan.recommended
? ['#9966FF', '#FF6699', '#FF9966'] ? ['#9966FF', '#FF6699', '#FF9966']
: ['#FFE7F8', '#C6A7FA', '#ADBCFF', '#E6E3FF'] : ['#FFE7F8', '#C6A7FA', '#ADBCFF', '#E6E3FF']
@@ -419,15 +427,15 @@ export default function MembershipScreen() {
(!agreed || isLoadingSubscriptions || isStripePricingLoading || isSubscribing) && styles.subscribeButtonDisabled, (!agreed || isLoadingSubscriptions || isStripePricingLoading || isSubscribing) && styles.subscribeButtonDisabled,
]} ]}
> >
{isLoadingSubscriptions || isStripePricingLoading || isSubscribing ? ( {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>
)} )}
</LinearGradient> </LinearGradient>
</Pressable> </Pressable>
{/* 协议复选框 */} {/* 协议复选框 */}
<View style={styles.agreementContainer}> <View style={styles.agreementContainer}>
<Pressable <Pressable
style={styles.checkbox} style={styles.checkbox}
onPress={() => setAgreed(!agreed)} onPress={() => setAgreed(!agreed)}
@@ -437,14 +445,14 @@ export default function MembershipScreen() {
<View style={styles.agreementTextWrapper}> <View style={styles.agreementTextWrapper}>
<Text style={styles.agreementText}> <Text style={styles.agreementText}>
{t('membership.agreementText')}{' '} {t('membership.agreementText')}{' '}
<Text <Text
style={styles.agreementLink} style={styles.agreementLink}
onPress={() => router.push('/terms')} onPress={() => router.push('/terms')}
> >
{t('membership.terms')} {t('membership.terms')}
</Text> </Text>
<Text style={styles.agreementText}> {t('membership.agreementAnd')} </Text> <Text style={styles.agreementText}> {t('membership.agreementAnd')} </Text>
<Text <Text
style={styles.agreementLink} style={styles.agreementLink}
onPress={() => router.push('/privacy')} onPress={() => router.push('/privacy')}
> >
@@ -455,7 +463,7 @@ export default function MembershipScreen() {
</View> </View>
</View> </View>
</View> </View>
{/* 积分抽屉 */} {/* 积分抽屉 */}
<PointsDrawer <PointsDrawer
visible={pointsDrawerVisible} visible={pointsDrawerVisible}
@@ -721,15 +729,18 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
marginTop: 8, marginTop: 8,
marginBottom: 16,
paddingVertical: 8,
}, },
agreementTextWrapper: { agreementTextWrapper: {
marginLeft: 4, marginLeft: 4,
}, },
checkbox: { checkbox: {
width: 12, width: 20,
height: 12, height: 20,
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
padding: 4,
}, },
agreementText: { agreementText: {
color: '#CCCCCC', color: '#CCCCCC',

View File

@@ -34,6 +34,10 @@ export interface PointsDrawerProps {
* 额外充值积分 * 额外充值积分
*/ */
topUpPoints?: number topUpPoints?: number
/**
* 充值回调
*/
onRecharge?: (amount: any) => void
} }
export default function PointsDrawer({ export default function PointsDrawer({
@@ -42,6 +46,7 @@ export default function PointsDrawer({
totalPoints = 0, totalPoints = 0,
subscriptionPoints = 0, subscriptionPoints = 0,
topUpPoints = 0, topUpPoints = 0,
onRecharge,
}: PointsDrawerProps) { }: PointsDrawerProps) {
const { t } = useTranslation() const { t } = useTranslation()
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()

View File

@@ -4,7 +4,7 @@ import {
type ListCategoriesWithTagsInput, type ListCategoriesWithTagsInput,
type ListCategoriesWithTagsResult, type ListCategoriesWithTagsResult,
} from '@repo/sdk' } from '@repo/sdk'
import { useState, useEffect } from 'react' import { useEffect } from 'react'
import { create } from 'zustand' import { create } from 'zustand'
import { type ApiError } from '@/lib/types' import { type ApiError } from '@/lib/types'
@@ -28,19 +28,17 @@ export const useCategoriesWithTagsStore = create<CategoriesWithTagsState>((set)
export const useCategoriesWithTags = (params?: ListCategoriesWithTagsInput) => { export const useCategoriesWithTags = (params?: ListCategoriesWithTagsInput) => {
const store = useCategoriesWithTagsStore() const store = useCategoriesWithTagsStore()
const [localLoading, setLocalLoading] = useState(false)
useEffect(() => { useEffect(() => {
if (!store.hasLoaded && !store.loading && !localLoading) { if (!store.hasLoaded && !store.loading) {
load() load()
} }
}, []) }, [])
const load = async (inputParams?: ListCategoriesWithTagsInput) => { const load = async (inputParams?: ListCategoriesWithTagsInput) => {
if (store.loading || localLoading) return if (store.loading) return
try { try {
setLocalLoading(true)
useCategoriesWithTagsStore.setState({ loading: true }) useCategoriesWithTagsStore.setState({ loading: true })
const category = root.get(CategoryController) const category = root.get(CategoryController)
@@ -65,14 +63,12 @@ export const useCategoriesWithTags = (params?: ListCategoriesWithTagsInput) => {
useCategoriesWithTagsStore.setState({ data, loading: false, error: null, hasLoaded: true }) useCategoriesWithTagsStore.setState({ data, loading: false, error: null, hasLoaded: true })
} catch (e) { } catch (e) {
useCategoriesWithTagsStore.setState({ error: e as ApiError, loading: false, hasLoaded: true }) useCategoriesWithTagsStore.setState({ error: e as ApiError, loading: false, hasLoaded: true })
} finally {
setLocalLoading(false)
} }
} }
return { return {
load, load,
loading: store.loading || localLoading, loading: store.loading,
error: store.error, error: store.error,
data: store.data, data: store.data,
} }

View File

@@ -2,18 +2,23 @@ import { useState, useEffect, useMemo } from 'react'
import { Linking, Alert } from 'react-native' import { Linking, Alert } from 'react-native'
import { subscription, useSession } from '@/lib/auth' import { subscription, useSession } from '@/lib/auth'
import type { SubscriptionListItem } from '@/lib/auth' import type { StripePricingTableResponse, SubscriptionListItem } from '@/lib/auth'
interface CreditPlan { interface CreditPlan {
amountInCents: number amountInCents: number
credits: number credits: number
popular?: boolean popular?: boolean
name: string
currency: string
featureList: string[]
} }
export function useMembership() { export function useMembership() {
const { data: session } = useSession() const { data: session } = useSession()
const [selectedPlanIndex, setSelectedPlanIndex] = useState(0) const [selectedPlanIndex, setSelectedPlanIndex] = useState(0)
const [stripePricingData, setStripePricingData] = useState<any>(null) const [stripePricingData, setStripePricingData] = useState<StripePricingTableResponse>(null)
const [authSubscriptions, setAuthSubscriptions] = useState<any>(null) const [authSubscriptions, setAuthSubscriptions] = useState<any>(null)
const [isStripePricingLoading, setIsStripePricingLoading] = useState(false) const [isStripePricingLoading, setIsStripePricingLoading] = useState(false)
const [isLoadingSubscriptions, setIsLoadingSubscriptions] = useState(false) const [isLoadingSubscriptions, setIsLoadingSubscriptions] = useState(false)
@@ -66,11 +71,14 @@ export function useMembership() {
const creditPlans = useMemo((): CreditPlan[] => { const creditPlans = useMemo((): CreditPlan[] => {
if (!stripePricingData?.pricing_table_items) return [] if (!stripePricingData?.pricing_table_items) return []
return stripePricingData.pricing_table_items return stripePricingData.pricing_table_items
.filter((item: any) => item.recurring?.interval === 'month') .filter((item) => item.recurring?.interval === 'month')
.map((item: any) => ({ .map((item) => ({
amountInCents: parseInt(item.amount), amountInCents: parseInt(item.amount),
credits: parseInt(item.metadata?.grant_token || item.amount), credits: parseInt(item.amount),
popular: item.is_highlight || item.highlight_text === 'most_popular', popular: item.is_highlight || item.highlight_text === 'most_popular',
name: item.name || '',
currency: item.currency || 'usd',
featureList: item.feature_list || [],
})) }))
}, [stripePricingData?.pricing_table_items]) }, [stripePricingData?.pricing_table_items])

View File

@@ -73,10 +73,16 @@ export const useTemplateGenerations = () => {
hasMoreRef.current = newGenerations.length >= (params?.limit || 20) hasMoreRef.current = newGenerations.length >= (params?.limit || 20)
currentPageRef.current = nextPage currentPageRef.current = nextPage
setData((prev) => ({ // 合并数据并去重
...newData, setData((prev) => {
data: [...(prev?.data || []), ...newGenerations], const existingIds = new Set((prev?.data || []).map(item => item.id))
})) const uniqueNewGenerations = newGenerations.filter(item => !existingIds.has(item.id))
return {
...newData,
data: [...(prev?.data || []), ...uniqueNewGenerations],
}
})
setLoadingMore(false) setLoadingMore(false)
return { data: newData, error: null } return { data: newData, error: null }
}, },

View File

@@ -107,9 +107,36 @@ export interface CheckoutResponse {
url?: string url?: string
} }
// Stripe Pricing Table API 接口定义
export interface StripePricingTableItem {
amount: string;
currency: string;
name: string;
price_id: string;
product_id: string;
feature_list: string[];
highlight_text?: string | null;
is_highlight: boolean;
call_to_action_link: string;
recurring: {
interval: string;
interval_count: number;
};
metadata?: any;
metered_price_id: string;
}
export interface StripePricingTableResponse {
id: string;
object: string;
active: boolean;
pricing_table_items: StripePricingTableItem[];
}
export interface ISubscription { export interface ISubscription {
list: (params?: SubscriptionListParams) => Promise<{ data?: SubscriptionListItem[]; error?: ApiError }> list: (params?: SubscriptionListParams) => Promise<{ data?: SubscriptionListItem[]; error?: ApiError }>
plans: (params: PlansParams) => Promise<{ data?: any; error?: ApiError }> plans: (params: PlansParams) => Promise<{ data?: StripePricingTableResponse; error?: ApiError }>
create: (params: CreateSubscriptionParams) => Promise<{ data?: CheckoutResponse; error?: ApiError }> create: (params: CreateSubscriptionParams) => Promise<{ data?: CheckoutResponse; error?: ApiError }>
upgrade: (params: UpgradeSubscriptionParams) => Promise<{ data?: any; error?: ApiError }> upgrade: (params: UpgradeSubscriptionParams) => Promise<{ data?: any; error?: ApiError }>
cancel: (params: CancelSubscriptionParams) => Promise<{ data?: any; error?: ApiError }> cancel: (params: CancelSubscriptionParams) => Promise<{ data?: any; error?: ApiError }>

View File

@@ -47,6 +47,21 @@
"privacy": { "privacy": {
"title": "Privacy Policy" "title": "Privacy Policy"
}, },
"pricing": {
"feature": {
"useSora2": "Can use Sora2 200 times",
"useSora3": "Can use Sora2 25 times",
"useSora4": "Can use Sora2 100 times",
"textToVideo": "Supports text-to-video/image-to-video",
"superClearImageGeneration": "Ultra-clear image generation",
"superDiscount": "discounts for Sora2, Veo3.1",
"useSora2ProTemplate": "support Sora2 Pro templates",
"productWatermark": "without brand watermarks",
"basicTemplate": "Basic Template",
"allTemplates": "All Templates",
"highQualityVideo": "Higher-definition video"
}
},
"membership": { "membership": {
"terms": "Terms of Service", "terms": "Terms of Service",
"privacy": "Privacy Policy", "privacy": "Privacy Policy",

View File

@@ -47,6 +47,21 @@
"privacy": { "privacy": {
"title": "隐私协议" "title": "隐私协议"
}, },
"pricing": {
"feature": {
"useSora2": "6000点可以使用200次Sora2",
"useSora3": "750点可以使用25次Sora2",
"useSora4": "3000点可以使用100次Sora2",
"textToVideo": "支持文生视频/图生视频",
"superClearImageGeneration": "超清图像生成",
"superDiscount": "享受Sora2Veo3.1超级折扣",
"useSora2ProTemplate": "可以使用Sora2 Pro模版",
"productWatermark": "生成作品去除品牌水印",
"basicTemplate": "基础模版",
"allTemplates": "全部模版",
"highQualityVideo": "视频更高清"
}
},
"membership": { "membership": {
"terms": "服务条款", "terms": "服务条款",
"privacy": "隐私协议", "privacy": "隐私协议",