将应用主色调从 '#BBF246' 更改为 '#87CEEB'(天空蓝),并更新所有相关组件和页面中的颜色引用。同时为多个页面添加统一的渐变背景,提升视觉效果和用户体验。新增压力分析模态框组件,并优化压力计组件的交互与显示逻辑。更新应用图标和启动图资源。
587 lines
18 KiB
TypeScript
587 lines
18 KiB
TypeScript
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 {
|
||
ActivityIndicator,
|
||
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';
|
||
import { useCosUpload } from '@/hooks/useCosUpload';
|
||
|
||
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 { upload, uploading } = useCosUpload();
|
||
const [uploadingKey, setUploadingKey] = useState<PoseView | null>(null);
|
||
|
||
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) {
|
||
// 设置正在上传状态
|
||
setUploadingKey(key);
|
||
try {
|
||
// 上传到 COS
|
||
const { url } = await upload(
|
||
{ uri: result.assets[0]?.uri ?? '', name: `posture-${key}.jpg`, type: 'image/jpeg' },
|
||
{ prefix: 'posture-assessment/' }
|
||
);
|
||
// 上传成功,更新状态
|
||
setUploadState((s) => ({ ...s, [key]: url }));
|
||
} catch (uploadError) {
|
||
console.warn('上传图片失败', uploadError);
|
||
Alert.alert('上传失败', '图片上传失败,请重试');
|
||
// 上传失败,清除状态
|
||
setUploadState((s) => ({ ...s, [key]: null }));
|
||
} finally {
|
||
// 清除上传状态
|
||
setUploadingKey(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) {
|
||
// 设置正在上传状态
|
||
setUploadingKey(key);
|
||
try {
|
||
// 上传到 COS
|
||
const { url } = await upload(
|
||
{ uri: result.assets[0]?.uri ?? '', name: `posture-${key}.jpg`, type: 'image/jpeg' },
|
||
{ prefix: 'posture-assessment/' }
|
||
);
|
||
// 上传成功,更新状态
|
||
setUploadState((s) => ({ ...s, [key]: url }));
|
||
} catch (uploadError) {
|
||
console.warn('上传图片失败', uploadError);
|
||
Alert.alert('上传失败', '图片上传失败,请重试');
|
||
// 上传失败,清除状态
|
||
setUploadState((s) => ({ ...s, [key]: null }));
|
||
} finally {
|
||
// 清除上传状态
|
||
setUploadingKey(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}
|
||
uploading={uploading && uploadingKey === 'front'}
|
||
/>
|
||
|
||
<UploadTile
|
||
label="侧面"
|
||
value={uploadState.side}
|
||
onPickCamera={() => requestPermissionAndPick('camera', 'side')}
|
||
onPickLibrary={() => requestPermissionAndPick('library', 'side')}
|
||
samples={SAMPLES.side}
|
||
uploading={uploading && uploadingKey === 'side'}
|
||
/>
|
||
|
||
<UploadTile
|
||
label="背面"
|
||
value={uploadState.back}
|
||
onPickCamera={() => requestPermissionAndPick('camera', 'back')}
|
||
onPickLibrary={() => requestPermissionAndPick('library', 'back')}
|
||
samples={SAMPLES.back}
|
||
uploading={uploading && uploadingKey === '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,
|
||
uploading,
|
||
}: {
|
||
label: string;
|
||
value?: string | null;
|
||
onPickCamera: () => void;
|
||
onPickLibrary: () => void;
|
||
samples: Sample[];
|
||
uploading?: boolean;
|
||
}) {
|
||
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}
|
||
disabled={uploading}
|
||
>
|
||
{uploading ? (
|
||
<View style={[styles.placeholder, { backgroundColor: '#f5f5f5' }]}>
|
||
<ActivityIndicator size="large" color={Colors.light.accentGreen} />
|
||
<Text style={styles.placeholderTitle}>上传中...</Text>
|
||
</View>
|
||
) : value ? (
|
||
<Image source={{ uri: value }} style={styles.preview} />
|
||
) : (
|
||
<View style={styles.placeholder}>
|
||
<View style={styles.plusBadge}>
|
||
<Ionicons name="camera" size={16} color={Colors.light.accentGreen} />
|
||
</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: Colors.light.accentGreen,
|
||
},
|
||
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: Colors.light.accentGreen,
|
||
},
|
||
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',
|
||
},
|
||
});
|
||
|