feat(medications): 优化药品管理功能和登录流程

- 更新默认药品图片为专用图标
- 移除未使用的 loading 状态选择器
- 优化 Apple 登录按钮样式,支持毛玻璃效果和加载状态
- 添加登录成功后返回功能(shouldBack 参数)
- 药品详情页添加信息卡片点击交互
- 添加药品添加页面的登录状态检查
- 增强时间选择器错误处理和数据验证
- 修复药品图片显示逻辑,支持网络图片
- 优化药品卡片样式和布局
- 添加图片加载错误处理
This commit is contained in:
richarjiang
2025-11-11 10:02:37 +08:00
parent 0594831c9f
commit 50525f82a1
8 changed files with 263 additions and 70 deletions

View File

@@ -5,7 +5,7 @@ import { IconSymbol } from '@/components/ui/IconSymbol';
import { Colors } from '@/constants/Colors';
import { useAppDispatch, useAppSelector } from '@/hooks/redux';
import { useColorScheme } from '@/hooks/useColorScheme';
import { fetchMedicationRecords, fetchMedications, selectMedicationDisplayItemsByDate, selectMedicationsLoading } from '@/store/medicationsSlice';
import { fetchMedicationRecords, fetchMedications, selectMedicationDisplayItemsByDate } from '@/store/medicationsSlice';
import { DEFAULT_MEMBER_NAME } from '@/store/userSlice';
import { useFocusEffect } from '@react-navigation/native';
import dayjs, { Dayjs } from 'dayjs';
@@ -42,7 +42,6 @@ export default function MedicationsScreen() {
// 从 Redux 获取数据
const selectedKey = selectedDate.format('YYYY-MM-DD');
const medicationsForDay = useAppSelector((state) => selectMedicationDisplayItemsByDate(selectedKey)(state));
const loading = useAppSelector(selectMedicationsLoading);
const handleOpenAddMedication = useCallback(() => {
router.push('/medications/add-medication');
@@ -79,9 +78,11 @@ export default function MedicationsScreen() {
// 为每个药物添加默认图片(如果没有图片)
const medicationsWithImages = useMemo(() => {
console.log('medicationsForDay', medicationsForDay);
return medicationsForDay.map((med: any) => ({
...med,
image: med.image || require('@/assets/images/icons/icon-healthy-diet.png'), // 默认使用瓶子图标
image: med.image || require('@/assets/images/medicine/image-medicine.png'), // 默认使用瓶子图标
}));
}, [medicationsForDay]);

View File

@@ -4,7 +4,7 @@ import { GlassView, isLiquidGlassAvailable } from 'expo-glass-effect';
import { LinearGradient } from 'expo-linear-gradient';
import { useLocalSearchParams, useRouter } from 'expo-router';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Alert, Animated, Linking, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { ActivityIndicator, Alert, Animated, Linking, Pressable, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { ThemedText } from '@/components/ThemedText';
@@ -18,7 +18,7 @@ import Toast from 'react-native-toast-message';
export default function LoginScreen() {
const router = useRouter();
const searchParams = useLocalSearchParams<{ redirectTo?: string; redirectParams?: string }>();
const searchParams = useLocalSearchParams<{ redirectTo?: string; redirectParams?: string; shouldBack?: string }>();
const scheme = (useColorScheme() ?? 'light') as 'light' | 'dark';
const color = Colors[scheme];
const pageBackground = scheme === 'light' ? color.pageBackgroundEmphasis : color.background;
@@ -122,16 +122,24 @@ export default function LoginScreen() {
type: 'success',
});
// 登录成功后处理重定向
const to = searchParams?.redirectTo as string | undefined;
const paramsJson = searchParams?.redirectParams as string | undefined;
let parsedParams: Record<string, any> | undefined;
if (paramsJson) {
try { parsedParams = JSON.parse(paramsJson); } catch { }
}
if (to) {
router.replace({ pathname: to, params: parsedParams } as any);
} else {
const shouldBack = searchParams?.shouldBack === 'true';
if (shouldBack) {
// 如果设置了 shouldBack直接返回上一页
router.back();
} else {
// 否则按照原有逻辑进行重定向
const to = searchParams?.redirectTo as string | undefined;
const paramsJson = searchParams?.redirectParams as string | undefined;
let parsedParams: Record<string, any> | undefined;
if (paramsJson) {
try { parsedParams = JSON.parse(paramsJson); } catch { }
}
if (to) {
router.replace({ pathname: to, params: parsedParams } as any);
} else {
router.back();
}
}
} catch (err: any) {
console.log('err.code', err.code);
@@ -248,20 +256,55 @@ export default function LoginScreen() {
{/* Apple 登录 */}
{appleAvailable && (
<Pressable
<TouchableOpacity
accessibilityRole="button"
onPress={() => guardAgreement(onAppleLogin)}
disabled={loading}
style={({ pressed }) => [
styles.appleButton,
{ backgroundColor: '#000000' },
loading && { opacity: 0.7 },
pressed && { transform: [{ scale: 0.98 }] },
]}
activeOpacity={0.7}
>
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
<Text style={styles.appleText}>使 Apple </Text>
</Pressable>
{isLiquidGlassAvailable() ? (
<GlassView
style={styles.appleButton}
glassEffectStyle="regular"
tintColor="rgba(0, 0, 0, 0.8)"
isInteractive={true}
>
{loading ? (
<>
<ActivityIndicator
size="small"
color="#FFFFFF"
style={{ marginRight: 10 }}
/>
<Text style={styles.appleText}>...</Text>
</>
) : (
<>
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
<Text style={styles.appleText}>使 Apple </Text>
</>
)}
</GlassView>
) : (
<View style={[styles.appleButton, styles.appleButtonFallback, loading && { opacity: 0.7 }]}>
{loading ? (
<>
<ActivityIndicator
size="small"
color="#FFFFFF"
style={{ marginRight: 10 }}
/>
<Text style={styles.appleText}>...</Text>
</>
) : (
<>
<Ionicons name="logo-apple" size={22} color="#FFFFFF" style={{ marginRight: 10 }} />
<Text style={styles.appleText}>使 Apple </Text>
</>
)}
</View>
)}
</TouchableOpacity>
)}
{/* 协议勾选 */}
@@ -343,12 +386,16 @@ const styles = StyleSheet.create({
justifyContent: 'center',
flexDirection: 'row',
marginBottom: 16,
overflow: 'hidden',
shadowColor: '#000',
shadowOffset: { width: 0, height: 8 },
shadowOpacity: 0.15,
shadowRadius: 12,
elevation: 2,
},
appleButtonFallback: {
backgroundColor: '#000000',
},
appleText: {
fontSize: 16,
color: '#FFFFFF',

View File

@@ -44,7 +44,7 @@ const FORM_LABELS: Record<Medication['form'], string> = {
other: '其他',
};
const DEFAULT_IMAGE = require('@/assets/images/icons/icon-healthy-diet.png');
const DEFAULT_IMAGE = require('@/assets/images/medicine/image-medicine.png');
type RecordsSummary = {
takenCount: number;
@@ -413,6 +413,22 @@ export default function MedicationDetailScreen() {
}
}, [deleteLoading, dispatch, medication, router]);
const handleStartDatePress = useCallback(() => {
Alert.alert('开始日期', `开始服药日期:${startDateLabel}`);
}, [startDateLabel]);
const handleTimePress = useCallback(() => {
Alert.alert('服药时间', `设置的时间:${reminderTimes}`);
}, [reminderTimes]);
const handleDosagePress = useCallback(() => {
Alert.alert('每次剂量', `单次服用剂量:${dosageLabel}`);
}, [dosageLabel]);
const handleFormPress = useCallback(() => {
Alert.alert('剂型', `药品剂型:${formLabel}`);
}, [formLabel]);
if (!medicationId) {
return (
<View style={[styles.container, { backgroundColor: colors.pageBackgroundEmphasis }]}>
@@ -489,12 +505,16 @@ export default function MedicationDetailScreen() {
value={startDateLabel}
icon="calendar-outline"
colors={colors}
clickable={true}
onPress={handleStartDatePress}
/>
<InfoCard
label="时间"
value={reminderTimes}
icon="time-outline"
colors={colors}
clickable={true}
onPress={handleTimePress}
/>
</View>
<View style={[styles.fullCard, { backgroundColor: colors.surface }]}>
@@ -511,8 +531,22 @@ export default function MedicationDetailScreen() {
<Section title="剂量与形式" color={colors.text}>
<View style={styles.row}>
<InfoCard label="每次剂量" value={dosageLabel} icon="medkit-outline" colors={colors} />
<InfoCard label="剂型" value={formLabel} icon="cube-outline" colors={colors} />
<InfoCard
label="每次剂量"
value={dosageLabel}
icon="medkit-outline"
colors={colors}
clickable={true}
onPress={handleDosagePress}
/>
<InfoCard
label="剂型"
value={formLabel}
icon="cube-outline"
colors={colors}
clickable={true}
onPress={handleFormPress}
/>
</View>
</Section>
@@ -712,20 +746,35 @@ const InfoCard = ({
value,
icon,
colors,
onPress,
clickable = false,
}: {
label: string;
value: string;
icon: keyof typeof Ionicons.glyphMap;
colors: (typeof Colors)[keyof typeof Colors];
onPress?: () => void;
clickable?: boolean;
}) => {
const CardWrapper = clickable ? TouchableOpacity : View;
return (
<View style={[styles.infoCard, { backgroundColor: colors.surface }]}>
<CardWrapper
style={[styles.infoCard, { backgroundColor: colors.surface }]}
onPress={onPress}
activeOpacity={clickable ? 0.7 : 1}
>
{clickable && (
<View style={styles.infoCardArrow}>
<Ionicons name="chevron-forward" size={16} color={colors.textMuted} />
</View>
)}
<View style={styles.infoCardIcon}>
<Ionicons name={icon} size={16} color="#4C6EF5" />
</View>
<Text style={[styles.infoCardLabel, { color: colors.textSecondary }]}>{label}</Text>
<Text style={[styles.infoCardValue, { color: colors.text }]}>{value}</Text>
</View>
</CardWrapper>
);
};
@@ -823,6 +872,13 @@ const styles = StyleSheet.create({
shadowRadius: 8,
shadowOffset: { width: 0, height: 4 },
elevation: 2,
position: 'relative',
},
infoCardArrow: {
position: 'absolute',
top: 12,
right: 12,
zIndex: 1,
},
infoCardIcon: {
width: 28,

View File

@@ -3,6 +3,7 @@ import { HeaderBar } from '@/components/ui/HeaderBar';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { Colors } from '@/constants/Colors';
import { useAppDispatch } from '@/hooks/redux';
import { useAuthGuard } from '@/hooks/useAuthGuard';
import { useColorScheme } from '@/hooks/useColorScheme';
import { useCosUpload } from '@/hooks/useCosUpload';
import { createMedicationAction, fetchMedicationRecords, fetchMedications } from '@/store/medicationsSlice';
@@ -86,10 +87,40 @@ const DEFAULT_TIME_PRESETS = ['08:00', '12:00', '18:00', '22:00'];
const formatTime = (date: Date) => dayjs(date).format('HH:mm');
const getDefaultTimeByIndex = (index: number) => DEFAULT_TIME_PRESETS[index % DEFAULT_TIME_PRESETS.length];
const createDateFromTime = (time: string) => {
const [hour, minute] = time.split(':').map((val) => parseInt(val, 10));
const next = new Date();
next.setHours(hour || 0, minute || 0, 0, 0);
return next;
try {
if (!time || typeof time !== 'string') {
console.warn('[MEDICATION] Invalid time string provided:', time);
return new Date();
}
const parts = time.split(':');
if (parts.length !== 2) {
console.warn('[MEDICATION] Invalid time format:', time);
return new Date();
}
const hour = parseInt(parts[0], 10);
const minute = parseInt(parts[1], 10);
if (isNaN(hour) || isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
console.warn('[MEDICATION] Invalid time values:', { hour, minute });
return new Date();
}
const next = new Date();
next.setHours(hour, minute, 0, 0);
// 验证日期是否有效
if (isNaN(next.getTime())) {
console.error('[MEDICATION] Failed to create valid date');
return new Date();
}
return next;
} catch (error) {
console.error('[MEDICATION] Error in createDateFromTime:', error);
return new Date();
}
};
export default function AddMedicationScreen() {
@@ -103,6 +134,8 @@ export default function AddMedicationScreen() {
const [medicationName, setMedicationName] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const { upload, uploading } = useCosUpload({ prefix: 'images/medications' });
// 获取登录验证相关的功能
const { ensureLoggedIn } = useAuthGuard();
const softBorderColor = useMemo(() => withAlpha(colors.border, 0.45), [colors.border]);
const fadedBorderFill = useMemo(() => withAlpha(colors.border, 0.2), [colors.border]);
const glassPrimaryTint = useMemo(() => withAlpha(colors.primary, theme === 'dark' ? 0.55 : 0.45), [colors.primary, theme]);
@@ -231,6 +264,16 @@ export default function AddMedicationScreen() {
setIsSubmitting(true);
try {
// 先检查用户是否已登录,如果未登录则跳转到登录页面
const isLoggedIn = await ensureLoggedIn({
shouldBack: true
});
if (!isLoggedIn) {
// 未登录ensureLoggedIn 已处理跳转,直接返回
setIsSubmitting(false);
return;
}
// 构建药物数据,符合 CreateMedicationDto 接口
const medicationData = {
name: medicationName.trim(),
@@ -289,6 +332,7 @@ export default function AddMedicationScreen() {
startDate,
note,
dispatch,
ensureLoggedIn,
]);
const handlePrev = useCallback(() => {
@@ -378,29 +422,51 @@ export default function AddMedicationScreen() {
const openTimePicker = useCallback(
(index?: number) => {
if (typeof index === 'number') {
setEditingTimeIndex(index);
setTimePickerDate(createDateFromTime(medicationTimes[index]));
} else {
setEditingTimeIndex(null);
setTimePickerDate(createDateFromTime(getDefaultTimeByIndex(medicationTimes.length)));
try {
if (typeof index === 'number') {
if (index >= 0 && index < medicationTimes.length) {
setEditingTimeIndex(index);
setTimePickerDate(createDateFromTime(medicationTimes[index]));
} else {
console.error('[MEDICATION] Invalid time index:', index);
return;
}
} else {
setEditingTimeIndex(null);
setTimePickerDate(createDateFromTime(getDefaultTimeByIndex(medicationTimes.length)));
}
setTimePickerVisible(true);
} catch (error) {
console.error('[MEDICATION] Error in openTimePicker:', error);
}
setTimePickerVisible(true);
},
[medicationTimes]
);
const confirmTime = useCallback(
(date: Date) => {
const nextValue = formatTime(date);
setMedicationTimes((prev) => {
if (editingTimeIndex == null) {
return [...prev, nextValue];
try {
if (!date || isNaN(date.getTime())) {
console.error('[MEDICATION] Invalid date provided to confirmTime');
setTimePickerVisible(false);
setEditingTimeIndex(null);
return;
}
return prev.map((time, idx) => (idx === editingTimeIndex ? nextValue : time));
});
setTimePickerVisible(false);
setEditingTimeIndex(null);
const nextValue = formatTime(date);
setMedicationTimes((prev) => {
if (editingTimeIndex == null) {
return [...prev, nextValue];
}
return prev.map((time, idx) => (idx === editingTimeIndex ? nextValue : time));
});
setTimePickerVisible(false);
setEditingTimeIndex(null);
} catch (error) {
console.error('[MEDICATION] Error in confirmTime:', error);
setTimePickerVisible(false);
setEditingTimeIndex(null);
}
},
[editingTimeIndex]
);
@@ -915,15 +981,26 @@ export default function AddMedicationScreen() {
visible={timePickerVisible}
transparent
animationType="fade"
onRequestClose={() => setTimePickerVisible(false)}
onRequestClose={() => {
setTimePickerVisible(false);
setEditingTimeIndex(null);
}}
>
<Pressable style={styles.pickerBackdrop} onPress={() => setTimePickerVisible(false)} />
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}
>
<Pressable
style={styles.pickerBackdrop}
onPress={() => {
setTimePickerVisible(false);
setEditingTimeIndex(null);
}}
/>
<View style={[styles.pickerSheet, { backgroundColor: colors.surface }]}>
<ThemedText style={[styles.modalTitle, { color: colors.text }]}>
{editingTimeIndex !== null ? '修改提醒时间' : '添加提醒时间'}
</ThemedText>
<DateTimePicker
value={timePickerDate}
mode="time"
display={Platform.OS === 'ios' ? 'spinner' : 'clock'}
display={Platform.OS === 'ios' ? 'spinner' : 'default'}
onChange={(event, date) => {
if (Platform.OS === 'ios') {
if (date) setTimePickerDate(date);