feat(profile): add data storage settings screen with cache management
Add DataStorageScreen for managing media cache and storage: - Display cache statistics (image/video/audio counts and sizes) - Show AsyncStorage usage information - Add clear all cache functionality - Add cleanup expired cache option (7 days threshold) - Add storage usage breakdown by category - Add "Last cleaned" timestamp display Also fix MediaCacheManager to skip native cache operations on web platform: - Return original URI directly on web without caching - Skip directory creation on web - Skip file existence checks on web - Skip startup and periodic cleanup on web
This commit is contained in:
@@ -41,6 +41,7 @@ export default function ProfileStackLayout() {
|
||||
<Stack.Screen name="terms" options={{ title: '用户协议' }} />
|
||||
<Stack.Screen name="privacy" options={{ title: '隐私政策' }} />
|
||||
<Stack.Screen name="verification" options={{ title: '身份认证' }} />
|
||||
<Stack.Screen name="data-storage" options={{ title: '数据与存储' }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
5
app/(app)/(tabs)/profile/data-storage.tsx
Normal file
5
app/(app)/(tabs)/profile/data-storage.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DataStorageScreen } from '../../../../src/screens/profile';
|
||||
|
||||
export default function DataStorageRoute() {
|
||||
return <DataStorageScreen />;
|
||||
}
|
||||
19
src/infrastructure/cache/MediaCacheManager.ts
vendored
19
src/infrastructure/cache/MediaCacheManager.ts
vendored
@@ -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<void> {
|
||||
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<boolean> {
|
||||
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<string> {
|
||||
if (Platform.OS === 'web') {
|
||||
return uri;
|
||||
}
|
||||
|
||||
const { conversationId, messageId, size = 0 } = options;
|
||||
|
||||
// 生成唯一键
|
||||
|
||||
@@ -206,3 +206,7 @@ export function hrefVerificationForm(identity?: string): string {
|
||||
}
|
||||
return '/verification-form';
|
||||
}
|
||||
|
||||
export function hrefProfileDataStorage(): string {
|
||||
return '/profile/data-storage';
|
||||
}
|
||||
|
||||
466
src/screens/profile/DataStorageScreen.tsx
Normal file
466
src/screens/profile/DataStorageScreen.tsx
Normal file
@@ -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<CacheStats | null>(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 (
|
||||
<View style={styles.loadingContainer}>
|
||||
<ActivityIndicator size="large" color={colors.primary.main} />
|
||||
<Text variant="body" color={colors.text.secondary} style={styles.loadingText}>
|
||||
正在计算存储空间...
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 存储总览 */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text variant="caption" style={styles.sectionTitle}>
|
||||
存储使用
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.totalCard}>
|
||||
<View style={styles.totalCardTop}>
|
||||
<MaterialCommunityIcons name="database" size={28} color={colors.primary.main} />
|
||||
<View style={styles.totalCardInfo}>
|
||||
<Text variant="h3" color={colors.text.primary} style={styles.totalSize}>
|
||||
{formatBytes(totalSize)}
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary}>
|
||||
总缓存大小
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<View style={styles.progressBarBg}>
|
||||
<View
|
||||
style={[
|
||||
styles.progressBarFill,
|
||||
{
|
||||
width: `${Math.min((totalSize / (500 * 1024 * 1024)) * 100, 100)}%`,
|
||||
backgroundColor:
|
||||
totalSize > 400 * 1024 * 1024
|
||||
? colors.error.main
|
||||
: totalSize > 250 * 1024 * 1024
|
||||
? colors.warning.main
|
||||
: colors.primary.main,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</View>
|
||||
<Text variant="caption" color={colors.text.hint}>
|
||||
上限 500 MB
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* 缓存明细 */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text variant="caption" style={styles.sectionTitle}>
|
||||
缓存明细
|
||||
</Text>
|
||||
</View>
|
||||
{storageItems.map((item) => (
|
||||
<View key={item.icon} style={styles.statItem}>
|
||||
<View style={styles.statItemLeft}>
|
||||
<View style={styles.iconContainer}>
|
||||
<MaterialCommunityIcons
|
||||
name={item.icon as any}
|
||||
size={22}
|
||||
color={colors.text.secondary}
|
||||
/>
|
||||
</View>
|
||||
<Text variant="body" color={colors.text.primary}>
|
||||
{item.label}
|
||||
</Text>
|
||||
</View>
|
||||
<Text variant="body" color={colors.text.secondary}>
|
||||
{item.value}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 缓存管理 */}
|
||||
<View style={styles.section}>
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text variant="caption" style={styles.sectionTitle}>
|
||||
缓存管理
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
style={styles.actionItem}
|
||||
onPress={handleCleanupExpired}
|
||||
disabled={clearing}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.actionItemLeft}>
|
||||
<View style={styles.iconContainer}>
|
||||
<MaterialCommunityIcons
|
||||
name="broom"
|
||||
size={22}
|
||||
color={colors.text.secondary}
|
||||
/>
|
||||
</View>
|
||||
<View>
|
||||
<Text variant="body" color={colors.text.primary}>
|
||||
清理过期缓存
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.subtitle}>
|
||||
清除超过 7 天未访问的缓存
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={[styles.actionItem, styles.actionItemLast]}
|
||||
onPress={handleClearCache}
|
||||
disabled={clearing}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<View style={styles.actionItemLeft}>
|
||||
<View style={styles.iconContainer}>
|
||||
<MaterialCommunityIcons
|
||||
name="delete-sweep-outline"
|
||||
size={22}
|
||||
color={colors.error.main}
|
||||
/>
|
||||
</View>
|
||||
<View>
|
||||
<Text variant="body" color={colors.error.main}>
|
||||
清除所有缓存
|
||||
</Text>
|
||||
<Text variant="caption" color={colors.text.secondary} style={styles.subtitle}>
|
||||
删除所有已缓存的媒体文件
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
{clearing ? (
|
||||
<ActivityIndicator size="small" color={colors.error.main} />
|
||||
) : (
|
||||
<MaterialCommunityIcons name="chevron-right" size={20} color={colors.text.hint} />
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* 缓存策略说明 */}
|
||||
<View style={styles.tipContainer}>
|
||||
<MaterialCommunityIcons name="information-outline" size={16} color={colors.text.hint} />
|
||||
<Text variant="caption" color={colors.text.hint} style={styles.tipText}>
|
||||
应用会自动清理过期缓存(7 天未访问)并在缓存超过 500 MB 时启用 LRU 策略自动回收空间。清除缓存不会影响账号数据与聊天记录。
|
||||
</Text>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['bottom']}>
|
||||
<ScrollView
|
||||
contentContainerStyle={[
|
||||
styles.scrollContent,
|
||||
{ paddingBottom: scrollBottomInset, paddingHorizontal: responsivePadding },
|
||||
]}
|
||||
>
|
||||
{renderContent()}
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user