This commit is contained in:
imeepos
2025-11-12 13:19:06 +08:00
parent a7d922b535
commit 3ebcc24e9b
14 changed files with 483 additions and 265 deletions

View File

@@ -1,4 +1,5 @@
import { useAuth } from '@/hooks/use-auth';
import { authEvents } from '@/lib/api/client';
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { LoginModal } from './login-modal';
@@ -51,6 +52,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}
}, [isAuthenticated, showLoginModal, pendingCallback]);
// 监听 401 未授权事件
useEffect(() => {
const unsubscribe = authEvents.onUnauthorized(() => {
if (!isLoading && !isAuthenticated) {
setShowLoginModal(true);
}
});
return unsubscribe;
}, [isLoading, isAuthenticated]);
const value = {
requireAuth,
showLoginModal,

View File

@@ -1,5 +1,5 @@
import { memo } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { memo, useCallback, useEffect, useRef } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, useWindowDimensions } from 'react-native';
type Category = {
id: string;
@@ -13,18 +13,77 @@ type CategoryTabsProps = {
};
export function CategoryTabs({ categories, activeId, onChange }: CategoryTabsProps) {
const scrollViewRef = useRef<ScrollView>(null);
const tabPositionsRef = useRef<Map<string, { x: number; width: number }>>(new Map());
const containerWidthRef = useRef<number>(0);
const { width: screenWidth } = useWindowDimensions();
const scrollToActiveTab = useCallback(() => {
if (!activeId) {
console.log('[CategoryTabs] No activeId');
return;
}
const position = tabPositionsRef.current.get(activeId);
if (!position || !scrollViewRef.current) {
console.log('[CategoryTabs] Missing position or ref:', { position, hasRef: !!scrollViewRef.current });
return;
}
const centerOffset = screenWidth / 2;
const tabCenter = position.x + position.width / 2;
const scrollX = tabCenter - centerOffset;
const maxScrollX = Math.max(0, containerWidthRef.current - screenWidth);
const targetX = Math.max(0, Math.min(scrollX, maxScrollX));
console.log('[CategoryTabs] Scrolling to:', {
activeId,
position,
screenWidth,
tabCenter,
targetX,
containerWidth: containerWidthRef.current,
});
scrollViewRef.current.scrollTo({
x: targetX,
animated: true,
});
}, [activeId, screenWidth]);
useEffect(() => {
const timer = setTimeout(scrollToActiveTab, 150);
return () => clearTimeout(timer);
}, [scrollToActiveTab]);
const handleContentSizeChange = useCallback((width: number) => {
containerWidthRef.current = width;
console.log('[CategoryTabs] Content size:', width);
}, []);
return (
<ScrollView
ref={scrollViewRef}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={styles.container}
style={styles.scrollView}
onContentSizeChange={handleContentSizeChange}
>
{categories.map(category => (
{categories.map((category, index) => (
<CategoryTabItem
key={category.id}
category={category}
isActive={category.id === activeId}
onPress={onChange}
onLayout={(layout) => {
tabPositionsRef.current.set(category.id, {
x: layout.x,
width: layout.width,
});
console.log(`[CategoryTabs] Tab layout [${category.label}]:`, layout);
}}
/>
))}
</ScrollView>
@@ -35,11 +94,19 @@ type CategoryTabItemProps = {
category: Category;
isActive: boolean;
onPress: (id: string) => void;
onLayout: (layout: { x: number; width: number }) => void;
};
const CategoryTabItem = memo(({ category, isActive, onPress }: CategoryTabItemProps) => {
const CategoryTabItem = memo(({ category, isActive, onPress, onLayout }: CategoryTabItemProps) => {
return (
<Pressable onPress={() => onPress(category.id)} style={styles.tabButton}>
<Pressable
onPress={() => onPress(category.id)}
style={styles.tabButton}
onLayout={(event) => {
const { x, width } = event.nativeEvent.layout;
onLayout({ x, width });
}}
>
<Text style={[styles.label, isActive && styles.labelActive]}>{category.label}</Text>
<View style={[styles.indicator, isActive && styles.indicatorActive]} />
</Pressable>
@@ -48,6 +115,10 @@ const CategoryTabItem = memo(({ category, isActive, onPress }: CategoryTabItemPr
CategoryTabItem.displayName = 'CategoryTabItem';
const styles = StyleSheet.create({
scrollView: {
flexGrow: 0,
flexShrink: 0,
},
container: {
paddingVertical: 10,
paddingHorizontal: 6,

View File

@@ -20,6 +20,8 @@ export type CommunityItem = {
image: string;
chip: string;
actionLabel: string;
width?: number;
height?: number;
};
type CommunityGridProps = {
@@ -83,10 +85,16 @@ type CommunityCardProps = {
};
const CommunityCard = memo(({ item, style, onPressCard, onPressAction }: CommunityCardProps) => {
const aspectRatio = item.width && item.height ? item.width / item.height : 9 / 16;
return (
<Pressable onPress={() => onPressCard?.(item)} style={[styles.card, style]}>
<View style={styles.imageWrapper}>
<ExpoImage source={{ uri: item.image }} style={styles.cardImage} contentFit="cover" />
<ExpoImage
source={{ uri: item.image }}
style={[styles.cardImage, { aspectRatio }]}
contentFit="cover"
/>
<LinearGradient
colors={['rgba(12, 14, 20, 0)', 'rgba(12, 14, 20, 0.85)']}
style={styles.imageGradient}
@@ -169,7 +177,6 @@ const styles = StyleSheet.create({
},
cardImage: {
width: '100%',
aspectRatio: 9 / 16,
height: undefined,
},
imageGradient: {

View File

@@ -1,3 +1,4 @@
import { memo } from 'react';
import { StyleSheet, View } from 'react-native';
import Svg, { Path } from 'react-native-svg';
@@ -5,7 +6,7 @@ type HeaderProps = {
title?: string;
};
export function Header({ title = 'BESTAI' }: HeaderProps) {
export const Header = memo(function Header({ title = 'BESTAI' }: HeaderProps) {
return (
<View style={styles.container}>
<Svg width={89} height={13} viewBox="0 0 89 13" style={styles.logo}>
@@ -13,7 +14,7 @@ export function Header({ title = 'BESTAI' }: HeaderProps) {
</Svg>
</View>
);
}
});
const styles = StyleSheet.create({
container: {

View File

@@ -1,20 +1,43 @@
import { PropsWithChildren } from 'react';
import { memo, PropsWithChildren } from 'react';
import { StyleSheet, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
type PageLayoutProps = PropsWithChildren<{
backgroundColor?: string;
topInset?: 'auto' | 'none' | number;
horizontalPadding?: number | false;
}>;
export function PageLayout({ children, backgroundColor = '#050505' }: PageLayoutProps) {
export function PageLayout({
children,
backgroundColor = '#050505',
topInset = 'auto',
horizontalPadding = 24,
}: PageLayoutProps) {
const insets = useSafeAreaInsets();
const spacingTop = topInset === 'auto' ? insets.top : topInset === 'none' ? 0 : topInset;
const spacingBottom = Math.max(insets.bottom, 16);
const spacingHorizontal = horizontalPadding === false ? 0 : horizontalPadding;
return <View style={[styles.container, { paddingBottom: spacingBottom, backgroundColor }]}>{children}</View>;
return (
<View
style={[
styles.container,
{
paddingTop: spacingTop,
paddingBottom: spacingBottom,
paddingHorizontal: spacingHorizontal,
backgroundColor
}
]}
>
{children}
</View>
);
}
export function StatusBarSpacer() {
export const StatusBarSpacer = memo(function StatusBarSpacer() {
const insets = useSafeAreaInsets();
if (!insets.top) {
@@ -22,11 +45,10 @@ export function StatusBarSpacer() {
}
return <View style={{ height: insets.top }} />;
}
});
const styles = StyleSheet.create({
container: {
flex: 1,
paddingHorizontal: 24,
},
});

View File

@@ -1,11 +1,11 @@
import React, { useEffect, useState, useMemo } from 'react';
import { View, useWindowDimensions, type ImageSourcePropType, ActivityIndicator } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import React, { useState, useMemo } from 'react';
import { View, useWindowDimensions, type ImageSourcePropType, StyleSheet } from 'react-native';
import { PageLayout } from '@/components/bestai/layout';
import { useAuth } from '@/hooks/use-auth';
import { useProfileData } from '@/hooks/use-profile-data';
import { createStats, deriveAvatarSource, deriveDisplayName, deriveNumericValue } from '@/utils/profile-data';
import { StyleSheet } from 'react-native';
import { PROFILE_THEME } from '@/theme/profile';
import { ContentGallery } from './content-gallery';
import { ContentTabs } from './content-tabs';
import { Divider } from './divider';
@@ -23,12 +23,13 @@ type BillingMode = 'monthly' | 'lifetime';
export function ProfileScreen() {
const { user } = useAuth();
const { width } = useWindowDimensions();
const insets = useSafeAreaInsets();
const [billingMode, setBillingMode] = useState<BillingMode>('monthly');
const [activeTab, setActiveTab] = useState<TabKey>('all');
const displayNameFromUser = deriveDisplayName(user) ?? 'prairie_pufferfish_the';
const [presentedDisplayName, setPresentedDisplayName] = useState(displayNameFromUser);
const [isEditingIdentity, setIsEditingIdentity] = useState(false);
const [editedIdentity, setEditedIdentity] = useState<{
name?: string;
avatar?: ImageSourcePropType;
} | null>(null);
const {
generations,
@@ -42,24 +43,33 @@ export function ProfileScreen() {
handleLoadMore,
} = useProfileData(activeTab);
const horizontalPadding = width < 400 ? 20 : 24;
const avatarSize = width < 400 ? 74 : 82;
const isSmallScreen = width < PROFILE_THEME.breakpoints.small;
const horizontalPadding = isSmallScreen
? PROFILE_THEME.spacing.horizontal.small
: PROFILE_THEME.spacing.horizontal.large;
const avatarSize = isSmallScreen
? PROFILE_THEME.avatar.size.small
: PROFILE_THEME.avatar.size.large;
const displayNameFromUser = deriveDisplayName(user) ?? 'prairie_pufferfish_the';
const avatarSource = deriveAvatarSource(user);
const creditBalance = deriveNumericValue((user as Record<string, unknown>)?.credits);
const stats = createStats(user);
const [presentedAvatar, setPresentedAvatar] = useState<ImageSourcePropType | undefined>(avatarSource);
useEffect(() => {
setPresentedDisplayName(displayNameFromUser);
}, [displayNameFromUser]);
const presentedDisplayName = editedIdentity?.name ?? displayNameFromUser;
const presentedAvatar = editedIdentity?.avatar ?? avatarSource;
useEffect(() => {
setPresentedAvatar(avatarSource);
}, [avatarSource]);
const headerContainerStyle = useMemo(
() => ({
paddingHorizontal: horizontalPadding,
paddingBottom: PROFILE_THEME.spacing.headerBottom,
}),
[horizontalPadding]
);
const headerComponent = useMemo(
() => (
<View style={{ paddingHorizontal: horizontalPadding, paddingBottom: 16 }}>
<View style={headerContainerStyle}>
<ProfileHeader
billingMode={billingMode}
onChangeBilling={setBillingMode}
@@ -86,7 +96,7 @@ export function ProfileScreen() {
</View>
),
[
horizontalPadding,
headerContainerStyle,
billingMode,
creditBalance,
presentedDisplayName,
@@ -100,33 +110,29 @@ export function ProfileScreen() {
if (isLoading) {
return (
<View style={[styles.screen, { backgroundColor: stylesVars.background }]}>
{insets.top > 0 && <View style={{ height: insets.top }} />}
<PageLayout backgroundColor={PROFILE_THEME.colors.background} horizontalPadding={false}>
<ProfileLoadingState />
</View>
</PageLayout>
);
}
if (error) {
return (
<View style={[styles.screen, { backgroundColor: stylesVars.background }]}>
{insets.top > 0 && <View style={{ height: insets.top }} />}
<PageLayout backgroundColor={PROFILE_THEME.colors.background} horizontalPadding={false}>
<ProfileErrorState onRetry={handleRefresh} />
</View>
</PageLayout>
);
}
return (
<View style={[styles.screen, { backgroundColor: stylesVars.background }]}>
{insets.top > 0 && <View style={{ height: insets.top }} />}
<PageLayout backgroundColor={PROFILE_THEME.colors.background} horizontalPadding={false}>
{isLoading && generations.length === 0 ? (
<View style={{ flex: 1 }}>
<View style={styles.flexContainer}>
{headerComponent}
<ContentSkeleton />
</View>
) : generations.length === 0 && !isTabSwitching ? (
<View style={{ flex: 1 }}>
<View style={styles.flexContainer}>
{headerComponent}
<ProfileEmptyState activeTab={activeTab} />
</View>
@@ -148,24 +154,16 @@ export function ProfileScreen() {
initialName={presentedDisplayName}
onClose={() => setIsEditingIdentity(false)}
onSave={({ name, avatar }) => {
setPresentedDisplayName(name);
if (avatar) {
setPresentedAvatar(avatar);
}
setEditedIdentity({ name, avatar });
setIsEditingIdentity(false);
}}
/>
</View>
</PageLayout>
);
}
const stylesVars = {
background: '#050505',
paddingBottom: 96,
};
const styles = StyleSheet.create({
screen: {
flexContainer: {
flex: 1,
},
});