diff --git a/app/(app)/(tabs)/profile/_layout.tsx b/app/(app)/(tabs)/profile/_layout.tsx index 3d62485..f5ac872 100644 --- a/app/(app)/(tabs)/profile/_layout.tsx +++ b/app/(app)/(tabs)/profile/_layout.tsx @@ -41,6 +41,7 @@ export default function ProfileStackLayout() { + ); } diff --git a/app/(app)/(tabs)/profile/data-storage.tsx b/app/(app)/(tabs)/profile/data-storage.tsx new file mode 100644 index 0000000..b8475ae --- /dev/null +++ b/app/(app)/(tabs)/profile/data-storage.tsx @@ -0,0 +1,5 @@ +import { DataStorageScreen } from '../../../../src/screens/profile'; + +export default function DataStorageRoute() { + return ; +} diff --git a/src/infrastructure/cache/MediaCacheManager.ts b/src/infrastructure/cache/MediaCacheManager.ts index 856da55..96c3a9e 100644 --- a/src/infrastructure/cache/MediaCacheManager.ts +++ b/src/infrastructure/cache/MediaCacheManager.ts @@ -6,6 +6,7 @@ import { File, Paths } from 'expo-file-system'; import AsyncStorage from '@react-native-async-storage/async-storage'; +import { Platform } from 'react-native'; import { MediaType, CleanupTrigger, @@ -102,13 +103,15 @@ export class MediaCacheManager { // 加载缓存记录 await this.loadCacheRecords(); - // 启动时清理 - if (this.policy.cleanupOnStart) { + // 启动时清理 (非web平台) + if (this.policy.cleanupOnStart && Platform.OS !== 'web') { await this.cleanup(CleanupTrigger.STARTUP); } - // 启动定期清理 - this.startPeriodicCleanup(); + // 启动定期清理 (非web平台) + if (Platform.OS !== 'web') { + this.startPeriodicCleanup(); + } this.isInitialized = true; } catch (error) { @@ -121,6 +124,8 @@ export class MediaCacheManager { * 确保缓存目录存在 */ private async ensureDirectories(): Promise { + if (Platform.OS === 'web') return; + try { const dirs = [getImageDir(), getVideoDir(), getAudioDir()]; for (const dir of dirs) { @@ -168,6 +173,8 @@ export class MediaCacheManager { * 检查文件是否存在 */ private async checkFileExists(path: string): Promise { + if (Platform.OS === 'web') return false; + try { const file = new File(path); return await file.exists; @@ -199,6 +206,10 @@ export class MediaCacheManager { size?: number; } = {} ): Promise { + if (Platform.OS === 'web') { + return uri; + } + const { conversationId, messageId, size = 0 } = options; // 生成唯一键 diff --git a/src/navigation/hrefs.ts b/src/navigation/hrefs.ts index c12009e..1f62a04 100644 --- a/src/navigation/hrefs.ts +++ b/src/navigation/hrefs.ts @@ -206,3 +206,7 @@ export function hrefVerificationForm(identity?: string): string { } return '/verification-form'; } + +export function hrefProfileDataStorage(): string { + return '/profile/data-storage'; +} diff --git a/src/screens/profile/DataStorageScreen.tsx b/src/screens/profile/DataStorageScreen.tsx new file mode 100644 index 0000000..d8f95a8 --- /dev/null +++ b/src/screens/profile/DataStorageScreen.tsx @@ -0,0 +1,466 @@ +/** + * 数据与存储设置页 DataStorageScreen(响应式适配) + * 胡萝卜BBS - 缓存管理、存储统计 + */ + +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { + View, + StyleSheet, + TouchableOpacity, + Alert, + ScrollView, + ActivityIndicator, +} from 'react-native'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; +import { MaterialCommunityIcons } from '@expo/vector-icons'; +import AsyncStorage from '@react-native-async-storage/async-storage'; +import { spacing, borderRadius, fontSizes, useAppColors, type AppColors } from '../../theme'; +import { Text } from '../../components/common'; +import { useResponsive, useResponsiveSpacing } from '../../hooks'; +import { mediaCacheManager } from '../../infrastructure/cache'; +import type { CacheStats } from '../../infrastructure/cache'; + +const CONTENT_MAX_WIDTH = 720; + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +interface StorageStatItem { + icon: string; + label: string; + value: string; + count?: number; +} + +export const DataStorageScreen: React.FC = () => { + const colors = useAppColors(); + const styles = useMemo(() => createDataStorageStyles(colors), [colors]); + const { isMobile } = useResponsive(); + const insets = useSafeAreaInsets(); + const responsivePadding = useResponsiveSpacing({ xs: 8, sm: 12, md: 16, lg: 24, xl: 32 }); + + const [cacheStats, setCacheStats] = useState(null); + const [asyncStorageSize, setAsyncStorageSize] = useState(0); + const [loading, setLoading] = useState(true); + const [clearing, setClearing] = useState(false); + + const scrollBottomInset = isMobile ? 64 + 24 + insets.bottom + spacing.md : spacing.md; + + const loadStats = useCallback(async () => { + try { + setLoading(true); + await mediaCacheManager.initialize(); + const stats = mediaCacheManager.getCacheStats(); + setCacheStats(stats); + + const keys = await AsyncStorage.getAllKeys(); + if (keys.length > 0) { + const items = await AsyncStorage.multiGet(keys); + let totalSize = 0; + for (const [, value] of items) { + if (value) { + totalSize += value.length * 2; + } + } + setAsyncStorageSize(totalSize); + } else { + setAsyncStorageSize(0); + } + } catch (error) { + console.error('加载存储统计失败:', error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadStats(); + }, [loadStats]); + + const handleClearCache = () => { + Alert.alert( + '清除缓存', + '确定要清除所有媒体缓存吗?此操作不可撤销。', + [ + { text: '取消', style: 'cancel' }, + { + text: '清除', + style: 'destructive', + onPress: async () => { + try { + setClearing(true); + await mediaCacheManager.clearAll(); + await loadStats(); + Alert.alert('完成', '缓存已清除'); + } catch (error) { + console.error('清除缓存失败:', error); + Alert.alert('错误', '清除缓存失败,请稍后重试'); + } finally { + setClearing(false); + } + }, + }, + ] + ); + }; + + const handleCleanupExpired = () => { + Alert.alert( + '清理过期缓存', + '将清理超过 7 天未访问的缓存文件。', + [ + { text: '取消', style: 'cancel' }, + { + text: '清理', + onPress: async () => { + try { + setClearing(true); + const result = await mediaCacheManager.cleanupExpired(); + await loadStats(); + Alert.alert( + '清理完成', + `已清理 ${result.deletedCount} 个文件,释放 ${formatBytes(result.freedSize)} 空间` + ); + } catch (error) { + console.error('清理过期缓存失败:', error); + Alert.alert('错误', '清理失败,请稍后重试'); + } finally { + setClearing(false); + } + }, + }, + ] + ); + }; + + const totalSize = (cacheStats?.totalSize ?? 0) + asyncStorageSize; + + const storageItems: StorageStatItem[] = useMemo(() => { + if (!cacheStats) return []; + return [ + { + icon: 'image-outline', + label: '图片缓存', + value: cacheStats.imageCount > 0 ? `${cacheStats.imageCount} 个文件` : '暂无', + count: cacheStats.imageCount, + }, + { + icon: 'video-outline', + label: '视频缓存', + value: cacheStats.videoCount > 0 ? `${cacheStats.videoCount} 个文件` : '暂无', + count: cacheStats.videoCount, + }, + { + icon: 'music-note-outline', + label: '音频缓存', + value: cacheStats.audioCount > 0 ? `${cacheStats.audioCount} 个文件` : '暂无', + count: cacheStats.audioCount, + }, + { + icon: 'file-document-outline', + label: '本地数据', + value: asyncStorageSize > 0 ? formatBytes(asyncStorageSize) : '暂无', + }, + ]; + }, [cacheStats, asyncStorageSize]); + + const renderContent = () => { + if (loading) { + return ( + + + + 正在计算存储空间... + + + ); + } + + return ( + <> + {/* 存储总览 */} + + + + 存储使用 + + + + + + + + {formatBytes(totalSize)} + + + 总缓存大小 + + + + + 400 * 1024 * 1024 + ? colors.error.main + : totalSize > 250 * 1024 * 1024 + ? colors.warning.main + : colors.primary.main, + }, + ]} + /> + + + 上限 500 MB + + + + + {/* 缓存明细 */} + + + + 缓存明细 + + + {storageItems.map((item) => ( + + + + + + + {item.label} + + + + {item.value} + + + ))} + + + {/* 缓存管理 */} + + + + 缓存管理 + + + + + + + + + + 清理过期缓存 + + + 清除超过 7 天未访问的缓存 + + + + + + + + + + + + + 清除所有缓存 + + + 删除所有已缓存的媒体文件 + + + + {clearing ? ( + + ) : ( + + )} + + + + {/* 缓存策略说明 */} + + + + 应用会自动清理过期缓存(7 天未访问)并在缓存超过 500 MB 时启用 LRU 策略自动回收空间。清除缓存不会影响账号数据与聊天记录。 + + + + ); + }; + + return ( + + + {renderContent()} + + + ); +}; + +function createDataStorageStyles(colors: AppColors) { + return StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background.paper, + }, + scrollContent: { + paddingVertical: spacing.lg, + }, + loadingContainer: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: spacing['3xl'], + }, + loadingText: { + marginTop: spacing.md, + }, + section: { + marginBottom: spacing.xl, + maxWidth: CONTENT_MAX_WIDTH, + alignSelf: 'center', + width: '100%', + }, + sectionHeader: { + paddingHorizontal: spacing['2xl'], + marginBottom: spacing.sm, + marginTop: spacing.sm, + }, + sectionTitle: { + fontWeight: '600', + fontSize: fontSizes.sm, + color: colors.text.secondary, + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + totalCard: { + marginHorizontal: spacing.lg, + padding: spacing.xl, + borderRadius: borderRadius.lg, + backgroundColor: colors.background.default, + }, + totalCardTop: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: spacing.lg, + }, + totalCardInfo: { + marginLeft: spacing.lg, + }, + totalSize: { + marginBottom: 2, + }, + progressBarBg: { + height: 6, + borderRadius: 3, + backgroundColor: colors.divider, + marginBottom: spacing.sm, + overflow: 'hidden', + }, + progressBarFill: { + height: '100%', + borderRadius: 3, + }, + statItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 14, + paddingHorizontal: spacing['2xl'], + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.divider, + }, + statItemLeft: { + flexDirection: 'row', + alignItems: 'center', + }, + iconContainer: { + width: 24, + height: 24, + justifyContent: 'center', + alignItems: 'center', + marginRight: spacing.lg, + }, + actionItem: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingVertical: 16, + paddingHorizontal: spacing['2xl'], + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: colors.divider, + }, + actionItemLast: { + borderBottomWidth: 0, + }, + actionItemLeft: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + }, + subtitle: { + marginTop: 2, + }, + tipContainer: { + flexDirection: 'row', + alignItems: 'flex-start', + marginHorizontal: spacing['2xl'], + padding: spacing.md, + backgroundColor: colors.background.default, + borderRadius: 14, + gap: spacing.sm, + maxWidth: CONTENT_MAX_WIDTH, + alignSelf: 'center', + width: '100%', + }, + tipText: { + flex: 1, + lineHeight: 20, + }, + }); +} + +export default DataStorageScreen; diff --git a/src/screens/profile/SettingsScreen.tsx b/src/screens/profile/SettingsScreen.tsx index ba65fb9..789940e 100644 --- a/src/screens/profile/SettingsScreen.tsx +++ b/src/screens/profile/SettingsScreen.tsx @@ -239,10 +239,9 @@ export const SettingsScreen: React.FC = () => { break; } - case 'data_storage': { - Alert.alert('数据与存储', '缓存清理功能即将上线!'); + case 'data_storage': + router.push(hrefs.hrefProfileDataStorage()); break; - } case 'about': router.push(hrefs.hrefProfileAbout()); break; diff --git a/src/screens/profile/index.ts b/src/screens/profile/index.ts index 7784813..2f622a3 100644 --- a/src/screens/profile/index.ts +++ b/src/screens/profile/index.ts @@ -18,6 +18,7 @@ export { AboutScreen } from './AboutScreen'; export { TermsOfServiceScreen } from './TermsOfServiceScreen'; export { PrivacyPolicyScreen } from './PrivacyPolicyScreen'; export { VerificationSettingsScreen } from './VerificationSettingsScreen'; +export { DataStorageScreen } from './DataStorageScreen'; // 导出 Hook 供需要自定义的场景使用 export { useUserProfile, createSharedProfileStyles, TABS, TAB_ICONS } from './useUserProfile';