feat: 更新应用版本和集成腾讯云 COS 上传功能
- 将应用版本更新至 1.0.2,修改相关配置文件 - 集成腾讯云 COS 上传功能,新增相关服务和钩子 - 更新 AI 体态评估页面,支持照片上传和评估结果展示 - 添加雷达图组件以展示评估结果 - 更新样式以适应新功能的展示和交互 - 修改登录页面背景效果,提升用户体验
This commit is contained in:
@@ -92,8 +92,8 @@ android {
|
|||||||
applicationId 'com.anonymous.digitalpilates'
|
applicationId 'com.anonymous.digitalpilates'
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 2
|
||||||
versionName "1.0.0"
|
versionName "1.0.2"
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
debug {
|
debug {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ newArchEnabled=true
|
|||||||
|
|
||||||
# Use this property to enable or disable the Hermes JS engine.
|
# Use this property to enable or disable the Hermes JS engine.
|
||||||
# If set to false, you will be using JSC instead.
|
# If set to false, you will be using JSC instead.
|
||||||
hermesEnabled=true
|
hermesEnabled=false
|
||||||
|
|
||||||
# Enable GIF support in React Native images (~200 B increase)
|
# Enable GIF support in React Native images (~200 B increase)
|
||||||
expo.gif.enabled=true
|
expo.gif.enabled=true
|
||||||
|
|||||||
3
app.json
3
app.json
@@ -2,12 +2,13 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "digital-pilates",
|
"name": "digital-pilates",
|
||||||
"slug": "digital-pilates",
|
"slug": "digital-pilates",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/images/icon.png",
|
"icon": "./assets/images/icon.png",
|
||||||
"scheme": "digitalpilates",
|
"scheme": "digitalpilates",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
"newArchEnabled": true,
|
"newArchEnabled": true,
|
||||||
|
"jsEngine": "jsc",
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.anonymous.digitalpilates",
|
"bundleIdentifier": "com.anonymous.digitalpilates",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
|
import ImageViewing from 'react-native-image-viewing';
|
||||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
import { HeaderBar } from '@/components/ui/HeaderBar';
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
@@ -31,17 +32,17 @@ type Sample = { uri: string; correct: boolean };
|
|||||||
|
|
||||||
const SAMPLES: Record<PoseView, Sample[]> = {
|
const SAMPLES: Record<PoseView, Sample[]> = {
|
||||||
front: [
|
front: [
|
||||||
{ uri: 'https://images.unsplash.com/photo-1594737625785-c6683fc87c73?w=400&q=80&auto=format', correct: true },
|
{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', correct: true },
|
||||||
{ uri: 'https://images.unsplash.com/photo-1544716278-ca5e3f4abd8c?w=400&q=80&auto=format', correct: false },
|
{ uri: 'https://images.unsplash.com/photo-1544716278-ca5e3f4abd8c?w=400&q=80&auto=format', correct: false },
|
||||||
{ uri: 'https://images.unsplash.com/photo-1571019614242-c5c5dee9f50b?w=400&q=80&auto=format', correct: false },
|
{ uri: 'https://images.unsplash.com/photo-1571019614242-c5c5dee9f50b?w=400&q=80&auto=format', correct: false },
|
||||||
],
|
],
|
||||||
side: [
|
side: [
|
||||||
{ uri: 'https://images.unsplash.com/photo-1554463529-e27854014799?w=400&q=80&auto=format', correct: true },
|
{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', correct: true },
|
||||||
{ uri: 'https://images.unsplash.com/photo-1596357395104-5bcae0b1a5eb?w=400&q=80&auto=format', correct: false },
|
{ uri: 'https://images.unsplash.com/photo-1596357395104-5bcae0b1a5eb?w=400&q=80&auto=format', correct: false },
|
||||||
{ uri: 'https://images.unsplash.com/photo-1526506118085-60ce8714f8c5?w=400&q=80&auto=format', correct: false },
|
{ uri: 'https://images.unsplash.com/photo-1526506118085-60ce8714f8c5?w=400&q=80&auto=format', correct: false },
|
||||||
],
|
],
|
||||||
back: [
|
back: [
|
||||||
{ uri: 'https://images.unsplash.com/photo-1517836357463-d25dfeac3438?w=400&q=80&auto=format', correct: true },
|
{ uri: 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/images/imagedemo.jpeg', correct: true },
|
||||||
{ uri: 'https://images.unsplash.com/photo-1571721797421-f4c9f2b13107?w=400&q=80&auto=format', correct: false },
|
{ uri: 'https://images.unsplash.com/photo-1571721797421-f4c9f2b13107?w=400&q=80&auto=format', correct: false },
|
||||||
{ uri: 'https://images.unsplash.com/photo-1518611012118-696072aa579a?w=400&q=80&auto=format', correct: false },
|
{ uri: 'https://images.unsplash.com/photo-1518611012118-696072aa579a?w=400&q=80&auto=format', correct: false },
|
||||||
],
|
],
|
||||||
@@ -50,7 +51,7 @@ const SAMPLES: Record<PoseView, Sample[]> = {
|
|||||||
export default function AIPostureAssessmentScreen() {
|
export default function AIPostureAssessmentScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const theme = Colors.dark;
|
const theme = Colors.light;
|
||||||
|
|
||||||
const [uploadState, setUploadState] = useState<UploadState>({});
|
const [uploadState, setUploadState] = useState<UploadState>({});
|
||||||
const canStart = useMemo(
|
const canStart = useMemo(
|
||||||
@@ -167,13 +168,13 @@ export default function AIPostureAssessmentScreen() {
|
|||||||
|
|
||||||
function handleStart() {
|
function handleStart() {
|
||||||
if (!canStart) return;
|
if (!canStart) return;
|
||||||
// TODO: 调用后端或进入分析页面
|
// 进入评估中间页面
|
||||||
Alert.alert('开始测评', '已收集三视角照片,准备开始AI体态分析');
|
router.push('/ai-posture-processing');
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.screen, { backgroundColor: theme.background }]}>
|
<View style={[styles.screen, { backgroundColor: Colors.light.pageBackgroundEmphasis }]}>
|
||||||
<HeaderBar title="AI体态测评" onBack={() => router.back()} tone="dark" transparent />
|
<HeaderBar title="AI体态测评" onBack={() => router.back()} tone="light" transparent />
|
||||||
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={{ paddingBottom: insets.bottom + 120 }}
|
contentContainerStyle={{ paddingBottom: insets.bottom + 120 }}
|
||||||
@@ -207,10 +208,8 @@ export default function AIPostureAssessmentScreen() {
|
|||||||
|
|
||||||
{/* Intro */}
|
{/* Intro */}
|
||||||
<View style={styles.introBox}>
|
<View style={styles.introBox}>
|
||||||
<Text style={styles.title}>上传标准姿势照片</Text>
|
<Text style={[styles.title, { color: '#192126' }]}>上传标准姿势照片</Text>
|
||||||
<Text style={styles.description}>
|
<Text style={[styles.description, { color: '#5E6468' }]}>请依次上传正面、侧面与背面全身照。保持光线均匀、背景简洁,身体立正自然放松。</Text>
|
||||||
请依次上传正面、侧面与背面全身照。保持光线均匀、背景简洁,身体立正自然放松。
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{/* Upload sections */}
|
{/* Upload sections */}
|
||||||
@@ -272,6 +271,10 @@ function UploadTile({
|
|||||||
onPickLibrary: () => void;
|
onPickLibrary: () => void;
|
||||||
samples: Sample[];
|
samples: Sample[];
|
||||||
}) {
|
}) {
|
||||||
|
const [viewerVisible, setViewerVisible] = React.useState(false);
|
||||||
|
const [viewerIndex, setViewerIndex] = React.useState(0);
|
||||||
|
const imagesForViewer = React.useMemo(() => samples.map((s) => ({ uri: s.uri })), [samples]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<View style={styles.sectionHeader}>
|
<View style={styles.sectionHeader}>
|
||||||
@@ -294,7 +297,7 @@ function UploadTile({
|
|||||||
) : (
|
) : (
|
||||||
<View style={styles.placeholder}>
|
<View style={styles.placeholder}>
|
||||||
<View style={styles.plusBadge}>
|
<View style={styles.plusBadge}>
|
||||||
<Ionicons name="camera" size={16} color="#192126" />
|
<Ionicons name="camera" size={16} color="#BBF246" />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.placeholderTitle}>拍摄或选择照片</Text>
|
<Text style={styles.placeholderTitle}>拍摄或选择照片</Text>
|
||||||
<Text style={styles.placeholderDesc}>点击拍摄,长按从相册选择</Text>
|
<Text style={styles.placeholderDesc}>点击拍摄,长按从相册选择</Text>
|
||||||
@@ -302,19 +305,27 @@ function UploadTile({
|
|||||||
)}
|
)}
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
<BlurView intensity={18} tint="dark" style={styles.sampleBox}>
|
<BlurView intensity={12} tint="light" style={styles.sampleBox}>
|
||||||
<Text style={styles.sampleTitle}>示例</Text>
|
<Text style={styles.sampleTitle}>示例</Text>
|
||||||
<View style={styles.sampleRow}>
|
<View style={styles.sampleRow}>
|
||||||
{samples.map((s, idx) => (
|
{samples.map((s, idx) => (
|
||||||
<View key={idx} style={styles.sampleItem}>
|
<View key={idx} style={styles.sampleItem}>
|
||||||
<Image source={{ uri: s.uri }} style={styles.sampleImg} />
|
<TouchableOpacity activeOpacity={0.9} onPress={() => { setViewerIndex(idx); setViewerVisible(true); }}>
|
||||||
<View style={[styles.sampleTag, { backgroundColor: s.correct ? '#2BCC7F' : '#E24D4D' }]}>
|
<Image source={{ uri: s.uri }} style={styles.sampleImg} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<View style={[styles.sampleTag, { backgroundColor: s.correct ? '#2BCC7F' : 'rgba(25,33,38,0.08)' }]}>
|
||||||
<Text style={styles.sampleTagText}>{s.correct ? '正确示范' : '错误示范'}</Text>
|
<Text style={styles.sampleTagText}>{s.correct ? '正确示范' : '错误示范'}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
</BlurView>
|
</BlurView>
|
||||||
|
<ImageViewing
|
||||||
|
images={imagesForViewer}
|
||||||
|
imageIndex={viewerIndex}
|
||||||
|
visible={viewerVisible}
|
||||||
|
onRequestClose={() => setViewerVisible(false)}
|
||||||
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -328,15 +339,15 @@ const styles = StyleSheet.create({
|
|||||||
marginHorizontal: 16,
|
marginHorizontal: 16,
|
||||||
padding: 14,
|
padding: 14,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
backgroundColor: 'rgba(255,255,255,0.04)'
|
backgroundColor: 'rgba(25,33,38,0.06)'
|
||||||
},
|
},
|
||||||
permTitle: {
|
permTitle: {
|
||||||
color: '#ECEDEE',
|
color: '#192126',
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
permDesc: {
|
permDesc: {
|
||||||
color: 'rgba(255,255,255,0.75)',
|
color: '#5E6468',
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
},
|
},
|
||||||
@@ -367,10 +378,10 @@ const styles = StyleSheet.create({
|
|||||||
height: 40,
|
height: 40,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderColor: 'rgba(255,255,255,0.18)',
|
borderColor: 'rgba(25,33,38,0.14)',
|
||||||
},
|
},
|
||||||
permSecondaryText: {
|
permSecondaryText: {
|
||||||
color: 'rgba(255,255,255,0.85)',
|
color: '#384046',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
@@ -420,12 +431,12 @@ const styles = StyleSheet.create({
|
|||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
},
|
},
|
||||||
sectionTitle: {
|
sectionTitle: {
|
||||||
color: '#ECEDEE',
|
color: '#192126',
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
retakeHint: {
|
retakeHint: {
|
||||||
color: 'rgba(255,255,255,0.55)',
|
color: '#888F92',
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
},
|
},
|
||||||
uploader: {
|
uploader: {
|
||||||
@@ -433,8 +444,8 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
borderWidth: 1,
|
borderWidth: 1,
|
||||||
borderStyle: 'dashed',
|
borderStyle: 'dashed',
|
||||||
borderColor: 'rgba(255,255,255,0.18)',
|
borderColor: 'rgba(25,33,38,0.14)',
|
||||||
backgroundColor: '#1E262C',
|
backgroundColor: '#FFFFFF',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
},
|
},
|
||||||
preview: {
|
preview: {
|
||||||
@@ -453,25 +464,27 @@ const styles = StyleSheet.create({
|
|||||||
borderRadius: 18,
|
borderRadius: 18,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
backgroundColor: '#BBF246',
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: '#BBF246',
|
||||||
},
|
},
|
||||||
placeholderTitle: {
|
placeholderTitle: {
|
||||||
color: '#ECEDEE',
|
color: '#192126',
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '700',
|
fontWeight: '700',
|
||||||
},
|
},
|
||||||
placeholderDesc: {
|
placeholderDesc: {
|
||||||
color: 'rgba(255,255,255,0.65)',
|
color: '#888F92',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
},
|
},
|
||||||
sampleBox: {
|
sampleBox: {
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
borderRadius: 16,
|
borderRadius: 16,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
backgroundColor: 'rgba(255,255,255,0.04)',
|
backgroundColor: 'rgba(255,255,255,0.72)',
|
||||||
},
|
},
|
||||||
sampleTitle: {
|
sampleTitle: {
|
||||||
color: 'rgba(255,255,255,0.8)',
|
color: '#192126',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
marginBottom: 8,
|
marginBottom: 8,
|
||||||
fontWeight: '600',
|
fontWeight: '600',
|
||||||
@@ -487,7 +500,7 @@ const styles = StyleSheet.create({
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
height: 90,
|
height: 90,
|
||||||
borderRadius: 12,
|
borderRadius: 12,
|
||||||
backgroundColor: '#111',
|
backgroundColor: '#F2F4F5',
|
||||||
},
|
},
|
||||||
sampleTag: {
|
sampleTag: {
|
||||||
alignSelf: 'flex-start',
|
alignSelf: 'flex-start',
|
||||||
|
|||||||
279
app/ai-posture-processing.tsx
Normal file
279
app/ai-posture-processing.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import Animated, { Easing, useAnimatedStyle, useSharedValue, withDelay, withRepeat, withSequence, withTiming } from 'react-native-reanimated';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
|
||||||
|
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||||
|
|
||||||
|
export default function AIPostureProcessingScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const theme = Colors.dark;
|
||||||
|
|
||||||
|
// Core looping animations
|
||||||
|
const spin = useSharedValue(0);
|
||||||
|
const pulse = useSharedValue(0);
|
||||||
|
const scanY = useSharedValue(0);
|
||||||
|
const particle = useSharedValue(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
spin.value = withRepeat(withTiming(1, { duration: 6000, easing: Easing.linear }), -1);
|
||||||
|
pulse.value = withRepeat(withSequence(
|
||||||
|
withTiming(1, { duration: 1600, easing: Easing.inOut(Easing.quad) }),
|
||||||
|
withTiming(0, { duration: 1600, easing: Easing.inOut(Easing.quad) })
|
||||||
|
), -1, true);
|
||||||
|
scanY.value = withRepeat(withTiming(1, { duration: 3800, easing: Easing.inOut(Easing.cubic) }), -1, false);
|
||||||
|
particle.value = withDelay(400, withRepeat(withTiming(1, { duration: 5200, easing: Easing.inOut(Easing.quad) }), -1, true));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const ringStyleOuter = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ rotate: `${spin.value * 360}deg` }],
|
||||||
|
opacity: 0.8,
|
||||||
|
}));
|
||||||
|
const ringStyleInner = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ rotate: `${-spin.value * 360}deg` }, { scale: 0.98 + pulse.value * 0.04 }],
|
||||||
|
}));
|
||||||
|
const scannerStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ translateY: (scanY.value * (SCREEN_HEIGHT * 0.45)) - (SCREEN_HEIGHT * 0.225) }],
|
||||||
|
opacity: 0.6 + Math.sin(scanY.value * Math.PI) * 0.2,
|
||||||
|
}));
|
||||||
|
const particleStyleA = useAnimatedStyle(() => ({
|
||||||
|
transform: [
|
||||||
|
{ translateX: Math.sin(particle.value * Math.PI * 2) * 40 },
|
||||||
|
{ translateY: Math.cos(particle.value * Math.PI * 2) * 24 },
|
||||||
|
{ rotate: `${particle.value * 360}deg` },
|
||||||
|
],
|
||||||
|
opacity: 0.5 + 0.5 * Math.abs(Math.sin(particle.value * Math.PI)),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.screen, { backgroundColor: theme.background }]}>
|
||||||
|
<HeaderBar title="AI评估进行中" onBack={() => router.back()} tone="light" transparent />
|
||||||
|
|
||||||
|
{/* Layered background */}
|
||||||
|
<View style={[StyleSheet.absoluteFill, { zIndex: -1 }]} pointerEvents="none">
|
||||||
|
<LinearGradient
|
||||||
|
colors={["#F7FFE8", "#F0FBFF", "#FFF6E8"]}
|
||||||
|
start={{ x: 0.1, y: 0 }}
|
||||||
|
end={{ x: 0.9, y: 1 }}
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
/>
|
||||||
|
<BlurView intensity={20} tint="light" style={styles.blurBlobA} />
|
||||||
|
<BlurView intensity={20} tint="light" style={styles.blurBlobB} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Hero visualization */}
|
||||||
|
<View style={styles.hero}>
|
||||||
|
<View style={styles.heroBackdrop} />
|
||||||
|
<Animated.View style={[styles.ringOuter, ringStyleOuter]} />
|
||||||
|
<Animated.View style={[styles.ringInner, ringStyleInner]} />
|
||||||
|
|
||||||
|
<View style={styles.grid}>
|
||||||
|
{Array.from({ length: 9 }).map((_, i) => (
|
||||||
|
<View key={`row-${i}`} style={styles.gridRow}>
|
||||||
|
{Array.from({ length: 9 }).map((__, j) => (
|
||||||
|
<View key={`cell-${i}-${j}`} style={styles.gridCell} />
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
<Animated.View style={[styles.scanner, scannerStyle]} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Animated.View style={[styles.particleA, particleStyleA]} />
|
||||||
|
<Animated.View style={[styles.particleB, particleStyleA, { right: undefined, left: SCREEN_WIDTH * 0.2, top: undefined, bottom: 60 }]} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Copy & actions */}
|
||||||
|
<View style={[styles.panel, { paddingBottom: insets.bottom + 16 }]}>
|
||||||
|
<Text style={styles.title}>正在进行体态特征提取与矢量评估</Text>
|
||||||
|
<Text style={styles.subtitle}>这通常需要 10-30 秒。你可以停留在此页面等待结果,或点击返回稍后在个人中心查看。</Text>
|
||||||
|
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.primaryBtn, { backgroundColor: theme.primary }]}
|
||||||
|
activeOpacity={0.9}
|
||||||
|
// TODO: 评估完成后恢复为停留当前页面等待结果(不要跳转)
|
||||||
|
onPress={() => router.replace('/ai-posture-result')}
|
||||||
|
>
|
||||||
|
<Ionicons name="time-outline" size={16} color="#192126" />
|
||||||
|
<Text style={styles.primaryBtnText}>保持页面等待</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={styles.secondaryBtn} activeOpacity={0.9} onPress={() => router.replace('/(tabs)/personal')}>
|
||||||
|
<Text style={styles.secondaryBtnText}>返回个人中心</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const RING_SIZE = Math.min(SCREEN_WIDTH, SCREEN_HEIGHT) * 0.62;
|
||||||
|
const INNER_RING_SIZE = RING_SIZE * 0.72;
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
screen: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
hero: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
blurBlobA: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -80,
|
||||||
|
right: -60,
|
||||||
|
width: 240,
|
||||||
|
height: 240,
|
||||||
|
borderRadius: 120,
|
||||||
|
backgroundColor: 'rgba(187,242,70,0.20)',
|
||||||
|
},
|
||||||
|
blurBlobB: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 120,
|
||||||
|
left: -40,
|
||||||
|
width: 220,
|
||||||
|
height: 220,
|
||||||
|
borderRadius: 110,
|
||||||
|
backgroundColor: 'rgba(89, 198, 255, 0.16)',
|
||||||
|
},
|
||||||
|
heroBackdrop: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: RING_SIZE * 1.08,
|
||||||
|
height: RING_SIZE * 1.08,
|
||||||
|
borderRadius: (RING_SIZE * 1.08) / 2,
|
||||||
|
backgroundColor: 'rgba(25,33,38,0.25)',
|
||||||
|
},
|
||||||
|
ringOuter: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: RING_SIZE,
|
||||||
|
height: RING_SIZE,
|
||||||
|
borderRadius: RING_SIZE / 2,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(25,33,38,0.16)',
|
||||||
|
},
|
||||||
|
ringInner: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: INNER_RING_SIZE,
|
||||||
|
height: INNER_RING_SIZE,
|
||||||
|
borderRadius: INNER_RING_SIZE / 2,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: 'rgba(187,242,70,0.65)',
|
||||||
|
shadowColor: '#BBF246',
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0.35,
|
||||||
|
shadowRadius: 24,
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
width: RING_SIZE * 0.9,
|
||||||
|
height: RING_SIZE * 0.9,
|
||||||
|
borderRadius: RING_SIZE * 0.45,
|
||||||
|
overflow: 'hidden',
|
||||||
|
padding: 10,
|
||||||
|
backgroundColor: 'rgba(25,33,38,0.08)',
|
||||||
|
},
|
||||||
|
gridRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
},
|
||||||
|
gridCell: {
|
||||||
|
flex: 1,
|
||||||
|
aspectRatio: 1,
|
||||||
|
margin: 2,
|
||||||
|
borderRadius: 3,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.16)',
|
||||||
|
},
|
||||||
|
scanner: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: '50%',
|
||||||
|
height: 60,
|
||||||
|
marginTop: -30,
|
||||||
|
backgroundColor: 'rgba(187,242,70,0.10)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(187,242,70,0.25)',
|
||||||
|
},
|
||||||
|
particleA: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: SCREEN_WIDTH * 0.18,
|
||||||
|
top: 40,
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 7,
|
||||||
|
backgroundColor: '#BBF246',
|
||||||
|
shadowColor: '#BBF246',
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 16,
|
||||||
|
},
|
||||||
|
particleB: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: SCREEN_WIDTH * 0.08,
|
||||||
|
top: 120,
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: 4,
|
||||||
|
backgroundColor: 'rgba(89, 198, 255, 1)',
|
||||||
|
shadowColor: 'rgba(89, 198, 255, 1)',
|
||||||
|
shadowOffset: { width: 0, height: 0 },
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 12,
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 8,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
color: '#ECEDEE',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '800',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
color: 'rgba(255,255,255,0.75)',
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 10,
|
||||||
|
marginTop: 14,
|
||||||
|
},
|
||||||
|
primaryBtn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 6,
|
||||||
|
height: 44,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 12,
|
||||||
|
},
|
||||||
|
primaryBtnText: {
|
||||||
|
color: '#192126',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
secondaryBtn: {
|
||||||
|
flex: 1,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 12,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(255,255,255,0.18)',
|
||||||
|
},
|
||||||
|
secondaryBtnText: {
|
||||||
|
color: 'rgba(255,255,255,0.85)',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
318
app/ai-posture-result.tsx
Normal file
318
app/ai-posture-result.tsx
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
|
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
|
import { RadarChart } from '@/components/RadarChart';
|
||||||
|
import { HeaderBar } from '@/components/ui/HeaderBar';
|
||||||
|
import { Colors } from '@/constants/Colors';
|
||||||
|
|
||||||
|
type PoseView = 'front' | 'side' | 'back';
|
||||||
|
|
||||||
|
// 斯多特普拉提体态评估维度(示例)
|
||||||
|
const DIMENSIONS = [
|
||||||
|
{ key: 'head_neck', label: '头颈对齐' },
|
||||||
|
{ key: 'shoulder', label: '肩带稳定' },
|
||||||
|
{ key: 'ribs', label: '胸廓控制' },
|
||||||
|
{ key: 'pelvis', label: '骨盆中立' },
|
||||||
|
{ key: 'spine', label: '脊柱排列' },
|
||||||
|
{ key: 'hip_knee', label: '髋膝对线' },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Issue = {
|
||||||
|
title: string;
|
||||||
|
severity: 'low' | 'medium' | 'high';
|
||||||
|
description: string;
|
||||||
|
suggestions: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ViewReport = {
|
||||||
|
score: number; // 0-5
|
||||||
|
issues: Issue[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ResultData = {
|
||||||
|
radar: number[]; // 与 DIMENSIONS 对应,0-5
|
||||||
|
overview: string;
|
||||||
|
byView: Record<PoseView, ViewReport>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// NOTE: 此处示例数据,后续可由 API 注入
|
||||||
|
const MOCK_RESULT: ResultData = {
|
||||||
|
radar: [4.2, 3.6, 3.2, 4.6, 3.8, 3.4],
|
||||||
|
overview: '整体体态较为均衡,骨盆与脊柱控制较好;肩带稳定性与胸廓控制仍有提升空间。',
|
||||||
|
byView: {
|
||||||
|
front: {
|
||||||
|
score: 3.8,
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
title: '肩峰略前移,肩胛轻度外旋',
|
||||||
|
severity: 'medium',
|
||||||
|
description: '站立正面观察,右侧肩峰较左侧略有前移,提示肩带稳定性偏弱。',
|
||||||
|
suggestions: ['肩胛稳定训练(如天鹅摆臂分解)', '胸椎伸展与放松', '轻度弹力带外旋激活'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
side: {
|
||||||
|
score: 4.1,
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
title: '骨盆接近中立,腰椎轻度前凸',
|
||||||
|
severity: 'low',
|
||||||
|
description: '侧面观察,骨盆位置接近中立位,腰椎存在轻度前凸,需注意腹压与肋骨下沉。',
|
||||||
|
suggestions: ['呼吸配合下的腹横肌激活', '猫牛流动改善胸椎灵活性'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
back: {
|
||||||
|
score: 3.5,
|
||||||
|
issues: [
|
||||||
|
{
|
||||||
|
title: '右侧肩胛轻度上抬',
|
||||||
|
severity: 'medium',
|
||||||
|
description: '背面观察,右肩胛较左侧轻度上抬,肩胛下回旋不足。',
|
||||||
|
suggestions: ['锯前肌激活训练', '低位划船,关注肩胛下沉与后缩'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AIPostureResultScreen() {
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
const router = useRouter();
|
||||||
|
const theme = Colors.light;
|
||||||
|
|
||||||
|
const categories = useMemo(() => DIMENSIONS.map(d => ({ key: d.key, label: d.label })), []);
|
||||||
|
|
||||||
|
const ScoreBadge = ({ score }: { score: number }) => (
|
||||||
|
<View style={styles.scoreBadge}>
|
||||||
|
<Text style={styles.scoreText}>{score.toFixed(1)}</Text>
|
||||||
|
<Text style={styles.scoreUnit}>/5</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const IssueItem = ({ issue }: { issue: Issue }) => (
|
||||||
|
<View style={styles.issueItem}>
|
||||||
|
<View style={[styles.issueDot, issue.severity === 'high' ? styles.dotHigh : issue.severity === 'medium' ? styles.dotMedium : styles.dotLow]} />
|
||||||
|
<View style={{ flex: 1 }}>
|
||||||
|
<Text style={styles.issueTitle}>{issue.title}</Text>
|
||||||
|
<Text style={styles.issueDesc}>{issue.description}</Text>
|
||||||
|
{!!issue.suggestions?.length && (
|
||||||
|
<View style={styles.suggestRow}>
|
||||||
|
{issue.suggestions.map((s, idx) => (
|
||||||
|
<View key={idx} style={styles.suggestChip}><Text style={styles.suggestText}>{s}</Text></View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
|
||||||
|
const ViewCard = ({ title, report }: { title: string; report: ViewReport }) => (
|
||||||
|
<Animated.View entering={FadeInDown.duration(400)} style={styles.card}>
|
||||||
|
<View style={styles.cardHeader}>
|
||||||
|
<Text style={styles.cardTitle}>{title}</Text>
|
||||||
|
<ScoreBadge score={report.score} />
|
||||||
|
</View>
|
||||||
|
{report.issues.map((iss, idx) => (<IssueItem key={idx} issue={iss} />))}
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.screen, { backgroundColor: theme.background }]}>
|
||||||
|
<HeaderBar title="体态评估结果" onBack={() => router.back()} tone="light" transparent />
|
||||||
|
|
||||||
|
{/* 背景装饰 */}
|
||||||
|
<View style={[StyleSheet.absoluteFill, { zIndex: -1 }]} pointerEvents="none">
|
||||||
|
<BlurView intensity={20} tint="light" style={styles.bgBlobA} />
|
||||||
|
<BlurView intensity={20} tint="light" style={styles.bgBlobB} />
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView contentContainerStyle={{ paddingBottom: insets.bottom + 40 }} showsVerticalScrollIndicator={false}>
|
||||||
|
{/* 总览与雷达图 */}
|
||||||
|
<Animated.View entering={FadeInDown.duration(400)} style={styles.card}>
|
||||||
|
<Text style={styles.sectionTitle}>总体概览</Text>
|
||||||
|
<Text style={styles.overview}>{MOCK_RESULT.overview}</Text>
|
||||||
|
<View style={styles.radarWrap}>
|
||||||
|
<RadarChart categories={categories} values={MOCK_RESULT.radar} />
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* 视图分析 */}
|
||||||
|
<ViewCard title="正面视图" report={MOCK_RESULT.byView.front} />
|
||||||
|
<ViewCard title="侧面视图" report={MOCK_RESULT.byView.side} />
|
||||||
|
<ViewCard title="背面视图" report={MOCK_RESULT.byView.back} />
|
||||||
|
|
||||||
|
{/* 底部操作 */}
|
||||||
|
<View style={styles.actions}>
|
||||||
|
<TouchableOpacity style={[styles.primaryBtn, { backgroundColor: theme.primary }]} onPress={() => router.replace('/(tabs)/personal')}>
|
||||||
|
<Ionicons name="checkmark-circle" size={18} color={theme.onPrimary} />
|
||||||
|
<Text style={[styles.primaryBtnText, { color: theme.onPrimary }]}>完成并返回</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity style={[styles.secondaryBtn, { borderColor: theme.border }]} onPress={() => router.push('/ai-coach-chat')}>
|
||||||
|
<Text style={[styles.secondaryBtnText, { color: theme.text }]}>生成训练建议</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
screen: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
bgBlobA: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: -60,
|
||||||
|
right: -40,
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
borderRadius: 100,
|
||||||
|
backgroundColor: 'rgba(187,242,70,0.18)',
|
||||||
|
},
|
||||||
|
bgBlobB: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 100,
|
||||||
|
left: -30,
|
||||||
|
width: 180,
|
||||||
|
height: 180,
|
||||||
|
borderRadius: 90,
|
||||||
|
backgroundColor: 'rgba(89, 198, 255, 0.16)',
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
marginTop: 16,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
borderRadius: 16,
|
||||||
|
padding: 14,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.72)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(25,33,38,0.08)',
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
color: '#192126',
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '800',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
overview: {
|
||||||
|
color: '#384046',
|
||||||
|
fontSize: 14,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
radarWrap: {
|
||||||
|
marginTop: 10,
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
cardHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
cardTitle: {
|
||||||
|
color: '#192126',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
scoreBadge: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: 'rgba(187,242,70,0.16)',
|
||||||
|
},
|
||||||
|
scoreText: {
|
||||||
|
color: '#192126',
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
scoreUnit: {
|
||||||
|
color: '#5E6468',
|
||||||
|
fontSize: 12,
|
||||||
|
marginLeft: 4,
|
||||||
|
},
|
||||||
|
issueItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 10,
|
||||||
|
paddingVertical: 10,
|
||||||
|
},
|
||||||
|
issueDot: {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
borderRadius: 5,
|
||||||
|
marginTop: 6,
|
||||||
|
},
|
||||||
|
dotHigh: { backgroundColor: '#E24D4D' },
|
||||||
|
dotMedium: { backgroundColor: '#F0C23C' },
|
||||||
|
dotLow: { backgroundColor: '#2BCC7F' },
|
||||||
|
issueTitle: {
|
||||||
|
color: '#192126',
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
issueDesc: {
|
||||||
|
color: '#5E6468',
|
||||||
|
fontSize: 13,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
suggestRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: 8,
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
suggestChip: {
|
||||||
|
paddingHorizontal: 10,
|
||||||
|
paddingVertical: 6,
|
||||||
|
borderRadius: 12,
|
||||||
|
backgroundColor: 'rgba(25,33,38,0.04)',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(25,33,38,0.08)',
|
||||||
|
},
|
||||||
|
suggestText: {
|
||||||
|
color: '#192126',
|
||||||
|
fontSize: 12,
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
marginTop: 16,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 10,
|
||||||
|
},
|
||||||
|
primaryBtn: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 8,
|
||||||
|
height: 48,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
borderRadius: 14,
|
||||||
|
},
|
||||||
|
primaryBtnText: {
|
||||||
|
color: '#192126',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '800',
|
||||||
|
},
|
||||||
|
secondaryBtn: {
|
||||||
|
flex: 1,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 14,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'transparent',
|
||||||
|
},
|
||||||
|
secondaryBtnText: {
|
||||||
|
color: '#384046',
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import * as AppleAuthentication from 'expo-apple-authentication';
|
import * as AppleAuthentication from 'expo-apple-authentication';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { useLocalSearchParams, useRouter } from 'expo-router';
|
import { useLocalSearchParams, useRouter } from 'expo-router';
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Alert, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
import { Alert, Animated, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
|
||||||
import { ThemedText } from '@/components/ThemedText';
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
@@ -17,7 +18,52 @@ export default function LoginScreen() {
|
|||||||
const searchParams = useLocalSearchParams<{ redirectTo?: string; redirectParams?: string }>();
|
const searchParams = useLocalSearchParams<{ redirectTo?: string; redirectParams?: string }>();
|
||||||
const scheme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
const scheme = (useColorScheme() ?? 'light') as 'light' | 'dark';
|
||||||
const color = Colors[scheme];
|
const color = Colors[scheme];
|
||||||
|
const pageBackground = scheme === 'light' ? color.pageBackgroundEmphasis : color.background;
|
||||||
const dispatch = useAppDispatch();
|
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 [hasAgreed, setHasAgreed] = useState<boolean>(false);
|
||||||
const [appleAvailable, setAppleAvailable] = useState<boolean>(false);
|
const [appleAvailable, setAppleAvailable] = useState<boolean>(false);
|
||||||
@@ -29,7 +75,21 @@ export default function LoginScreen() {
|
|||||||
|
|
||||||
const guardAgreement = useCallback((action: () => void) => {
|
const guardAgreement = useCallback((action: () => void) => {
|
||||||
if (!hasAgreed) {
|
if (!hasAgreed) {
|
||||||
Alert.alert('请先阅读并同意', '勾选“我已阅读并同意用户协议与隐私政策”后才可继续登录');
|
Alert.alert(
|
||||||
|
'请先阅读并同意',
|
||||||
|
'继续登录前,请阅读并勾选《隐私政策》和《用户协议》。点击“同意并继续”将默认勾选并继续登录。',
|
||||||
|
[
|
||||||
|
{ text: '取消', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: '同意并继续',
|
||||||
|
onPress: () => {
|
||||||
|
setHasAgreed(true);
|
||||||
|
setTimeout(() => action(), 0);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ cancelable: true }
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
action();
|
action();
|
||||||
@@ -83,11 +143,79 @@ export default function LoginScreen() {
|
|||||||
}
|
}
|
||||||
}, [router, searchParams?.redirectParams, searchParams?.redirectTo]);
|
}, [router, searchParams?.redirectParams, searchParams?.redirectTo]);
|
||||||
|
|
||||||
const disabledStyle = useMemo(() => ({ opacity: hasAgreed ? 1 : 0.5 }), [hasAgreed]);
|
// 登录按钮不再因未勾选协议而禁用,仅在加载中禁用
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SafeAreaView edges={['top']} style={[styles.safeArea, { backgroundColor: color.background }]}>
|
<SafeAreaView edges={['top']} style={[styles.safeArea, { backgroundColor: pageBackground }]}>
|
||||||
<ThemedView style={styles.container}>
|
<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}>
|
<View style={styles.header}>
|
||||||
<TouchableOpacity accessibilityRole="button" onPress={() => router.back()} style={styles.backButton}>
|
<TouchableOpacity accessibilityRole="button" onPress={() => router.back()} style={styles.backButton}>
|
||||||
@@ -108,11 +236,11 @@ export default function LoginScreen() {
|
|||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
onPress={() => guardAgreement(onAppleLogin)}
|
onPress={() => guardAgreement(onAppleLogin)}
|
||||||
disabled={!hasAgreed || loading}
|
disabled={loading}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.appleButton,
|
styles.appleButton,
|
||||||
{ backgroundColor: '#000000' },
|
{ backgroundColor: '#000000' },
|
||||||
disabledStyle,
|
loading && { opacity: 0.7 },
|
||||||
pressed && { transform: [{ scale: 0.98 }] },
|
pressed && { transform: [{ scale: 0.98 }] },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
@@ -125,16 +253,16 @@ export default function LoginScreen() {
|
|||||||
<Pressable
|
<Pressable
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
onPress={() => guardAgreement(onGuestLogin)}
|
onPress={() => guardAgreement(onGuestLogin)}
|
||||||
disabled={!hasAgreed || loading}
|
disabled={loading}
|
||||||
style={({ pressed }) => [
|
style={({ pressed }) => [
|
||||||
styles.guestButton,
|
styles.guestButton,
|
||||||
{ borderColor: color.border, backgroundColor: color.surface },
|
{ borderColor: color.border, backgroundColor: color.surface },
|
||||||
disabledStyle,
|
loading && { opacity: 0.7 },
|
||||||
pressed && { transform: [{ scale: 0.98 }] },
|
pressed && { transform: [{ scale: 0.98 }] },
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<Ionicons name="person-circle-outline" size={22} color={Colors.light.neutral200} style={{ marginRight: 8 }} />
|
<Ionicons name="person-circle-outline" size={22} color={color.neutral200} style={{ marginRight: 8 }} />
|
||||||
<Text style={[styles.guestText, { color: Colors.light.neutral200 }]}>以游客身份继续</Text>
|
<Text style={[styles.guestText, { color: color.neutral200 }]}>以游客身份继续</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
|
|
||||||
{/* 协议勾选 */}
|
{/* 协议勾选 */}
|
||||||
@@ -192,6 +320,7 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
|
lineHeight: 38,
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
marginTop: 8,
|
marginTop: 8,
|
||||||
@@ -248,6 +377,45 @@ const styles = StyleSheet.create({
|
|||||||
link: { fontSize: 12, fontWeight: '600' },
|
link: { fontSize: 12, fontWeight: '600' },
|
||||||
footerHint: { marginTop: 24 },
|
footerHint: { marginTop: 24 },
|
||||||
hintText: { fontSize: 12 },
|
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,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import DateTimePicker from '@react-native-community/datetimepicker';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { Pressable, SafeAreaView, ScrollView, StyleSheet, TextInput, View } from 'react-native';
|
import { Modal, Platform, Pressable, SafeAreaView, ScrollView, StyleSheet, TextInput, View } from 'react-native';
|
||||||
import DateTimePickerModal from 'react-native-modal-datetime-picker';
|
|
||||||
|
|
||||||
import { ThemedText } from '@/components/ThemedText';
|
import { ThemedText } from '@/components/ThemedText';
|
||||||
import { ThemedView } from '@/components/ThemedView';
|
import { ThemedView } from '@/components/ThemedView';
|
||||||
@@ -39,6 +39,7 @@ export default function TrainingPlanScreen() {
|
|||||||
const { draft, current } = useAppSelector((s) => s.trainingPlan);
|
const { draft, current } = useAppSelector((s) => s.trainingPlan);
|
||||||
const [weightInput, setWeightInput] = useState<string>('');
|
const [weightInput, setWeightInput] = useState<string>('');
|
||||||
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
const [datePickerVisible, setDatePickerVisible] = useState(false);
|
||||||
|
const [pickerDate, setPickerDate] = useState<Date>(new Date());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch(loadTrainingPlan());
|
dispatch(loadTrainingPlan());
|
||||||
@@ -60,12 +61,31 @@ export default function TrainingPlanScreen() {
|
|||||||
return true;
|
return true;
|
||||||
}, [draft]);
|
}, [draft]);
|
||||||
|
|
||||||
|
const formattedStartDate = useMemo(() => {
|
||||||
|
const d = new Date(draft.startDate);
|
||||||
|
try {
|
||||||
|
return new Intl.DateTimeFormat('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
weekday: 'short',
|
||||||
|
}).format(d);
|
||||||
|
} catch {
|
||||||
|
return d.toLocaleDateString('zh-CN');
|
||||||
|
}
|
||||||
|
}, [draft.startDate]);
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
await dispatch(saveTrainingPlan()).unwrap().catch(() => { });
|
await dispatch(saveTrainingPlan()).unwrap().catch(() => { });
|
||||||
router.back();
|
router.back();
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDatePicker = () => setDatePickerVisible(true);
|
const openDatePicker = () => {
|
||||||
|
const base = draft.startDate ? new Date(draft.startDate) : new Date();
|
||||||
|
base.setHours(0, 0, 0, 0);
|
||||||
|
setPickerDate(base);
|
||||||
|
setDatePickerVisible(true);
|
||||||
|
};
|
||||||
const closeDatePicker = () => setDatePickerVisible(false);
|
const closeDatePicker = () => setDatePickerVisible(false);
|
||||||
const onConfirmDate = (date: Date) => {
|
const onConfirmDate = (date: Date) => {
|
||||||
// 只允许今天之后(含今天)的日期
|
// 只允许今天之后(含今天)的日期
|
||||||
@@ -161,7 +181,7 @@ export default function TrainingPlanScreen() {
|
|||||||
</Pressable>
|
</Pressable>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<ThemedText style={styles.dateHint}>{new Date(draft.startDate).toLocaleDateString()}</ThemedText>
|
<ThemedText style={styles.dateHint}>{formattedStartDate}</ThemedText>
|
||||||
|
|
||||||
<View style={styles.rowBetween}>
|
<View style={styles.rowBetween}>
|
||||||
<ThemedText style={styles.label}>开始体重 (kg)</ThemedText>
|
<ThemedText style={styles.label}>开始体重 (kg)</ThemedText>
|
||||||
@@ -197,13 +217,44 @@ export default function TrainingPlanScreen() {
|
|||||||
<View style={{ height: 32 }} />
|
<View style={{ height: 32 }} />
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</ThemedView>
|
</ThemedView>
|
||||||
<DateTimePickerModal
|
<Modal
|
||||||
isVisible={datePickerVisible}
|
visible={datePickerVisible}
|
||||||
mode="date"
|
transparent
|
||||||
minimumDate={new Date()}
|
animationType="fade"
|
||||||
onConfirm={onConfirmDate}
|
onRequestClose={closeDatePicker}
|
||||||
onCancel={closeDatePicker}
|
>
|
||||||
/>
|
<Pressable style={styles.modalBackdrop} onPress={closeDatePicker} />
|
||||||
|
<View style={styles.modalSheet}>
|
||||||
|
<DateTimePicker
|
||||||
|
value={pickerDate}
|
||||||
|
mode="date"
|
||||||
|
display={Platform.OS === 'ios' ? 'inline' : 'calendar'}
|
||||||
|
minimumDate={new Date()}
|
||||||
|
{...(Platform.OS === 'ios' ? { locale: 'zh-CN' } : {})}
|
||||||
|
onChange={(event, date) => {
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
if (date) setPickerDate(date);
|
||||||
|
} else {
|
||||||
|
if (event.type === 'set' && date) {
|
||||||
|
onConfirmDate(date);
|
||||||
|
} else {
|
||||||
|
closeDatePicker();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{Platform.OS === 'ios' && (
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<Pressable onPress={closeDatePicker} style={[styles.modalBtn]}>
|
||||||
|
<ThemedText style={styles.modalBtnText}>取消</ThemedText>
|
||||||
|
</Pressable>
|
||||||
|
<Pressable onPress={() => { onConfirmDate(pickerDate); }} style={[styles.modalBtn, styles.modalBtnPrimary]}>
|
||||||
|
<ThemedText style={[styles.modalBtnText, styles.modalBtnTextPrimary]}>确定</ThemedText>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -239,12 +290,14 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
color: '#1A1A1A',
|
color: '#1A1A1A',
|
||||||
|
lineHeight: 36,
|
||||||
},
|
},
|
||||||
subtitle: {
|
subtitle: {
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: '#5E6468',
|
color: '#5E6468',
|
||||||
marginTop: 6,
|
marginTop: 6,
|
||||||
marginBottom: 16,
|
marginBottom: 16,
|
||||||
|
lineHeight: 20,
|
||||||
},
|
},
|
||||||
card: {
|
card: {
|
||||||
backgroundColor: '#FFFFFF',
|
backgroundColor: '#FFFFFF',
|
||||||
@@ -463,6 +516,42 @@ const styles = StyleSheet.create({
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: '800',
|
fontWeight: '800',
|
||||||
},
|
},
|
||||||
|
modalBackdrop: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.35)',
|
||||||
|
},
|
||||||
|
modalSheet: {
|
||||||
|
position: 'absolute',
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: '#FFFFFF',
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
},
|
||||||
|
modalActions: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
marginTop: 8,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
modalBtn: {
|
||||||
|
paddingHorizontal: 14,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 10,
|
||||||
|
backgroundColor: '#F1F5F9',
|
||||||
|
},
|
||||||
|
modalBtnPrimary: {
|
||||||
|
backgroundColor: palette.primary,
|
||||||
|
},
|
||||||
|
modalBtnText: {
|
||||||
|
color: '#334155',
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
modalBtnTextPrimary: {
|
||||||
|
color: palette.ink,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
104
components/RadarChart.tsx
Normal file
104
components/RadarChart.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { StyleSheet, View } from 'react-native';
|
||||||
|
import Svg, * as SvgLib from 'react-native-svg';
|
||||||
|
|
||||||
|
export type RadarCategory = { key: string; label: string };
|
||||||
|
|
||||||
|
export type RadarChartProps = {
|
||||||
|
categories: RadarCategory[];
|
||||||
|
values: number[]; // 与 categories 一一对应
|
||||||
|
maxValue?: number; // 默认 5
|
||||||
|
size?: number; // 组件宽高,默认 260
|
||||||
|
levels?: number; // 网格层数,默认 5
|
||||||
|
showGrid?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RadarChart({ categories, values, maxValue = 5, size = 260, levels = 5, showGrid = true }: RadarChartProps) {
|
||||||
|
const radius = size * 0.38;
|
||||||
|
const cx = size / 2;
|
||||||
|
const cy = size / 2;
|
||||||
|
const count = categories.length;
|
||||||
|
|
||||||
|
const points = useMemo(() => {
|
||||||
|
return categories.map((_, i) => {
|
||||||
|
const angle = (Math.PI * 2 * i) / count - Math.PI / 2; // 顶部起点
|
||||||
|
return { angle, x: (v: number) => cx + Math.cos(angle) * v, y: (v: number) => cy + Math.sin(angle) * v };
|
||||||
|
});
|
||||||
|
}, [categories, count, cx, cy]);
|
||||||
|
|
||||||
|
const valuePath = useMemo(() => {
|
||||||
|
const rValues = values.map((v) => Math.max(0, Math.min(maxValue, v)) / maxValue * radius);
|
||||||
|
const d = rValues
|
||||||
|
.map((rv, i) => `${i === 0 ? 'M' : 'L'} ${points[i].x(rv)} ${points[i].y(rv)}`)
|
||||||
|
.join(' ');
|
||||||
|
return `${d} Z`;
|
||||||
|
}, [values, maxValue, radius, points]);
|
||||||
|
|
||||||
|
const gridPolygons = useMemo(() => {
|
||||||
|
const polys: string[] = [];
|
||||||
|
for (let l = 1; l <= levels; l++) {
|
||||||
|
const r = (radius / levels) * l;
|
||||||
|
const p = points.map((p) => `${p.x(r)},${p.y(r)}`).join(' ');
|
||||||
|
polys.push(p);
|
||||||
|
}
|
||||||
|
return polys;
|
||||||
|
}, [levels, radius, points]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ width: size, height: size }}>
|
||||||
|
<Svg width={size} height={size}>
|
||||||
|
<SvgLib.Defs>
|
||||||
|
<SvgLib.LinearGradient id="radarFill" x1="0" y1="0" x2="1" y2="1">
|
||||||
|
<SvgLib.Stop offset="0%" stopColor="#BBF246" stopOpacity={0.32} />
|
||||||
|
<SvgLib.Stop offset="100%" stopColor="#59C6FF" stopOpacity={0.28} />
|
||||||
|
</SvgLib.LinearGradient>
|
||||||
|
</SvgLib.Defs>
|
||||||
|
|
||||||
|
{showGrid && (
|
||||||
|
<SvgLib.G>
|
||||||
|
{gridPolygons.map((pointsStr, idx) => (
|
||||||
|
<SvgLib.Polygon
|
||||||
|
key={`grid-${idx}`}
|
||||||
|
points={pointsStr}
|
||||||
|
fill="none"
|
||||||
|
stroke="rgba(25,33,38,0.12)"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{points.map((p, i) => (
|
||||||
|
<SvgLib.Line
|
||||||
|
key={`axis-${i}`}
|
||||||
|
x1={cx}
|
||||||
|
y1={cy}
|
||||||
|
x2={p.x(radius)}
|
||||||
|
y2={p.y(radius)}
|
||||||
|
stroke="rgba(25,33,38,0.12)"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SvgLib.G>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{categories.map((c, i) => {
|
||||||
|
const r = radius + 18;
|
||||||
|
const x = points[i].x(r);
|
||||||
|
const y = points[i].y(r);
|
||||||
|
const anchor = Math.cos(points[i].angle) > 0.2 ? 'start' : Math.cos(points[i].angle) < -0.2 ? 'end' : 'middle';
|
||||||
|
const dy = Math.sin(points[i].angle) > 0.6 ? 10 : Math.sin(points[i].angle) < -0.6 ? -4 : 4;
|
||||||
|
return (
|
||||||
|
<SvgLib.Text key={`label-${c.key}`} x={x} y={y + dy} fontSize={11} fill="#192126" textAnchor={anchor}>
|
||||||
|
{c.label}
|
||||||
|
</SvgLib.Text>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<SvgLib.Path d={valuePath} fill="url(#radarFill)" stroke="#A8DB3D" strokeWidth={2} />
|
||||||
|
<SvgLib.Circle cx={cx} cy={cy} r={3} fill="#A8DB3D" />
|
||||||
|
</Svg>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({});
|
||||||
|
|
||||||
|
|
||||||
26
constants/Cos.ts
Normal file
26
constants/Cos.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export const COS_BUCKET: string = '';
|
||||||
|
export const COS_REGION: string = '';
|
||||||
|
export const COS_PUBLIC_BASE: string = '';
|
||||||
|
|
||||||
|
// 统一的对象键前缀(可按业务拆分)
|
||||||
|
export const COS_KEY_PREFIX = 'uploads/';
|
||||||
|
|
||||||
|
// 生成文件名(含子目录),避免冲突
|
||||||
|
export function buildCosKey(params: { prefix?: string; ext?: string; userId?: string }): string {
|
||||||
|
const { prefix, ext, userId } = params;
|
||||||
|
const date = new Date();
|
||||||
|
const yyyy = date.getFullYear();
|
||||||
|
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(date.getDate()).padStart(2, '0');
|
||||||
|
const ts = date.getTime();
|
||||||
|
const rand = Math.random().toString(36).slice(2, 8);
|
||||||
|
const base = `${COS_KEY_PREFIX}${yyyy}/${mm}/${dd}/${userId ? userId + '/' : ''}${ts}_${rand}`;
|
||||||
|
return `${prefix ? prefix.replace(/\/*$/, '/') : ''}${base}${ext ? (ext.startsWith('.') ? ext : `.${ext}`) : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPublicUrl(key: string): string {
|
||||||
|
if (!COS_PUBLIC_BASE) return '';
|
||||||
|
return `${COS_PUBLIC_BASE.replace(/\/$/, '')}/${key.replace(/^\//, '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
174
docs/cos.md
Normal file
174
docs/cos.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# 腾讯云 COS 上传集成说明
|
||||||
|
|
||||||
|
本文档记录本项目在前端(Expo/React Native)侧对腾讯云 COS 的接入方式与使用规范,包含配置、接口约定、上传用法、进度/取消/重试、URL 构建、权限与安全、常见问题等。
|
||||||
|
|
||||||
|
## 概览
|
||||||
|
- 依赖:`cos-js-sdk-v5`
|
||||||
|
- 临时密钥接口:`GET /api/users/cos-token`
|
||||||
|
- 统一封装:
|
||||||
|
- 配置:`constants/Cos.ts`
|
||||||
|
- 上传服务:`services/cos.ts`
|
||||||
|
- Hook:`hooks/useCosUpload.ts`
|
||||||
|
|
||||||
|
## 安装与依赖
|
||||||
|
已在 `package.json` 中添加:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dependencies": {
|
||||||
|
"cos-js-sdk-v5": "^1.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置
|
||||||
|
编辑 `constants/Cos.ts`,填入实际 COS 参数:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
export const COS_BUCKET = 'your-bucket-125xxxxxxx';
|
||||||
|
export const COS_REGION = 'ap-shanghai';
|
||||||
|
export const COS_PUBLIC_BASE = 'https://your-bucket-125xxxxxxx.cos.ap-shanghai.myqcloud.com';
|
||||||
|
export const COS_KEY_PREFIX = 'uploads/';
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- COS_PUBLIC_BASE:若桶或 CDN 具有公网访问,使用对应域名(建议 HTTPS)。若为私有桶,可忽略该项(前端不能直接拼公开 URL)。
|
||||||
|
- `buildCosKey` 会依据日期和随机串生成不重复 Key,可通过 `prefix`/`userId` 定制目录。
|
||||||
|
|
||||||
|
## 后端接口约定:/api/users/cos-token
|
||||||
|
- 方法:`GET`
|
||||||
|
- 鉴权:建议要求登录态,后端根据用户权限签发最小权限临时凭证。
|
||||||
|
- 响应体示例(关键字段):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"credentials": {
|
||||||
|
"tmpSecretId": "TMPID...",
|
||||||
|
"tmpSecretKey": "TMPKEY...",
|
||||||
|
"sessionToken": "SESSION_TOKEN..."
|
||||||
|
},
|
||||||
|
"startTime": 1730000000,
|
||||||
|
"expiredTime": 1730001800
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 最小权限策略建议(示例,后端侧):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"version": "2.0",
|
||||||
|
"statement": [
|
||||||
|
{
|
||||||
|
"action": [
|
||||||
|
"name/cos:PutObject",
|
||||||
|
"name/cos:InitiateMultipartUpload",
|
||||||
|
"name/cos:UploadPart",
|
||||||
|
"name/cos:CompleteMultipartUpload"
|
||||||
|
],
|
||||||
|
"effect": "allow",
|
||||||
|
"resource": [
|
||||||
|
"qcs::cos:ap-shanghai:uid/125xxxxxxx:your-bucket-125xxxxxxx/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:对上传路径进行前缀限制(如 `uploads/*`)能进一步收敛权限范围。
|
||||||
|
|
||||||
|
## 前端用法一:Hook(推荐)
|
||||||
|
在页面/组件内使用 `useCosUpload`,支持进度、取消与重试。
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { useCosUpload } from '@/hooks/useCosUpload';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
|
||||||
|
export default function Uploader() {
|
||||||
|
const { upload, progress, uploading, cancel } = useCosUpload({ prefix: 'images/', userId: 'uid-123' });
|
||||||
|
|
||||||
|
const pickAndUpload = async () => {
|
||||||
|
const res = await ImagePicker.launchImageLibraryAsync({ mediaTypes: ImagePicker.MediaTypeOptions.Images });
|
||||||
|
if (res.canceled || !res.assets?.[0]) return;
|
||||||
|
const asset = res.assets[0];
|
||||||
|
const result = await upload({ uri: asset.uri, name: asset.fileName, type: asset.mimeType });
|
||||||
|
console.log('uploaded:', result); // { key, url }
|
||||||
|
};
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
说明:
|
||||||
|
- `progress` 范围 0-1;`uploading` 指示进行中;可随时 `cancel()`。
|
||||||
|
- 未开启公网访问时,`url` 可能为空(需服务端签名下载)。
|
||||||
|
|
||||||
|
## 前端用法二:直接调用服务
|
||||||
|
`services/cos.ts` 提供基础方法:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { uploadWithRetry } from '@/services/cos';
|
||||||
|
|
||||||
|
const { key, etag } = await uploadWithRetry({
|
||||||
|
key: 'uploads/demo.png',
|
||||||
|
body: blob,
|
||||||
|
contentType: 'image/png'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- 进度回调:`onProgress: ({ percent }) => { ... }`
|
||||||
|
- 取消:传入 `signal: AbortSignal`
|
||||||
|
- 重试:`maxRetries`/`backoffMs` 配置指数退避
|
||||||
|
|
||||||
|
## URL 构建
|
||||||
|
若为公网可访问场景,使用:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { buildPublicUrl } from '@/constants/Cos';
|
||||||
|
const url = buildPublicUrl(key);
|
||||||
|
```
|
||||||
|
|
||||||
|
若为私有桶,请改走服务端生成临时下载链接或代理下载。
|
||||||
|
|
||||||
|
## 大文件与分片上传
|
||||||
|
当前默认使用 `putObject`。若需大文件/断点续传,建议切换 SDK 的分片接口 `sliceUploadFile`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const taskId = cos.sliceUploadFile({
|
||||||
|
Bucket: COS_BUCKET,
|
||||||
|
Region: COS_REGION,
|
||||||
|
Key: key,
|
||||||
|
Body: fileOrBlob,
|
||||||
|
onProgress(progress) { /* ... */ }
|
||||||
|
}, (err, data) => { /* ... */ });
|
||||||
|
```
|
||||||
|
|
||||||
|
如需切换,可在 `services/cos.ts` 中将 `putObject` 替换为 `sliceUploadFile`,并保留同样的进度、取消与重试包装。
|
||||||
|
|
||||||
|
## CORS 与浏览器(如使用 Web)
|
||||||
|
- COS 控制台需配置 CORS,允许来源域名、方法(PUT/POST/OPTIONS)、请求头(含 `Authorization`、`x-cos-*` 等)、暴露头。
|
||||||
|
- 若通过自有域名/网关代理,也需在代理层正确透传 CORS 与签名头。
|
||||||
|
|
||||||
|
## 安全与最佳实践
|
||||||
|
- 永久密钥仅存在服务端,通过 `/api/users/cos-token` 颁发短期凭证。
|
||||||
|
- 严控临时凭证权限与有效期,并限制上传前缀。
|
||||||
|
- 前端仅持有短期凭证;错误重试次数受限,避免暴力重放。
|
||||||
|
|
||||||
|
## 常见问题排查
|
||||||
|
- 403/签名失败:确认服务端时间同步、`region/bucket`/`resource` 配置正确;临时密钥未过期。
|
||||||
|
- CORS 失败:检查 COS 跨域规则;确认需要的请求头(含 `Authorization`)均已放行。
|
||||||
|
- URL 访问 403:私有桶下请改用服务端签名下载;或为指定前缀开启公共读(谨慎)。
|
||||||
|
- MIME/ContentType:从 ImagePicker 取 `mimeType`,或按扩展名兜底设置。
|
||||||
|
|
||||||
|
## 测试清单
|
||||||
|
- 小图上传成功且进度递增、可取消。
|
||||||
|
- 取消后不再继续发片段;重试如期生效。
|
||||||
|
- 返回的 `key` 能通过 `buildPublicUrl` 拼出可访问地址(公网桶)。
|
||||||
|
- 临时密钥过期后,能够重新获取并继续上传。
|
||||||
|
|
||||||
|
## 变更点速记
|
||||||
|
- 依赖:`cos-js-sdk-v5`
|
||||||
|
- 新增:`constants/Cos.ts`、`services/cos.ts`、`hooks/useCosUpload.ts`
|
||||||
|
- 接口:`GET /api/users/cos-token`(返回临时凭证)
|
||||||
|
|
||||||
|
---
|
||||||
|
如需我将某页面接入示例按钮或改造为分片上传,请在需求中指明目标文件与交互期望。
|
||||||
59
hooks/useCosUpload.ts
Normal file
59
hooks/useCosUpload.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { buildCosKey, buildPublicUrl } from '@/constants/Cos';
|
||||||
|
import { uploadWithRetry } from '@/services/cos';
|
||||||
|
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
export type UseCosUploadOptions = {
|
||||||
|
prefix?: string;
|
||||||
|
userId?: string;
|
||||||
|
contentType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCosUpload(defaultOptions?: UseCosUploadOptions) {
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
const cancel = useCallback(() => {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const upload = useCallback(
|
||||||
|
async (file: { uri?: string; name?: string; type?: string; buffer?: any; blob?: Blob } | Blob | any, options?: UseCosUploadOptions) => {
|
||||||
|
const finalOptions = { ...(defaultOptions || {}), ...(options || {}) };
|
||||||
|
const extGuess = (() => {
|
||||||
|
const name = (file && (file.name || (file as any).filename)) || '';
|
||||||
|
const match = name.match(/\.([a-zA-Z0-9]+)$/);
|
||||||
|
return match ? match[1] : undefined;
|
||||||
|
})();
|
||||||
|
const key = buildCosKey({ prefix: finalOptions.prefix, userId: finalOptions.userId, ext: extGuess });
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
setProgress(0);
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
let body = (file as any)?.blob || (file as any)?.buffer || file;
|
||||||
|
// Expo ImagePicker 返回 { uri } 时,转换为 Blob
|
||||||
|
if (!body && (file as any)?.uri) {
|
||||||
|
const resp = await fetch((file as any).uri);
|
||||||
|
body = await resp.blob();
|
||||||
|
}
|
||||||
|
const res = await uploadWithRetry({
|
||||||
|
key,
|
||||||
|
body,
|
||||||
|
contentType: finalOptions.contentType || (file as any)?.type,
|
||||||
|
signal: controller.signal,
|
||||||
|
onProgress: ({ percent }) => setProgress(percent),
|
||||||
|
});
|
||||||
|
const url = buildPublicUrl(res.key);
|
||||||
|
return { key: res.key, url };
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[defaultOptions]
|
||||||
|
);
|
||||||
|
|
||||||
|
return useMemo(() => ({ upload, cancel, progress, uploading }), [upload, cancel, progress, uploading]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
411
ios/Podfile.lock
411
ios/Podfile.lock
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"expo.jsEngine": "hermes",
|
"expo.jsEngine": "jsc",
|
||||||
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
|
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
|
||||||
"newArchEnabled": "true"
|
"newArchEnabled": "true"
|
||||||
}
|
}
|
||||||
@@ -145,7 +145,6 @@
|
|||||||
13B07F8E1A680F5B00A75B9A /* Resources */,
|
13B07F8E1A680F5B00A75B9A /* Resources */,
|
||||||
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
||||||
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
|
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
|
||||||
761236F3114550442BC2DA44 /* [CP] Embed Pods Frameworks */,
|
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -258,24 +257,6 @@
|
|||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-digitalpilates/expo-configure-project.sh\"\n";
|
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-digitalpilates/expo-configure-project.sh\"\n";
|
||||||
};
|
};
|
||||||
761236F3114550442BC2DA44 /* [CP] Embed Pods Frameworks */ = {
|
|
||||||
isa = PBXShellScriptBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
inputPaths = (
|
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-digitalpilates/Pods-digitalpilates-frameworks.sh",
|
|
||||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
|
|
||||||
);
|
|
||||||
name = "[CP] Embed Pods Frameworks";
|
|
||||||
outputPaths = (
|
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
shellPath = /bin/sh;
|
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-digitalpilates/Pods-digitalpilates-frameworks.sh\"\n";
|
|
||||||
showEnvVarsInLog = 0;
|
|
||||||
};
|
|
||||||
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
|
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -461,7 +442,7 @@
|
|||||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) DEBUG";
|
||||||
USE_HERMES = true;
|
USE_HERMES = false;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -518,7 +499,7 @@
|
|||||||
);
|
);
|
||||||
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
USE_HERMES = true;
|
USE_HERMES = false;
|
||||||
VALIDATE_PRODUCT = YES;
|
VALIDATE_PRODUCT = YES;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.0</string>
|
<string>1.0.2</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1</string>
|
<string>2</string>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
|||||||
58
package-lock.json
generated
58
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "digital-pilates",
|
"name": "digital-pilates",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "digital-pilates",
|
"name": "digital-pilates",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/vector-icons": "^14.1.0",
|
"@expo/vector-icons": "^14.1.0",
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
@@ -15,6 +15,7 @@
|
|||||||
"@react-navigation/elements": "^2.3.8",
|
"@react-navigation/elements": "^2.3.8",
|
||||||
"@react-navigation/native": "^7.1.6",
|
"@react-navigation/native": "^7.1.6",
|
||||||
"@reduxjs/toolkit": "^2.8.2",
|
"@reduxjs/toolkit": "^2.8.2",
|
||||||
|
"cos-js-sdk-v5": "^1.6.0",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"expo": "~53.0.20",
|
"expo": "~53.0.20",
|
||||||
"expo-apple-authentication": "6.4.2",
|
"expo-apple-authentication": "6.4.2",
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
"react-native": "0.79.5",
|
"react-native": "0.79.5",
|
||||||
"react-native-gesture-handler": "~2.24.0",
|
"react-native-gesture-handler": "~2.24.0",
|
||||||
"react-native-health": "^1.19.0",
|
"react-native-health": "^1.19.0",
|
||||||
|
"react-native-image-viewing": "^0.2.2",
|
||||||
"react-native-modal-datetime-picker": "^18.0.0",
|
"react-native-modal-datetime-picker": "^18.0.0",
|
||||||
"react-native-reanimated": "~3.17.4",
|
"react-native-reanimated": "~3.17.4",
|
||||||
"react-native-safe-area-context": "5.4.0",
|
"react-native-safe-area-context": "5.4.0",
|
||||||
@@ -5182,6 +5184,15 @@
|
|||||||
"url": "https://opencollective.com/core-js"
|
"url": "https://opencollective.com/core-js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cos-js-sdk-v5": {
|
||||||
|
"version": "1.10.1",
|
||||||
|
"resolved": "https://mirrors.tencent.com/npm/cos-js-sdk-v5/-/cos-js-sdk-v5-1.10.1.tgz",
|
||||||
|
"integrity": "sha512-a4SRfCY5g6Z35C7OWe9te/S1zk77rVQzfpvZ33gmTdJQzKxbNbEG7Aw/v453XwVMsQB352FIf7KRMm5Ya/wlZQ==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"dependencies": {
|
||||||
|
"fast-xml-parser": "4.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cosmiconfig": {
|
"node_modules/cosmiconfig": {
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.2.1.tgz",
|
||||||
@@ -6776,6 +6787,28 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-xml-parser": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://mirrors.tencent.com/npm/fast-xml-parser/-/fast-xml-parser-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-/PlTQCI96+fZMAOLMZK4CWG1ItCbfZ/0jx7UIJFChPNrx7tcEgerUgWbeieCM9MfHInUDyK8DWYZ+YrywDJuTg==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "paypal",
|
||||||
|
"url": "https://paypal.me/naturalintelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"strnum": "^1.0.5"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"fxparser": "src/cli/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fastq": {
|
"node_modules/fastq": {
|
||||||
"version": "1.19.1",
|
"version": "1.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz",
|
||||||
@@ -10691,6 +10724,16 @@
|
|||||||
"node": ">=8.0"
|
"node": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-native-image-viewing": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://mirrors.tencent.com/npm/react-native-image-viewing/-/react-native-image-viewing-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-osWieG+p/d2NPbAyonOMubttajtYEYiRGQaJA54slFxZ69j1V4/dCmcrVQry47ktVKy8/qpFwCpW1eT6MH5T2Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=16.11.0",
|
||||||
|
"react-native": ">=0.61.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-native-is-edge-to-edge": {
|
"node_modules/react-native-is-edge-to-edge": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz",
|
||||||
@@ -12197,6 +12240,17 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strnum": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://mirrors.tencent.com/npm/strnum/-/strnum-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/structured-headers": {
|
"node_modules/structured-headers": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/structured-headers/-/structured-headers-0.4.1.tgz",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "digital-pilates",
|
"name": "digital-pilates",
|
||||||
"main": "expo-router/entry",
|
"main": "expo-router/entry",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"reset-project": "node ./scripts/reset-project.js",
|
"reset-project": "node ./scripts/reset-project.js",
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"lint": "expo lint"
|
"lint": "expo lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"cos-js-sdk-v5": "^1.6.0",
|
||||||
"@expo/vector-icons": "^14.1.0",
|
"@expo/vector-icons": "^14.1.0",
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
"@react-native-community/datetimepicker": "^8.4.4",
|
"@react-native-community/datetimepicker": "^8.4.4",
|
||||||
@@ -40,6 +41,7 @@
|
|||||||
"react-native": "0.79.5",
|
"react-native": "0.79.5",
|
||||||
"react-native-gesture-handler": "~2.24.0",
|
"react-native-gesture-handler": "~2.24.0",
|
||||||
"react-native-health": "^1.19.0",
|
"react-native-health": "^1.19.0",
|
||||||
|
"react-native-image-viewing": "^0.2.2",
|
||||||
"react-native-modal-datetime-picker": "^18.0.0",
|
"react-native-modal-datetime-picker": "^18.0.0",
|
||||||
"react-native-reanimated": "~3.17.4",
|
"react-native-reanimated": "~3.17.4",
|
||||||
"react-native-safe-area-context": "5.4.0",
|
"react-native-safe-area-context": "5.4.0",
|
||||||
|
|||||||
107
services/cos.ts
Normal file
107
services/cos.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { COS_BUCKET, COS_REGION } from '@/constants/Cos';
|
||||||
|
import { api } from '@/services/api';
|
||||||
|
|
||||||
|
type CosCredential = {
|
||||||
|
credentials: {
|
||||||
|
tmpSecretId: string;
|
||||||
|
tmpSecretKey: string;
|
||||||
|
sessionToken: string;
|
||||||
|
};
|
||||||
|
startTime?: number;
|
||||||
|
expiredTime?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UploadOptions = {
|
||||||
|
key: string;
|
||||||
|
body: any;
|
||||||
|
contentType?: string;
|
||||||
|
onProgress?: (progress: { percent: number }) => void;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
};
|
||||||
|
|
||||||
|
let CosSdk: any | null = null;
|
||||||
|
|
||||||
|
async function ensureCosSdk(): Promise<any> {
|
||||||
|
if (CosSdk) return CosSdk;
|
||||||
|
// 动态导入避免影响首屏
|
||||||
|
const mod = await import('cos-js-sdk-v5');
|
||||||
|
CosSdk = mod.default ?? mod;
|
||||||
|
return CosSdk;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCredential(): Promise<CosCredential> {
|
||||||
|
return await api.get<CosCredential>('/users/cos-token');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadToCos(options: UploadOptions): Promise<{ key: string; etag?: string; headers?: Record<string, string> }> {
|
||||||
|
const { key, body, contentType, onProgress, signal } = options;
|
||||||
|
if (!COS_BUCKET || !COS_REGION) {
|
||||||
|
throw new Error('未配置 COS_BUCKET / COS_REGION');
|
||||||
|
}
|
||||||
|
const COS = await ensureCosSdk();
|
||||||
|
const cred = await fetchCredential();
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
if (signal) {
|
||||||
|
if (signal.aborted) controller.abort();
|
||||||
|
signal.addEventListener('abort', () => controller.abort(), { once: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const cos = new COS({
|
||||||
|
getAuthorization: (_opts: any, cb: any) => {
|
||||||
|
cb({
|
||||||
|
TmpSecretId: cred.credentials.tmpSecretId,
|
||||||
|
TmpSecretKey: cred.credentials.tmpSecretKey,
|
||||||
|
SecurityToken: cred.credentials.sessionToken,
|
||||||
|
StartTime: cred.startTime,
|
||||||
|
ExpiredTime: cred.expiredTime,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const task = cos.putObject(
|
||||||
|
{
|
||||||
|
Bucket: COS_BUCKET,
|
||||||
|
Region: COS_REGION,
|
||||||
|
Key: key,
|
||||||
|
Body: body,
|
||||||
|
ContentType: contentType,
|
||||||
|
onProgress: (progressData: any) => {
|
||||||
|
if (onProgress) {
|
||||||
|
const percent = progressData && progressData.percent ? progressData.percent : 0;
|
||||||
|
onProgress({ percent });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(err: any, data: any) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
resolve({ key, etag: data && data.ETag, headers: data && data.headers });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.signal.addEventListener('abort', () => {
|
||||||
|
try { task && task.cancel && task.cancel(); } catch { }
|
||||||
|
reject(new DOMException('Aborted', 'AbortError'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function uploadWithRetry(options: UploadOptions & { maxRetries?: number; backoffMs?: number }): Promise<{ key: string; etag?: string }> {
|
||||||
|
const { maxRetries = 2, backoffMs = 800, ...rest } = options;
|
||||||
|
let attempt = 0;
|
||||||
|
// 简单指数退避
|
||||||
|
while (true) {
|
||||||
|
try {
|
||||||
|
return await uploadToCos(rest);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (rest.signal?.aborted) throw e;
|
||||||
|
if (attempt >= maxRetries) throw e;
|
||||||
|
const wait = backoffMs * Math.pow(2, attempt);
|
||||||
|
await new Promise(r => setTimeout(r, wait));
|
||||||
|
attempt++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
46
types/react-native-svg.d.ts
vendored
46
types/react-native-svg.d.ts
vendored
@@ -34,6 +34,52 @@ declare module 'react-native-svg' {
|
|||||||
originY?: number;
|
originY?: number;
|
||||||
}
|
}
|
||||||
export const G: React.ComponentType<React.PropsWithChildren<GProps>>;
|
export const G: React.ComponentType<React.PropsWithChildren<GProps>>;
|
||||||
|
|
||||||
|
export interface DefsProps { }
|
||||||
|
export const Defs: React.ComponentType<React.PropsWithChildren<DefsProps>>;
|
||||||
|
|
||||||
|
export interface LineProps extends CommonProps {
|
||||||
|
x1?: number | string;
|
||||||
|
y1?: number | string;
|
||||||
|
x2?: number | string;
|
||||||
|
y2?: number | string;
|
||||||
|
}
|
||||||
|
export const Line: React.ComponentType<LineProps>;
|
||||||
|
|
||||||
|
export interface LinearGradientProps {
|
||||||
|
id?: string;
|
||||||
|
x1?: number | string;
|
||||||
|
y1?: number | string;
|
||||||
|
x2?: number | string;
|
||||||
|
y2?: number | string;
|
||||||
|
}
|
||||||
|
export const LinearGradient: React.ComponentType<React.PropsWithChildren<LinearGradientProps>>;
|
||||||
|
|
||||||
|
export interface StopProps {
|
||||||
|
offset?: number | string;
|
||||||
|
stopColor?: string;
|
||||||
|
stopOpacity?: number;
|
||||||
|
}
|
||||||
|
export const Stop: React.ComponentType<StopProps>;
|
||||||
|
|
||||||
|
export interface PolygonProps extends CommonProps {
|
||||||
|
points?: string;
|
||||||
|
}
|
||||||
|
export const Polygon: React.ComponentType<PolygonProps>;
|
||||||
|
|
||||||
|
export interface PathProps extends CommonProps {
|
||||||
|
d?: string;
|
||||||
|
}
|
||||||
|
export const Path: React.ComponentType<PathProps>;
|
||||||
|
|
||||||
|
export interface TextProps extends CommonProps {
|
||||||
|
x?: number | string;
|
||||||
|
y?: number | string;
|
||||||
|
fontSize?: number | string;
|
||||||
|
fill?: string;
|
||||||
|
textAnchor?: 'start' | 'middle' | 'end';
|
||||||
|
}
|
||||||
|
export const Text: React.ComponentType<React.PropsWithChildren<TextProps>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user