From 6b202475d100c5f0405c01106e0125dea8985a82 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Sat, 18 Jul 2026 22:16:08 +0800 Subject: [PATCH] add unified debug and release logging --- lib/core/http/dio_client.dart | 6 +- .../interceptors/app_log_interceptor.dart | 34 ++++++ lib/core/logging/app_logger.dart | 114 ++++++++++++++++++ lib/main.dart | 15 +++ lib/pages/settings_page.dart | 25 ++++ lib/providers/media_library_provider.dart | 6 + lib/widgets/app_log_dialog.dart | 79 ++++++++++++ 7 files changed, 276 insertions(+), 3 deletions(-) create mode 100644 lib/core/http/interceptors/app_log_interceptor.dart create mode 100644 lib/core/logging/app_logger.dart create mode 100644 lib/widgets/app_log_dialog.dart diff --git a/lib/core/http/dio_client.dart b/lib/core/http/dio_client.dart index 68fc846..d501b58 100644 --- a/lib/core/http/dio_client.dart +++ b/lib/core/http/dio_client.dart @@ -4,6 +4,7 @@ import 'package:dio/dio.dart'; import 'package:dio/io.dart'; import '../config/app_config.dart'; +import 'interceptors/app_log_interceptor.dart'; import 'interceptors/auth_interceptor.dart'; import 'interceptors/response_interceptor.dart'; @@ -20,15 +21,14 @@ class DioClient { baseUrl: AppConfig.apiBase, interceptors: [ _authInterceptor!, + AppLogInterceptor(), ResponseInterceptor(), ], ); accountDio = _createDio( baseUrl: AppConfig.accountBase, - interceptors: [ - ResponseInterceptor(), - ], + interceptors: [AppLogInterceptor(), ResponseInterceptor()], ); } diff --git a/lib/core/http/interceptors/app_log_interceptor.dart b/lib/core/http/interceptors/app_log_interceptor.dart new file mode 100644 index 0000000..a08788e --- /dev/null +++ b/lib/core/http/interceptors/app_log_interceptor.dart @@ -0,0 +1,34 @@ +import 'package:dio/dio.dart'; + +import '../../logging/app_logger.dart'; + +class AppLogInterceptor extends Interceptor { + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + AppLogger.debug( + 'HTTP', + '→ ${options.method} ${options.uri.path}${options.uri.hasQuery ? '?${options.uri.query}' : ''}', + ); + handler.next(options); + } + + @override + void onResponse(Response response, ResponseInterceptorHandler handler) { + AppLogger.debug( + 'HTTP', + '← ${response.statusCode ?? 0} ${response.requestOptions.method} ${response.requestOptions.uri.path}', + ); + handler.next(response); + } + + @override + void onError(DioException err, ErrorInterceptorHandler handler) { + AppLogger.error( + 'HTTP', + '${err.requestOptions.method} ${err.requestOptions.uri.path} 失败', + error: err.message, + stackTrace: err.stackTrace, + ); + handler.next(err); + } +} diff --git a/lib/core/logging/app_logger.dart b/lib/core/logging/app_logger.dart new file mode 100644 index 0000000..b9f4f13 --- /dev/null +++ b/lib/core/logging/app_logger.dart @@ -0,0 +1,114 @@ +import 'dart:async'; +import 'dart:developer' as developer; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; + +enum AppLogLevel { debug, info, warning, error } + +class AppLogEntry { + final DateTime timestamp; + final AppLogLevel level; + final String scope; + final String message; + + const AppLogEntry({ + required this.timestamp, + required this.level, + required this.scope, + required this.message, + }); + + String get text => + '${timestamp.toIso8601String()} [${level.name.toUpperCase()}] ' + '[$scope] $message'; +} + +/// One log sink for debug console, release file diagnostics and the in-app log +/// viewer. Never write request headers or authorization tokens here. +class AppLogger { + static const _maxEntries = 1200; + static final entries = ValueNotifier>(const []); + static File? _file; + static Future _pendingWrite = Future.value(); + + static Future initialize() async { + try { + final directory = await getApplicationSupportDirectory(); + final logsDirectory = Directory(path.join(directory.path, 'logs')); + await logsDirectory.create(recursive: true); + _file = File(path.join(logsDirectory.path, 'guangya.log')); + if (await _file!.exists() && await _file!.length() > 4 * 1024 * 1024) { + await _file!.rename( + path.join(logsDirectory.path, 'guangya.previous.log'), + ); + } + info('App', '日志中心已初始化:${_file!.path}'); + } catch (error) { + developer.log('日志中心初始化失败:$error', name: 'Guangya'); + } + } + + static void debug(String scope, String message) => + _write(AppLogLevel.debug, scope, message); + static void info(String scope, String message) => + _write(AppLogLevel.info, scope, message); + static void warning(String scope, String message) => + _write(AppLogLevel.warning, scope, message); + static void error( + String scope, + String message, { + Object? error, + StackTrace? stackTrace, + }) => _write( + AppLogLevel.error, + scope, + error == null ? message : '$message:$error', + stackTrace: stackTrace, + ); + + static void clear() => entries.value = const []; + + static void _write( + AppLogLevel level, + String scope, + String message, { + StackTrace? stackTrace, + }) { + if (kReleaseMode && level == AppLogLevel.debug) return; + final entry = AppLogEntry( + timestamp: DateTime.now(), + level: level, + scope: scope, + message: message, + ); + final next = [...entries.value, entry]; + entries.value = next.length > _maxEntries + ? next.sublist(next.length - _maxEntries) + : next; + developer.log( + entry.text, + name: 'Guangya', + level: switch (level) { + AppLogLevel.debug => 500, + AppLogLevel.info => 800, + AppLogLevel.warning => 900, + AppLogLevel.error => 1000, + }, + stackTrace: stackTrace, + ); + final file = _file; + if (file == null) return; + _pendingWrite = _pendingWrite + .then((_) async { + await file.writeAsString( + '${entry.text}${stackTrace == null ? '' : '\n$stackTrace'}\n', + mode: FileMode.append, + flush: false, + ); + }) + .catchError((_) {}); + } +} diff --git a/lib/main.dart b/lib/main.dart index 5916823..735cc1b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,11 @@ import 'dart:io'; +import 'dart:ui' show PlatformDispatcher; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:window_manager/window_manager.dart'; import 'package:media_kit/media_kit.dart'; import 'core/storage/storage_manager.dart'; +import 'core/logging/app_logger.dart'; import 'core/http/dio_client.dart'; import 'app/app.dart'; @@ -13,6 +15,19 @@ void main() async { // Init Hive for persistent storage await StorageManager.init(); + await AppLogger.initialize(); + FlutterError.onError = (details) { + AppLogger.error( + 'Flutter', + details.exceptionAsString(), + stackTrace: details.stack, + ); + FlutterError.presentError(details); + }; + PlatformDispatcher.instance.onError = (error, stackTrace) { + AppLogger.error('Dart', '未捕获异常', error: error, stackTrace: stackTrace); + return true; + }; // Init Dio HTTP client DioClient.init(); diff --git a/lib/pages/settings_page.dart b/lib/pages/settings_page.dart index 4ca1937..0d3a947 100644 --- a/lib/pages/settings_page.dart +++ b/lib/pages/settings_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../providers/theme_provider.dart'; import '../providers/media_library_provider.dart'; +import '../widgets/app_log_dialog.dart'; import '../core/storage/storage_manager.dart'; class SettingsDialog extends ConsumerStatefulWidget { @@ -306,6 +307,30 @@ class _SettingsDialogState extends ConsumerState { const SizedBox(height: 16), const ShadSeparator.horizontal(), const SizedBox(height: 16), + Text( + '诊断', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: cs.foreground, + ), + ), + const SizedBox(height: 12), + _SettingsRow( + icon: Icons.subject_rounded, + label: '运行日志', + child: ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: () => showShadDialog( + context: context, + builder: (_) => const AppLogDialog(), + ), + child: const Text('查看'), + ), + ), + const SizedBox(height: 16), + const ShadSeparator.horizontal(), + const SizedBox(height: 16), Text( '关于', style: TextStyle( diff --git a/lib/providers/media_library_provider.dart b/lib/providers/media_library_provider.dart index 67b3651..44d4bc0 100644 --- a/lib/providers/media_library_provider.dart +++ b/lib/providers/media_library_provider.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:flutter_riverpod/legacy.dart'; import '../api/guangya_api.dart'; +import '../core/logging/app_logger.dart'; import '../core/storage/file_metadata_cache.dart'; import '../core/storage/media_library_store.dart'; import '../core/storage/storage_manager.dart'; @@ -619,6 +620,11 @@ class MediaLibraryNotifier extends StateNotifier { } void _appendScanLog(String message, {bool isError = false}) { + if (isError) { + AppLogger.error('Media', message); + } else { + AppLogger.info('Media', message); + } final logs = [ ...state.scanLogs, MediaLibraryScanLog( diff --git a/lib/widgets/app_log_dialog.dart b/lib/widgets/app_log_dialog.dart new file mode 100644 index 0000000..ef8e747 --- /dev/null +++ b/lib/widgets/app_log_dialog.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:shadcn_ui/shadcn_ui.dart'; + +import '../core/logging/app_logger.dart'; + +class AppLogDialog extends StatelessWidget { + const AppLogDialog({super.key}); + + @override + Widget build(BuildContext context) { + final cs = ShadTheme.of(context).colorScheme; + return ShadDialog( + title: const Text('运行日志'), + description: const Text('实时显示当前会话日志;RELEASE 同时写入本地日志文件。'), + actions: [ + ShadButton.outline( + size: ShadButtonSize.sm, + onPressed: AppLogger.clear, + leading: const Icon(Icons.delete_outline_rounded, size: 16), + child: const Text('清空显示'), + ), + ShadButton( + size: ShadButtonSize.sm, + onPressed: () => Navigator.of(context).pop(), + child: const Text('关闭'), + ), + ], + child: SizedBox( + width: 840, + height: 520, + child: ValueListenableBuilder>( + valueListenable: AppLogger.entries, + builder: (_, entries, _) { + if (entries.isEmpty) { + return Center( + child: Text( + '暂无运行日志', + style: TextStyle(color: cs.mutedForeground), + ), + ); + } + return Container( + decoration: BoxDecoration( + color: cs.muted.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: cs.border), + ), + child: ListView.builder( + padding: const EdgeInsets.all(10), + itemCount: entries.length, + itemBuilder: (_, index) { + final entry = entries[entries.length - index - 1]; + final color = switch (entry.level) { + AppLogLevel.debug => cs.mutedForeground, + AppLogLevel.info => cs.foreground, + AppLogLevel.warning => cs.primary, + AppLogLevel.error => cs.destructive, + }; + return Padding( + padding: const EdgeInsets.only(bottom: 7), + child: SelectableText( + entry.text, + style: TextStyle( + fontFamily: 'monospace', + fontSize: 11, + height: 1.35, + color: color, + ), + ), + ); + }, + ), + ); + }, + ), + ), + ); + } +}