diff --git a/.kilocode/rules/memory-bank/tasks.md b/.kilocode/rules/memory-bank/tasks.md index be36518..b523f69 100644 --- a/.kilocode/rules/memory-bank/tasks.md +++ b/.kilocode/rules/memory-bank/tasks.md @@ -145,4 +145,202 @@ const styles = StyleSheet.create({ ### 参考实现 - `app/food/nutrition-analysis-history.tsx` - 删除按钮实现 - `components/glass/button.tsx` - 通用 Glass 按钮组件 -- `app/(tabs)/_layout.tsx` - 标签栏按钮实现 \ No newline at end of file +- `app/(tabs)/_layout.tsx` - 标签栏按钮实现 + +## 登录验证实现模式 + +**最后更新**: 2025-10-16 + +### 问题描述 +在应用中实现需要登录才能访问的功能时,需要判断用户是否已登录,未登录时先跳转到登录页面。 + +### 解决方案 +使用 `useAuthGuard` hook 中的 `pushIfAuthedElseLogin` 方法处理需要登录验证的导航操作,使用 `ensureLoggedIn` 方法处理需要登录验证的功能实现。 + +### 权限校验原则 +**重要**: 功能实现如果包含服务端接口的调用,需要使用 `ensureLoggedIn` 来判断用户是否登录。 + +### 实现模式 + +#### 1. 导入必要的 hook +```typescript +import { useAuthGuard } from '@/hooks/useAuthGuard'; +``` + +#### 2. 在组件中获取方法 +```typescript +const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard(); +``` + +#### 3. 替换导航操作 +```typescript +// ❌ 原来的写法 - 没有登录验证 + router.push('/food/nutrition-analysis-history')} + activeOpacity={0.7} +> + +// ✅ 修改后的写法 - 带登录验证 + pushIfAuthedElseLogin('/food/nutrition-analysis-history')} + activeOpacity={0.7} +> +``` + +#### 4. 服务端接口调用的登录验证 +对于需要调用服务端接口的功能,使用 `ensureLoggedIn` 进行登录验证: + +```typescript +// ❌ 原来的写法 - 没有登录验证 + startNewAnalysis(imageUri)} + activeOpacity={0.8} +> + +// ✅ 修改后的写法 - 带登录验证 + { + // 先验证登录状态 + const isLoggedIn = await ensureLoggedIn(); + if (isLoggedIn) { + startNewAnalysis(imageUri); + } + }} + activeOpacity={0.8} +> +``` + +#### 5. 完整示例(包含 Liquid Glass 兼容性处理) +```typescript +{isLiquidGlassAvailable() ? ( + pushIfAuthedElseLogin('/food/nutrition-analysis-history')} + activeOpacity={0.7} + > + + + + +) : ( + pushIfAuthedElseLogin('/food/nutrition-analysis-history')} + style={[styles.historyButton, styles.fallbackBackground]} + activeOpacity={0.7} + > + + +)} +``` + +### 重要注意事项 +1. **统一体验**:使用 `pushIfAuthedElseLogin` 可以确保登录后自动跳转到目标页面 +2. **参数传递**:该方法支持传递路由参数,格式为 `pushIfAuthedElseLogin('/path', { param: value })` +3. **登录重定向**:登录页面会接收 `redirectTo` 和 `redirectParams` 参数用于登录后跳转 +4. **兼容性**:与 Liquid Glass 设计风格完全兼容,可以同时使用 +5. **服务端接口调用**:所有调用服务端接口的功能必须使用 `ensureLoggedIn` 进行登录验证 +6. **异步处理**:`ensureLoggedIn` 是异步函数,需要使用 `await` 等待结果 + +### 其他可用方法 +- `ensureLoggedIn()` - 检查登录状态,未登录时跳转到登录页面,返回布尔值表示是否已登录 +- `guardHandler(fn, options)` - 包装一个函数,在执行前确保用户已登录 +- `isLoggedIn` - 布尔值,表示当前用户是否已登录 + +### 使用场景选择 +- **页面导航**:使用 `pushIfAuthedElseLogin` 处理页面跳转 +- **服务端接口调用**:使用 `ensureLoggedIn` 验证登录状态后再执行功能 +- **函数包装**:使用 `guardHandler` 包装需要登录验证的函数 + +### 参考实现 +- `app/food/nutrition-label-analysis.tsx` - 成分表分析功能登录验证 +- `app/(tabs)/personal.tsx` - 个人中心编辑按钮 +- `hooks/useAuthGuard.ts` - 完整的认证守卫实现 + +## 路由常量管理 + +**最后更新**: 2025-10-16 + +### 问题描述 +在应用开发中,所有路由路径都应该使用常量定义,而不是硬编码字符串。这样可以确保路由的一致性,便于维护和重构。 + +### 解决方案 +将所有路由路径定义在 `constants/Routes.ts` 文件中,并在组件中使用这些常量。 + +### 实现模式 + +#### 1. 添加新路由常量 +在 `constants/Routes.ts` 文件中添加新的路由常量: + +```typescript +export const ROUTES = { + // 现有路由... + + // 新增路由 + FOOD_CAMERA: '/food/camera', +} as const; +``` + +#### 2. 在组件中使用路由常量 +导入并使用路由常量,而不是硬编码路径: + +```typescript +import { ROUTES } from '@/constants/Routes'; + +// ❌ 错误写法 - 硬编码路径 +router.push('/food/camera?mealType=dinner'); + +// ✅ 正确写法 - 使用路由常量 +router.push(`${ROUTES.FOOD_CAMERA}?mealType=dinner`); +``` + +#### 3. 结合登录验证使用 +对于需要登录验证的路由,结合 `pushIfAuthedElseLogin` 使用: + +```typescript +import { ROUTES } from '@/constants/Routes'; +import { useAuthGuard } from '@/hooks/useAuthGuard'; + +const { pushIfAuthedElseLogin } = useAuthGuard(); + +// 在需要登录验证的路由中使用 + pushIfAuthedElseLogin(`${ROUTES.FOOD_CAMERA}?mealType=${currentMealType}`)} + activeOpacity={0.7} +> +``` + +### 重要注意事项 +1. **统一管理**:所有路由路径都必须在 `constants/Routes.ts` 中定义 +2. **命名规范**:使用大写字母和下划线,如 `FOOD_CAMERA` +3. **路径一致性**:常量名应该清晰表达路由的用途 +4. **参数处理**:查询参数和路径参数在使用时动态拼接 +5. **类型安全**:使用 `as const` 确保类型推导 + +### 路由分类 +按照功能模块对路由进行分组: + +```typescript +export const ROUTES = { + // Tab路由 + TAB_EXPLORE: '/explore', + TAB_COACH: '/coach', + + // 营养相关路由 + NUTRITION_RECORDS: '/nutrition/records', + FOOD_LIBRARY: '/food-library', + FOOD_CAMERA: '/food/camera', + + // 用户相关路由 + AUTH_LOGIN: '/auth/login', + PROFILE_EDIT: '/profile/edit', +} as const; +``` + +### 参考实现 +- `constants/Routes.ts` - 路由常量定义 +- `components/NutritionRadarCard.tsx` - 使用路由常量和登录验证 +- `app/food/camera.tsx` - 食物拍照页面实现 \ No newline at end of file diff --git a/app/food/camera.tsx b/app/food/camera.tsx index 68d769a..81d4b3b 100644 --- a/app/food/camera.tsx +++ b/app/food/camera.tsx @@ -1,5 +1,6 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; +import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { Ionicons } from '@expo/vector-icons'; import { CameraType, CameraView, useCameraPermissions } from 'expo-camera'; @@ -24,6 +25,7 @@ export default function FoodCameraScreen() { const router = useRouter(); const params = useLocalSearchParams<{ mealType?: string }>(); const cameraRef = useRef(null); + const { ensureLoggedIn } = useAuthGuard(); const [currentMealType, setCurrentMealType] = useState( (params.mealType as MealType) || 'dinner' @@ -103,9 +105,12 @@ export default function FoodCameraScreen() { }); if (photo) { - // 跳转到食物识别页面 + // 先验证登录状态,再跳转到食物识别页面 console.log('照片拍摄成功:', photo.uri); - router.replace(`/food/food-recognition?imageUri=${encodeURIComponent(photo.uri)}&mealType=${currentMealType}`); + const isLoggedIn = await ensureLoggedIn(); + if (isLoggedIn) { + router.replace(`/food/food-recognition?imageUri=${encodeURIComponent(photo.uri)}&mealType=${currentMealType}`); + } } } catch (error) { console.error('拍照失败:', error); @@ -127,7 +132,11 @@ export default function FoodCameraScreen() { if (!result.canceled && result.assets[0]) { const imageUri = result.assets[0].uri; console.log('从相册选择的照片:', imageUri); - router.push(`/food/food-recognition?imageUri=${encodeURIComponent(imageUri)}&mealType=${currentMealType}`); + // 先验证登录状态,再跳转到食物识别页面 + const isLoggedIn = await ensureLoggedIn(); + if (isLoggedIn) { + router.push(`/food/food-recognition?imageUri=${encodeURIComponent(imageUri)}&mealType=${currentMealType}`); + } } } catch (error) { console.error('选择照片失败:', error); diff --git a/app/food/nutrition-label-analysis.tsx b/app/food/nutrition-label-analysis.tsx index adcfa77..d833adc 100644 --- a/app/food/nutrition-label-analysis.tsx +++ b/app/food/nutrition-label-analysis.tsx @@ -1,5 +1,6 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; +import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useCosUpload } from '@/hooks/useCosUpload'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { @@ -30,6 +31,7 @@ import ImageViewing from 'react-native-image-viewing'; export default function NutritionLabelAnalysisScreen() { const safeAreaTop = useSafeAreaTop(); const router = useRouter(); + const { pushIfAuthedElseLogin, ensureLoggedIn } = useAuthGuard(); const { upload, uploading: uploadingToCos, progress: uploadProgress } = useCosUpload({ prefix: 'nutrition-labels' }); @@ -186,7 +188,7 @@ export default function NutritionLabelAnalysisScreen() { right={ isLiquidGlassAvailable() ? ( router.push('/food/nutrition-analysis-history')} + onPress={() => pushIfAuthedElseLogin('/food/nutrition-analysis-history')} activeOpacity={0.7} > ) : ( router.push('/food/nutrition-analysis-history')} + onPress={() => pushIfAuthedElseLogin('/food/nutrition-analysis-history')} style={[styles.historyButton, styles.fallbackBackground]} activeOpacity={0.7} > @@ -241,7 +243,13 @@ export default function NutritionLabelAnalysisScreen() { {!isAnalyzing && !isUploading && !newAnalysisResult && ( startNewAnalysis(imageUri)} + onPress={async () => { + // 先验证登录状态 + const isLoggedIn = await ensureLoggedIn(); + if (isLoggedIn) { + startNewAnalysis(imageUri); + } + }} activeOpacity={0.8} > diff --git a/app/voice-record.tsx b/app/voice-record.tsx index 9c8a790..87935bd 100644 --- a/app/voice-record.tsx +++ b/app/voice-record.tsx @@ -1,6 +1,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar'; import { Colors } from '@/constants/Colors'; import { useAppDispatch } from '@/hooks/redux'; +import { useAuthGuard } from '@/hooks/useAuthGuard'; import { useColorScheme } from '@/hooks/useColorScheme'; import { useSafeAreaTop } from '@/hooks/useSafeAreaWithPadding'; import { analyzeFoodFromText } from '@/services/foodRecognition'; @@ -28,6 +29,7 @@ export default function VoiceRecordScreen() { const colorTokens = Colors[theme]; const { mealType = 'dinner' } = useLocalSearchParams<{ mealType?: string }>(); const dispatch = useAppDispatch(); + const { ensureLoggedIn } = useAuthGuard(); // 状态管理 const [recordState, setRecordState] = useState('idle'); @@ -222,6 +224,12 @@ export default function VoiceRecordScreen() { // 开始录音 const startRecording = async () => { + // 先验证登录状态 + const isLoggedIn = await ensureLoggedIn(); + if (!isLoggedIn) { + return; + } + try { // 重置状态 setRecognizedText(''); @@ -292,6 +300,12 @@ export default function VoiceRecordScreen() { return; } + // 先验证登录状态 + const isLoggedIn = await ensureLoggedIn(); + if (!isLoggedIn) { + return; + } + try { triggerHapticFeedback('impactMedium'); setRecordState('analyzing'); diff --git a/assets/images/icons/icon-yingyang.png b/assets/images/icons/icon-yingyang.png new file mode 100644 index 0000000..e2fdb75 Binary files /dev/null and b/assets/images/icons/icon-yingyang.png differ diff --git a/components/NutritionRadarCard.tsx b/components/NutritionRadarCard.tsx index 932ec0d..f7063ef 100644 --- a/components/NutritionRadarCard.tsx +++ b/components/NutritionRadarCard.tsx @@ -250,7 +250,7 @@ export function NutritionRadarCard({ style={styles.foodOptionItem} onPress={() => { triggerLightHaptic(); - pushIfAuthedElseLogin(`/food/camera?mealType=${currentMealType}`); + router.push(`${ROUTES.FOOD_CAMERA}?mealType=${currentMealType}`); }} activeOpacity={0.7} > @@ -284,7 +284,7 @@ export function NutritionRadarCard({ style={styles.foodOptionItem} onPress={() => { triggerLightHaptic(); - pushIfAuthedElseLogin(`${ROUTES.VOICE_RECORD}?mealType=${currentMealType}`); + router.push(`${ROUTES.VOICE_RECORD}?mealType=${currentMealType}`); }} activeOpacity={0.7} > @@ -301,13 +301,13 @@ export function NutritionRadarCard({ style={styles.foodOptionItem} onPress={() => { triggerLightHaptic(); - pushIfAuthedElseLogin(`${ROUTES.NUTRITION_LABEL_ANALYSIS}?mealType=${currentMealType}`); + router.push(`${ROUTES.NUTRITION_LABEL_ANALYSIS}?mealType=${currentMealType}`); }} activeOpacity={0.7} > diff --git a/constants/Routes.ts b/constants/Routes.ts index 37b686a..837b76f 100644 --- a/constants/Routes.ts +++ b/constants/Routes.ts @@ -41,6 +41,7 @@ export const ROUTES = { FOOD_LIBRARY: '/food-library', VOICE_RECORD: '/voice-record', NUTRITION_LABEL_ANALYSIS: '/food/nutrition-label-analysis', + FOOD_CAMERA: '/food/camera', // 体重记录相关路由 WEIGHT_RECORDS: '/weight-records',