feat(profile): add data storage settings screen with cache management
Some checks failed
Frontend CI / build-and-push-web (push) Successful in 15m38s
Frontend CI / ota-android (push) Successful in 16m54s
Frontend CI / build-android-apk (push) Failing after 8m29s

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:
lafay
2026-04-07 16:27:02 +08:00
parent 445c1c5561
commit 25194313ae
7 changed files with 494 additions and 7 deletions

View File

@@ -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;
// 生成唯一键

View File

@@ -206,3 +206,7 @@ export function hrefVerificationForm(identity?: string): string {
}
return '/verification-form';
}
export function hrefProfileDataStorage(): string {
return '/profile/data-storage';
}

View 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;

View File

@@ -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;

View File

@@ -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';