Files
digital-pilates/app/auth/login.tsx
richarjiang 281149201b feat(personal): 持久化开发者模式状态并优化登录后数据加载
- 新增 kv-store 持久化开发者模式开关,避免每次冷启动丢失
- 登录成功后立即拉取用户资料,减少首页空数据闪烁
- 修复体重卡片在未登录时重复请求的问题
- 移除 ActivityHeatMap 与 userSlice 中的调试日志
- useAuthGuard 增加 token 调试输出(临时)
2025-09-15 16:24:38 +08:00

407 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Ionicons } from '@expo/vector-icons';
import * as AppleAuthentication from 'expo-apple-authentication';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Animated, Linking, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { PRIVACY_POLICY_URL, USER_AGREEMENT_URL } from '@/constants/Agree';
import { Colors } from '@/constants/Colors';
import { useAppDispatch } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { fetchMyProfile, login } from '@/store/userSlice';
import Toast from 'react-native-toast-message';
export default function LoginScreen() {
const router = useRouter();
const searchParams = useLocalSearchParams<{ redirectTo?: string; redirectParams?: string }>();
const scheme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const color = Colors[scheme];
const pageBackground = scheme === 'light' ? color.pageBackgroundEmphasis : color.background;
const dispatch = useAppDispatch();
const AnimatedLinear = useMemo(() => Animated.createAnimatedComponent(LinearGradient), []);
// 背景动效:轻微平移/旋转与呼吸动画
const translateAnim = useRef(new Animated.Value(0)).current;
const rotateAnim = useRef(new Animated.Value(0)).current;
const pulseAnimA = useRef(new Animated.Value(0)).current;
const pulseAnimB = useRef(new Animated.Value(0)).current;
useEffect(() => {
const loopTranslate = Animated.loop(
Animated.sequence([
Animated.timing(translateAnim, { toValue: 1, duration: 6000, useNativeDriver: true }),
Animated.timing(translateAnim, { toValue: 0, duration: 6000, useNativeDriver: true }),
])
);
const loopRotate = Animated.loop(
Animated.sequence([
Animated.timing(rotateAnim, { toValue: 1, duration: 10000, useNativeDriver: true }),
Animated.timing(rotateAnim, { toValue: 0, duration: 10000, useNativeDriver: true }),
])
);
const loopPulseA = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnimA, { toValue: 1, duration: 3500, useNativeDriver: true }),
Animated.timing(pulseAnimA, { toValue: 0, duration: 3500, useNativeDriver: true }),
])
);
const loopPulseB = Animated.loop(
Animated.sequence([
Animated.timing(pulseAnimB, { toValue: 1, duration: 4200, useNativeDriver: true }),
Animated.timing(pulseAnimB, { toValue: 0, duration: 4200, useNativeDriver: true }),
])
);
loopTranslate.start();
loopRotate.start();
loopPulseA.start();
loopPulseB.start();
return () => {
loopTranslate.stop();
loopRotate.stop();
loopPulseA.stop();
loopPulseB.stop();
};
}, [pulseAnimA, pulseAnimB, rotateAnim, translateAnim]);
const [hasAgreed, setHasAgreed] = useState<boolean>(false);
const [appleAvailable, setAppleAvailable] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(false);
useEffect(() => {
AppleAuthentication.isAvailableAsync().then(setAppleAvailable).catch(() => setAppleAvailable(false));
}, []);
const guardAgreement = useCallback((action: () => void) => {
if (!hasAgreed) {
Alert.alert(
'请先阅读并同意',
'继续登录前,请阅读并勾选《隐私政策》和《用户协议》。点击“同意并继续”将默认勾选并继续登录。',
[
{ text: '取消', style: 'cancel' },
{
text: '同意并继续',
onPress: () => {
setHasAgreed(true);
setTimeout(() => action(), 0);
},
},
],
{ cancelable: true }
);
return;
}
action();
}, [hasAgreed]);
const onAppleLogin = useCallback(async () => {
if (!appleAvailable) return;
try {
setLoading(true);
const credential = await AppleAuthentication.signInAsync({
requestedScopes: [
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
AppleAuthentication.AppleAuthenticationScope.EMAIL,
],
});
const identityToken = (credential as any)?.identityToken;
if (!identityToken || typeof identityToken !== 'string') {
throw new Error('未获取到 Apple 身份令牌');
}
await dispatch(login({ appleIdentityToken: identityToken })).unwrap();
// 拉取用户信息
await dispatch(fetchMyProfile())
Toast.show({
text1: '登录成功',
type: 'success',
});
// 登录成功后处理重定向
const to = searchParams?.redirectTo as string | undefined;
const paramsJson = searchParams?.redirectParams as string | undefined;
let parsedParams: Record<string, any> | undefined;
if (paramsJson) {
try { parsedParams = JSON.parse(paramsJson); } catch { }
}
if (to) {
router.replace({ pathname: to, params: parsedParams } as any);
} else {
router.back();
}
} catch (err: any) {
console.log('err.code', err.code);
if (err?.code === 'ERR_CANCELED' || err?.code === 'ERR_REQUEST_CANCELED') return;
const message = err?.message || '登录失败,请稍后再试';
Alert.alert('登录失败', message);
} finally {
setLoading(false);
}
}, [appleAvailable, router, searchParams?.redirectParams, searchParams?.redirectTo]);
// 登录按钮不再因未勾选协议而禁用,仅在加载中禁用
return (
<SafeAreaView edges={['top']} style={[styles.safeArea, { backgroundColor: pageBackground }]}>
<ThemedView style={[styles.container, { backgroundColor: pageBackground }]}>
{/* 动态背景层(置于内容之下) */}
<View pointerEvents="none" style={styles.bgWrap}>
{/* 基础全屏渐变:保证覆盖全屏 */}
<AnimatedLinear
colors={
scheme === 'light'
? [color.pageBackgroundEmphasis, color.heroSurfaceTint, color.surface]
: [color.background, '#0F1112', color.surface]
}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={[styles.bgGradientFull]}
/>
{/* 次级大面积渐变:对角线方向形成层次 */}
<AnimatedLinear
colors={
scheme === 'light'
? ['rgba(164,138,237,0.12)', 'rgba(187,242,70,0.16)', 'transparent']
: ['rgba(164,138,237,0.16)', 'rgba(187,242,70,0.12)', 'transparent']
}
start={{ x: 1, y: 0 }}
end={{ x: 0, y: 1 }}
style={[
styles.bgGradientCover,
{
transform: [
{
rotate: rotateAnim.interpolate({ inputRange: [0, 1], outputRange: ['-4deg', '6deg'] }),
},
],
opacity: scheme === 'light' ? 0.9 : 0.65,
},
]}
/>
{/* 动感色块 A主色呼吸置于左下 */}
<Animated.View
style={[
styles.accentBlobLarge,
{
backgroundColor: color.ornamentPrimary,
transform: [
{ translateX: -80 },
{ translateY: 320 },
{ scale: pulseAnimA.interpolate({ inputRange: [0, 1], outputRange: [1, 1.05] }) },
],
opacity: scheme === 'light' ? 0.55 : 0.4,
},
]}
/>
{/* 动感色块 B辅色漂移置于右上 */}
<Animated.View
style={[
styles.accentBlobMedium,
{
backgroundColor: color.ornamentAccent,
transform: [
{ translateX: 240 },
{ translateY: -40 },
{ scale: pulseAnimB.interpolate({ inputRange: [0, 1], outputRange: [1, 1.07] }) },
],
opacity: scheme === 'light' ? 0.5 : 0.38,
},
]}
/>
</View>
{/* 自定义头部,与其它页面风格一致 */}
<View style={styles.header}>
<TouchableOpacity accessibilityRole="button" onPress={() => router.back()} style={styles.backButton}>
<Ionicons name="chevron-back" size={24} color={scheme === 'dark' ? '#ECEDEE' : '#192126'} />
</TouchableOpacity>
<Text style={[styles.headerTitle, { color: color.text }]}></Text>
<View style={{ width: 32 }} />
</View>
<ScrollView contentContainerStyle={styles.content} showsVerticalScrollIndicator={false}>
<View style={styles.headerWrap}>
<ThemedText style={[styles.title, { color: color.text }]}>Out Live</ThemedText>
<ThemedText style={[styles.subtitle, { color: color.textMuted }]}>Out Live</ThemedText>
</View>
{/* Apple 登录 */}
{appleAvailable && (
<Pressable
accessibilityRole="button"
onPress={() => guardAgreement(onAppleLogin)}
disabled={loading}
style={({ pressed }) => [
styles.appleButton,
{ backgroundColor: '#000000' },
loading && { opacity: 0.7 },
pressed && { transform: [{ scale: 0.98 }] },
]}
>
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
<Text style={styles.appleText}>使 Apple </Text>
</Pressable>
)}
{/* 协议勾选 */}
<View style={styles.agreementRow}>
<Pressable onPress={() => setHasAgreed((v) => !v)} style={styles.checkboxWrap} accessibilityRole="checkbox" accessibilityState={{ checked: hasAgreed }}>
<View
style={[styles.checkbox, {
backgroundColor: hasAgreed ? color.primary : 'transparent',
borderColor: hasAgreed ? color.primary : color.border,
}]}
>
{hasAgreed && <Ionicons name="checkmark" size={14} color={color.onPrimary} />}
</View>
</Pressable>
<Text style={[styles.agreementText, { color: color.textMuted }]}></Text>
<Pressable onPress={() => Linking.openURL(PRIVACY_POLICY_URL)}>
<Text style={[styles.link, { color: color.primary }]}></Text>
</Pressable>
<Text style={[styles.agreementText, { color: color.textMuted }]}></Text>
<Pressable onPress={() => Linking.openURL(USER_AGREEMENT_URL)}>
<Text style={[styles.link, { color: color.primary }]}></Text>
</Pressable>
</View>
{/* 占位底部间距 */}
<View style={{ height: 40 }} />
</ScrollView>
</ThemedView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
safeArea: { flex: 1 },
container: { flex: 1 },
content: {
flexGrow: 1,
paddingHorizontal: 24,
justifyContent: 'center',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
paddingTop: 4,
paddingBottom: 8,
},
backButton: { width: 32, height: 32, alignItems: 'center', justifyContent: 'center' },
headerTitle: { fontSize: 18, fontWeight: '700' },
headerWrap: {
marginBottom: 36,
},
title: {
fontSize: 32,
fontWeight: '500',
letterSpacing: 0.5,
lineHeight: 38,
},
subtitle: {
marginTop: 8,
fontSize: 14,
fontWeight: '500',
},
appleButton: {
height: 56,
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
marginBottom: 16,
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 2,
},
appleText: {
fontSize: 16,
color: '#FFFFFF',
fontWeight: '600',
},
guestButton: {
height: 52,
borderRadius: 26,
alignItems: 'center',
justifyContent: 'center',
flexDirection: 'row',
borderWidth: 1,
marginTop: 6,
},
guestText: {
fontSize: 15,
fontWeight: '500',
},
agreementRow: {
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
marginTop: 24,
},
checkboxWrap: { marginRight: 8 },
checkbox: {
width: 18,
height: 18,
borderRadius: 5,
borderWidth: 1,
alignItems: 'center',
justifyContent: 'center',
},
agreementText: { fontSize: 12 },
link: { fontSize: 12, fontWeight: '600' },
footerHint: { marginTop: 24 },
hintText: { fontSize: 12 },
// 背景样式
bgWrap: {
...StyleSheet.absoluteFillObject,
zIndex: 0,
},
bgGradientFull: {
position: 'absolute',
left: 0,
top: 0,
right: 0,
bottom: 0,
},
bgGradientCover: {
position: 'absolute',
left: '-10%',
top: '-15%',
width: '130%',
height: '70%',
borderBottomLeftRadius: 36,
borderBottomRightRadius: 36,
},
accentBlob: {
position: 'absolute',
width: 180,
height: 180,
borderRadius: 90,
},
accentBlobLarge: {
position: 'absolute',
width: 260,
height: 260,
borderRadius: 130,
},
accentBlobMedium: {
position: 'absolute',
width: 180,
height: 180,
borderRadius: 90,
},
});