diff --git a/lib/core/logging/app_logger.dart b/lib/core/logging/app_logger.dart index bf9ead3..dbce887 100644 --- a/lib/core/logging/app_logger.dart +++ b/lib/core/logging/app_logger.dart @@ -36,6 +36,7 @@ class AppLogEntry { 'HTTP' => '网络', 'Auth' => '认证', 'Media' => '媒体库', + 'Backup' => '数据备份', 'CloudIndex' => '全盘索引', 'Player' => '播放器', 'Storage' => '本地存储', diff --git a/lib/pages/media_library_page.dart b/lib/pages/media_library_page.dart index 13679f8..4c1e5a4 100644 --- a/lib/pages/media_library_page.dart +++ b/lib/pages/media_library_page.dart @@ -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 { List backups; try { backups = await notifier.cloudScrapedBackups(); + } catch (_) { + return; } finally { if (mounted) setState(() => _backupBusy = false); } diff --git a/lib/pages/workspace_page.dart b/lib/pages/workspace_page.dart index 5a9556c..f21433a 100644 --- a/lib/pages/workspace_page.dart +++ b/lib/pages/workspace_page.dart @@ -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, + ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/providers/media_library_provider.dart b/lib/providers/media_library_provider.dart index 7b2c036..a452e4c 100644 --- a/lib/providers/media_library_provider.dart +++ b/lib/providers/media_library_provider.dart @@ -314,6 +314,7 @@ class MediaLibraryNotifier extends StateNotifier { Future 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 { ); Directory? temporaryDirectory; try { + _appendBackupLog('正在确认云盘备份目录'); final destination = await _resolveCloudBackupDestination(); state = state.copyWith( cloudBackupSync: CloudBackupSyncProgress( @@ -337,14 +339,17 @@ class MediaLibraryNotifier extends StateNotifier { 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 { isActive: true, ), ); + _appendBackupLog('正在上传备份到 ${destination.path}'); await _api!.fileUpload( backup, parentID: destination.id, @@ -383,17 +389,20 @@ class MediaLibraryNotifier extends StateNotifier { 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 { Future> 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 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 { 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 { 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 _loadScanHistory() { final raw = StorageManager.get(StorageKeys.mediaScanHistory); if (raw is! List) return const [];