improve backup diagnostics and mobile sidebar

This commit is contained in:
ngfchl
2026-07-19 12:44:30 +08:00
parent 13ab2ce103
commit 7ef1a186ac
4 changed files with 246 additions and 58 deletions
+1
View File
@@ -36,6 +36,7 @@ class AppLogEntry {
'HTTP' => '网络',
'Auth' => '认证',
'Media' => '媒体库',
'Backup' => '数据备份',
'CloudIndex' => '全盘索引',
'Player' => '播放器',
'Storage' => '本地存储',
+50 -1
View File
@@ -123,7 +123,9 @@ class _BackupActionsMenuState extends State<_BackupActionsMenu> {
final label = active
? '${progress!.phase} $percentage%'
: progress?.error != null
? '数据备份失败'
? progress!.phase.contains('恢复')
? '数据恢复失败'
: '数据备份失败'
: '数据备份';
return ShadPopover(
controller: _controller,
@@ -132,6 +134,51 @@ class _BackupActionsMenuState extends State<_BackupActionsMenu> {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (progress?.error?.trim().isNotEmpty == true) ...[
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: ShadTheme.of(
context,
).colorScheme.destructive.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: ShadTheme.of(
context,
).colorScheme.destructive.withValues(alpha: 0.34),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${progress!.phase}原因',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: ShadTheme.of(context).colorScheme.destructive,
),
),
const SizedBox(height: 4),
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 96),
child: SingleChildScrollView(
child: SelectableText(
progress.error!,
style: TextStyle(
fontSize: 11,
height: 1.35,
color: ShadTheme.of(context).colorScheme.foreground,
),
),
),
),
],
),
),
const SizedBox(height: 6),
],
_item(
icon: Icons.save_alt_rounded,
label: '导出数据',
@@ -1200,6 +1247,8 @@ class _MediaLibraryPageState extends ConsumerState<MediaLibraryPage> {
List<CloudFile> backups;
try {
backups = await notifier.cloudScrapedBackups();
} catch (_) {
return;
} finally {
if (mounted) setState(() => _backupBusy = false);
}
+51 -44
View File
@@ -1531,55 +1531,62 @@ class _SidebarTile extends StatelessWidget {
final cs = ShadTheme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
child: Semantics(
button: true,
selected: selected,
label: label,
child: ShadButton.ghost(
width: double.infinity,
height: 42,
padding: const EdgeInsets.symmetric(horizontal: 9),
decoration: BoxDecoration(
color: selected
? const Color(0xFFFFB18A).withValues(alpha: 0.72)
: Colors.transparent,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: selected
? Colors.white.withValues(alpha: 0.36)
: const Color(0xFFFFE4D2).withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(8),
padding: EdgeInsets.zero,
onPressed: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 140),
height: 42,
padding: const EdgeInsets.symmetric(horizontal: 9),
decoration: BoxDecoration(
color: selected
? const Color(0xFFFFB18A).withValues(alpha: 0.72)
: Colors.transparent,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: selected
? Colors.white.withValues(alpha: 0.36)
: const Color(0xFFFFE4D2).withValues(alpha: 0.82),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 17, color: cs.primary),
),
child: Icon(icon, size: 17, color: cs.primary),
),
const SizedBox(width: 10),
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
color: selected ? cs.foreground : cs.mutedForeground,
const SizedBox(width: 10),
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
color: selected ? cs.foreground : cs.mutedForeground,
),
),
),
),
if (count != null)
Text(
count.toString(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: cs.mutedForeground,
if (count != null)
Text(
count.toString(),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: cs.mutedForeground,
),
),
),
],
],
),
),
),
),
+144 -13
View File
@@ -314,6 +314,7 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
Future<void> exportScrapedDataToCloud() async {
if (_api == null || state.isLoading || state.isScanning) return;
_appendBackupLog('开始同步刮削数据到云盘');
state = state.copyWith(
clearError: true,
clearStatus: true,
@@ -327,6 +328,7 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
);
Directory? temporaryDirectory;
try {
_appendBackupLog('正在确认云盘备份目录');
final destination = await _resolveCloudBackupDestination();
state = state.copyWith(
cloudBackupSync: CloudBackupSyncProgress(
@@ -337,14 +339,17 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
isActive: true,
),
);
_appendBackupLog('备份目录已就绪:${destination.path}');
temporaryDirectory = await Directory.systemTemp.createTemp(
'guangya-media-',
);
final backup = File(
'${temporaryDirectory.path}/media-library-${DateTime.now().millisecondsSinceEpoch}.sqlite3',
);
_appendBackupLog('正在导出本地刮削数据库');
await _store.exportBackupTo(backup.path);
final size = await backup.length();
_appendBackupLog('本地数据库导出完成,大小 ${_formatBytes(size)}');
state = state.copyWith(
cloudBackupSync: CloudBackupSyncProgress(
phase: '正在上传备份',
@@ -354,6 +359,7 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
isActive: true,
),
);
_appendBackupLog('正在上传备份到 ${destination.path}');
await _api!.fileUpload(
backup,
parentID: destination.id,
@@ -383,17 +389,20 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
isActive: false,
),
);
_appendBackupLog('云盘备份同步完成:$uploadedPath');
} catch (error) {
final destination = state.cloudBackupSync?.destination ?? '云盘根目录/小黄鸭备份';
final reason = _backupFailureReason(error);
_appendBackupLog('同步到云盘失败:$reason', isError: true, error: error);
state = state.copyWith(
errorMessage: '同步到云盘失败:$error',
errorMessage: '同步到云盘失败:$reason',
cloudBackupSync: CloudBackupSyncProgress(
phase: '同步失败',
destination: destination,
transferredBytes: 0,
totalBytes: 0,
isActive: false,
error: '$error',
error: reason,
),
);
} finally {
@@ -431,24 +440,73 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
Future<List<CloudFile>> cloudScrapedBackups() async {
if (_api == null) return const [];
final response = await _api!.searchFiles(
'media-library.sqlite3',
pageSize: 100,
_appendBackupLog('正在查找云盘中的 SQLite 备份');
state = state.copyWith(
clearError: true,
clearStatus: true,
cloudBackupSync: const CloudBackupSyncProgress(
phase: '正在查找云盘备份',
destination: '云盘根目录/小黄鸭备份',
transferredBytes: 0,
totalBytes: 0,
isActive: true,
),
);
return _extractFiles(response)
.where(
(file) =>
!file.isDirectory && file.name.toLowerCase().endsWith('.sqlite3'),
)
.toList();
try {
final response = await _api!.searchFiles(
'media-library.sqlite3',
pageSize: 100,
);
final backups = _extractFiles(response)
.where(
(file) =>
!file.isDirectory &&
file.name.toLowerCase().endsWith('.sqlite3'),
)
.toList();
_appendBackupLog('云盘备份查找完成,找到 ${backups.length} 个文件');
state = state.copyWith(
cloudBackupSync: const CloudBackupSyncProgress(
phase: '请选择要恢复的备份',
destination: '云盘根目录/小黄鸭备份',
transferredBytes: 0,
totalBytes: 0,
isActive: false,
),
);
return backups;
} catch (error) {
final reason = _backupFailureReason(error);
_appendBackupLog('查找云盘备份失败:$reason', isError: true, error: error);
state = state.copyWith(
errorMessage: '查找云盘备份失败:$reason',
cloudBackupSync: CloudBackupSyncProgress(
phase: '恢复失败',
destination: '云盘根目录/小黄鸭备份',
transferredBytes: 0,
totalBytes: 0,
isActive: false,
error: reason,
),
);
rethrow;
}
}
Future<void> importScrapedDataFromCloud(CloudFile backup) async {
if (_api == null || state.isLoading || state.isScanning) return;
_appendBackupLog('开始从云盘恢复:${backup.name}');
state = state.copyWith(
isLoading: true,
clearError: true,
clearStatus: true,
cloudBackupSync: CloudBackupSyncProgress(
phase: '正在获取备份下载地址',
destination: backup.name,
transferredBytes: 0,
totalBytes: backup.size ?? 0,
isActive: true,
),
);
Directory? temporaryDirectory;
try {
@@ -468,10 +526,57 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
final localBackup = File(
'${temporaryDirectory.path}/media-library.sqlite3',
);
await Dio().download(url, localBackup.path);
_appendBackupLog('正在下载云盘备份:${backup.name}');
await Dio().download(
url,
localBackup.path,
onReceiveProgress: (received, total) {
state = state.copyWith(
cloudBackupSync: CloudBackupSyncProgress(
phase: '正在下载云盘备份',
destination: backup.name,
transferredBytes: received,
totalBytes: total > 0 ? total : (backup.size ?? 0),
isActive: true,
),
);
},
);
_appendBackupLog('备份下载完成,正在导入本地数据库');
state = state.copyWith(
cloudBackupSync: CloudBackupSyncProgress(
phase: '正在导入刮削数据',
destination: backup.name,
transferredBytes: backup.size ?? 0,
totalBytes: backup.size ?? 0,
isActive: true,
),
);
await _applyImportedBackup(localBackup.path);
_appendBackupLog('云盘备份恢复完成:${backup.name}');
state = state.copyWith(
cloudBackupSync: CloudBackupSyncProgress(
phase: '恢复完成',
destination: backup.name,
transferredBytes: backup.size ?? 0,
totalBytes: backup.size ?? 0,
isActive: false,
),
);
} catch (error) {
state = state.copyWith(errorMessage: '从云盘同步失败:$error');
final reason = _backupFailureReason(error);
_appendBackupLog('从云盘恢复失败:$reason', isError: true, error: error);
state = state.copyWith(
errorMessage: '从云盘恢复失败:$reason',
cloudBackupSync: CloudBackupSyncProgress(
phase: '恢复失败',
destination: backup.name,
transferredBytes: 0,
totalBytes: backup.size ?? 0,
isActive: false,
error: reason,
),
);
} finally {
if (temporaryDirectory != null && await temporaryDirectory.exists()) {
await temporaryDirectory.delete(recursive: true);
@@ -1059,6 +1164,32 @@ class MediaLibraryNotifier extends StateNotifier<MediaLibraryState> {
state = state.copyWith(scanLogs: logs);
}
void _appendBackupLog(String message, {bool isError = false, Object? error}) {
if (isError) {
AppLogger.error('Backup', message, error: error);
} else {
AppLogger.info('Backup', message);
}
final logs = [
...state.scanLogs,
MediaLibraryScanLog(
createdAt: DateTime.now(),
message: '【数据备份】$message',
isError: isError,
),
];
if (logs.length > 120) logs.removeRange(0, logs.length - 120);
state = state.copyWith(scanLogs: logs);
unawaited(_persistScanHistory());
}
String _backupFailureReason(Object error) {
final value = error.toString().trim();
return value
.replaceFirst(RegExp(r'^Exception:\s*'), '')
.replaceFirst(RegExp(r'^DioException \[[^\]]+\]:\s*'), '网络请求失败:');
}
List<MediaLibraryScanLog> _loadScanHistory() {
final raw = StorageManager.get<dynamic>(StorageKeys.mediaScanHistory);
if (raw is! List) return const [];