Files
digital-pilates/app/ai-posture-assessment.tsx
richarjiang 321947db98 feat: 更新应用版本和集成腾讯云 COS 上传功能
- 将应用版本更新至 1.0.2,修改相关配置文件
- 集成腾讯云 COS 上传功能,新增相关服务和钩子
- 更新 AI 体态评估页面,支持照片上传和评估结果展示
- 添加雷达图组件以展示评估结果
- 更新样式以适应新功能的展示和交互
- 修改登录页面背景效果,提升用户体验
2025-08-13 15:21:54 +08:00

535 lines
16 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 { BlurView } from 'expo-blur';
import * as ImagePicker from 'expo-image-picker';
import { useRouter } from 'expo-router';
import React, { useEffect, useMemo, useState } from 'react';
import {
Alert,
Image,
Linking,
Platform,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from 'react-native';
import ImageViewing from 'react-native-image-viewing';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { HeaderBar } from '@/components/ui/HeaderBar';
import { Colors } from '@/constants/Colors';
type PoseView = 'front' | 'side' | 'back';
type UploadState = {
front?: string | null;
side?: string | null;
back?: string | null;
};
type Sample = { uri: string; correct: boolean };
const SAMPLES: Record<PoseView, Sample[]> = {
front: [
{ 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-1571019614242-c5c5dee9f50b?w=400&q=80&auto=format', correct: false },
],
side: [
{ 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-1526506118085-60ce8714f8c5?w=400&q=80&auto=format', correct: false },
],
back: [
{ 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-1518611012118-696072aa579a?w=400&q=80&auto=format', correct: false },
],
};
export default function AIPostureAssessmentScreen() {
const router = useRouter();
const insets = useSafeAreaInsets();
const theme = Colors.light;
const [uploadState, setUploadState] = useState<UploadState>({});
const canStart = useMemo(
() => Boolean(uploadState.front && uploadState.side && uploadState.back),
[uploadState]
);
const [cameraPerm, setCameraPerm] = useState<ImagePicker.PermissionStatus | null>(null);
const [libraryPerm, setLibraryPerm] = useState<ImagePicker.PermissionStatus | null>(null);
const [libraryAccess, setLibraryAccess] = useState<'all' | 'limited' | 'none' | null>(null);
const [cameraCanAsk, setCameraCanAsk] = useState<boolean | null>(null);
const [libraryCanAsk, setLibraryCanAsk] = useState<boolean | null>(null);
useEffect(() => {
(async () => {
const cam = await ImagePicker.getCameraPermissionsAsync();
const lib = await ImagePicker.getMediaLibraryPermissionsAsync();
setCameraPerm(cam.status);
setLibraryPerm(lib.status);
setLibraryAccess(
(lib as any).accessPrivileges ?? (lib.status === 'granted' ? 'all' : 'none')
);
setCameraCanAsk(cam.canAskAgain);
setLibraryCanAsk(lib.canAskAgain);
})();
}, []);
async function requestAllPermissions() {
try {
const cam = await ImagePicker.requestCameraPermissionsAsync();
const lib = await ImagePicker.requestMediaLibraryPermissionsAsync();
setCameraPerm(cam.status);
setLibraryPerm(lib.status);
setLibraryAccess(
(lib as any).accessPrivileges ?? (lib.status === 'granted' ? 'all' : 'none')
);
setCameraCanAsk(cam.canAskAgain);
setLibraryCanAsk(lib.canAskAgain);
const libGranted = lib.status === 'granted' || (lib as any).accessPrivileges === 'limited';
if (cam.status !== 'granted' || !libGranted) {
Alert.alert(
'权限未完全授予',
'请在系统设置中授予相机与相册权限以完成上传',
[
{ text: '取消', style: 'cancel' },
{ text: '去设置', onPress: () => Linking.openSettings() },
]
);
}
} catch { }
}
async function requestPermissionAndPick(source: 'camera' | 'library', key: PoseView) {
try {
if (source === 'camera') {
const resp = await ImagePicker.requestCameraPermissionsAsync();
setCameraPerm(resp.status);
setCameraCanAsk(resp.canAskAgain);
if (resp.status !== 'granted') {
Alert.alert(
'权限不足',
'需要相机权限以拍摄照片',
resp.canAskAgain
? [{ text: '好的' }]
: [
{ text: '取消', style: 'cancel' },
{ text: '去设置', onPress: () => Linking.openSettings() },
]
);
return;
}
const result = await ImagePicker.launchCameraAsync({
allowsEditing: true,
quality: 0.8,
aspect: [3, 4],
});
if (!result.canceled) {
setUploadState((s) => ({ ...s, [key]: result.assets[0]?.uri ?? null }));
}
} else {
const resp = await ImagePicker.requestMediaLibraryPermissionsAsync();
setLibraryPerm(resp.status);
setLibraryAccess(
(resp as any).accessPrivileges ?? (resp.status === 'granted' ? 'all' : 'none')
);
setLibraryCanAsk(resp.canAskAgain);
const libGranted = resp.status === 'granted' || (resp as any).accessPrivileges === 'limited';
if (!libGranted) {
Alert.alert(
'权限不足',
'需要相册权限以选择照片',
resp.canAskAgain
? [{ text: '好的' }]
: [
{ text: '取消', style: 'cancel' },
{ text: '去设置', onPress: () => Linking.openSettings() },
]
);
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
allowsEditing: true,
quality: 0.8,
aspect: [3, 4],
});
if (!result.canceled) {
setUploadState((s) => ({ ...s, [key]: result.assets[0]?.uri ?? null }));
}
}
} catch (e) {
Alert.alert('发生错误', '选择图片失败,请重试');
}
}
function handleStart() {
if (!canStart) return;
// 进入评估中间页面
router.push('/ai-posture-processing');
}
return (
<View style={[styles.screen, { backgroundColor: Colors.light.pageBackgroundEmphasis }]}>
<HeaderBar title="AI体态测评" onBack={() => router.back()} tone="light" transparent />
<ScrollView
contentContainerStyle={{ paddingBottom: insets.bottom + 120 }}
showsVerticalScrollIndicator={false}
>
{/* Permissions Banner (iOS 优先提示) */}
{Platform.OS === 'ios' && (
(cameraPerm !== 'granted' || !(libraryPerm === 'granted' || libraryAccess === 'limited')) && (
<BlurView intensity={18} tint="dark" style={styles.permBanner}>
<Text style={styles.permTitle}></Text>
<Text style={styles.permDesc}>
AI体态测评
</Text>
<View style={styles.permActions}>
{((cameraCanAsk ?? true) || (libraryCanAsk ?? true)) ? (
<TouchableOpacity style={styles.permPrimary} onPress={requestAllPermissions}>
<Text style={styles.permPrimaryText}></Text>
</TouchableOpacity>
) : (
<TouchableOpacity style={styles.permPrimary} onPress={() => Linking.openSettings()}>
<Text style={styles.permPrimaryText}></Text>
</TouchableOpacity>
)}
<TouchableOpacity style={styles.permSecondary} onPress={() => requestPermissionAndPick('library', 'front')}>
<Text style={styles.permSecondaryText}></Text>
</TouchableOpacity>
</View>
</BlurView>
)
)}
{/* Intro */}
<View style={styles.introBox}>
<Text style={[styles.title, { color: '#192126' }]}>姿</Text>
<Text style={[styles.description, { color: '#5E6468' }]}>线</Text>
</View>
{/* Upload sections */}
<UploadTile
label="正面"
value={uploadState.front}
onPickCamera={() => requestPermissionAndPick('camera', 'front')}
onPickLibrary={() => requestPermissionAndPick('library', 'front')}
samples={SAMPLES.front}
/>
<UploadTile
label="侧面"
value={uploadState.side}
onPickCamera={() => requestPermissionAndPick('camera', 'side')}
onPickLibrary={() => requestPermissionAndPick('library', 'side')}
samples={SAMPLES.side}
/>
<UploadTile
label="背面"
value={uploadState.back}
onPickCamera={() => requestPermissionAndPick('camera', 'back')}
onPickLibrary={() => requestPermissionAndPick('library', 'back')}
samples={SAMPLES.back}
/>
</ScrollView>
{/* Bottom CTA */}
<View style={[styles.bottomCtaWrap, { paddingBottom: insets.bottom + 10 }]}>
<TouchableOpacity
disabled={!canStart}
activeOpacity={1}
onPress={handleStart}
style={[
styles.bottomCta,
{ backgroundColor: canStart ? theme.primary : theme.neutral300 },
]}
>
<Text style={[styles.bottomCtaText, { color: canStart ? theme.onPrimary : theme.textMuted }]}>
{canStart ? '开始测评' : '请先完成三视角上传'}
</Text>
</TouchableOpacity>
</View>
</View>
);
}
function UploadTile({
label,
value,
onPickCamera,
onPickLibrary,
samples,
}: {
label: string;
value?: string | null;
onPickCamera: () => void;
onPickLibrary: () => void;
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 (
<View style={styles.section}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>{label}</Text>
{value ? (
<Text style={styles.retakeHint}></Text>
) : (
<Text style={styles.retakeHint}></Text>
)}
</View>
<TouchableOpacity
activeOpacity={0.95}
onLongPress={onPickLibrary}
onPress={onPickCamera}
style={styles.uploader}
>
{value ? (
<Image source={{ uri: value }} style={styles.preview} />
) : (
<View style={styles.placeholder}>
<View style={styles.plusBadge}>
<Ionicons name="camera" size={16} color="#BBF246" />
</View>
<Text style={styles.placeholderTitle}></Text>
<Text style={styles.placeholderDesc}></Text>
</View>
)}
</TouchableOpacity>
<BlurView intensity={12} tint="light" style={styles.sampleBox}>
<Text style={styles.sampleTitle}></Text>
<View style={styles.sampleRow}>
{samples.map((s, idx) => (
<View key={idx} style={styles.sampleItem}>
<TouchableOpacity activeOpacity={0.9} onPress={() => { setViewerIndex(idx); setViewerVisible(true); }}>
<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>
</View>
</View>
))}
</View>
</BlurView>
<ImageViewing
images={imagesForViewer}
imageIndex={viewerIndex}
visible={viewerVisible}
onRequestClose={() => setViewerVisible(false)}
/>
</View>
);
}
const styles = StyleSheet.create({
screen: {
flex: 1,
},
permBanner: {
marginTop: 12,
marginHorizontal: 16,
padding: 14,
borderRadius: 16,
backgroundColor: 'rgba(25,33,38,0.06)'
},
permTitle: {
color: '#192126',
fontSize: 16,
fontWeight: '700',
},
permDesc: {
color: '#5E6468',
marginTop: 6,
fontSize: 13,
},
permActions: {
flexDirection: 'row',
gap: 10,
marginTop: 10,
},
permPrimary: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 14,
height: 40,
borderRadius: 12,
backgroundColor: '#BBF246',
},
permPrimaryText: {
color: '#192126',
fontSize: 14,
fontWeight: '800',
},
permSecondary: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
paddingHorizontal: 14,
height: 40,
borderRadius: 12,
borderWidth: 1,
borderColor: 'rgba(25,33,38,0.14)',
},
permSecondaryText: {
color: '#384046',
fontSize: 14,
fontWeight: '700',
},
header: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 16,
},
backButton: {
width: 32,
height: 32,
borderRadius: 16,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'rgba(255,255,255,0.06)',
},
headerTitle: {
fontSize: 22,
color: '#ECEDEE',
fontWeight: '700',
},
introBox: {
marginTop: 12,
paddingHorizontal: 20,
gap: 10,
},
title: {
fontSize: 26,
color: '#ECEDEE',
fontWeight: '800',
},
description: {
fontSize: 15,
lineHeight: 22,
color: 'rgba(255,255,255,0.75)',
},
section: {
marginTop: 16,
paddingHorizontal: 16,
gap: 12,
},
sectionHeader: {
paddingHorizontal: 4,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
},
sectionTitle: {
color: '#192126',
fontSize: 18,
fontWeight: '700',
},
retakeHint: {
color: '#888F92',
fontSize: 13,
},
uploader: {
height: 220,
borderRadius: 18,
borderWidth: 1,
borderStyle: 'dashed',
borderColor: 'rgba(25,33,38,0.14)',
backgroundColor: '#FFFFFF',
overflow: 'hidden',
},
preview: {
width: '100%',
height: '100%',
},
placeholder: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: 8,
},
plusBadge: {
width: 36,
height: 36,
borderRadius: 18,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: '#FFFFFF',
borderWidth: 2,
borderColor: '#BBF246',
},
placeholderTitle: {
color: '#192126',
fontSize: 16,
fontWeight: '700',
},
placeholderDesc: {
color: '#888F92',
fontSize: 12,
},
sampleBox: {
marginTop: 8,
borderRadius: 16,
padding: 12,
backgroundColor: 'rgba(255,255,255,0.72)',
},
sampleTitle: {
color: '#192126',
fontSize: 14,
marginBottom: 8,
fontWeight: '600',
},
sampleRow: {
flexDirection: 'row',
gap: 10,
},
sampleItem: {
flex: 1,
},
sampleImg: {
width: '100%',
height: 90,
borderRadius: 12,
backgroundColor: '#F2F4F5',
},
sampleTag: {
alignSelf: 'flex-start',
paddingHorizontal: 8,
paddingVertical: 4,
borderRadius: 8,
marginTop: 6,
},
sampleTagText: {
color: '#192126',
fontSize: 12,
fontWeight: '700',
},
bottomCtaWrap: {
position: 'absolute',
left: 16,
right: 16,
bottom: 0,
},
bottomCta: {
height: 64,
borderRadius: 32,
alignItems: 'center',
justifyContent: 'center',
},
bottomCtaText: {
fontSize: 18,
fontWeight: '800',
},
});