From fdc4060ac0a04d9cc3b3164ca5e3852012b62f11 Mon Sep 17 00:00:00 2001 From: xkeyC <3334969096@qq.com> Date: Sun, 6 Apr 2025 00:00:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20log=20=E5=88=86=E6=9E=90=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/common/utils/multi_window_manager.dart | 34 +- .../utils/multi_window_manager.freezed.dart | 43 +- lib/common/utils/multi_window_manager.g.dart | 4 + .../log_analyze_ui/log_analyze_provider.dart | 400 +++++++++++++++++- .../log_analyze_provider.freezed.dart | 198 +++++++++ .../log_analyze_provider.g.dart | 178 +++++++- .../tools/log_analyze_ui/log_analyze_ui.dart | 245 ++++++++++- lib/ui/tools/tools_ui_model.dart | 2 +- lib/ui/tools/tools_ui_model.g.dart | 2 +- pubspec.yaml | 3 +- 10 files changed, 1070 insertions(+), 39 deletions(-) create mode 100644 lib/ui/tools/log_analyze_ui/log_analyze_provider.freezed.dart diff --git a/lib/common/utils/multi_window_manager.dart b/lib/common/utils/multi_window_manager.dart index ed01cd2..79be9ba 100644 --- a/lib/common/utils/multi_window_manager.dart +++ b/lib/common/utils/multi_window_manager.dart @@ -7,6 +7,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hexcolor/hexcolor.dart'; import 'package:starcitizen_doctor/app.dart'; +import 'package:starcitizen_doctor/common/helper/log_helper.dart'; +import 'package:starcitizen_doctor/generated/l10n.dart'; import 'package:starcitizen_doctor/ui/tools/log_analyze_ui/log_analyze_ui.dart'; import 'base_utils.dart'; @@ -21,6 +23,7 @@ class MultiWindowAppState with _$MultiWindowAppState { required String backgroundColor, required String menuColor, required String micaColor, + required List gameInstallPaths, String? languageCode, String? countryCode, }) = _MultiWindowAppState; @@ -29,12 +32,17 @@ class MultiWindowAppState with _$MultiWindowAppState { } class MultiWindowManager { - static Future launchSubWindow(String type, AppGlobalState appGlobalState) async { + static Future launchSubWindow(String type, String title, AppGlobalState appGlobalState) async { + final gameInstallPaths = await SCLoggerHelper.getGameInstallPath(await SCLoggerHelper.getLauncherLogList() ?? []); final window = await DesktopMultiWindow.createWindow(jsonEncode({ 'window_type': type, - 'app_state': _appStateToWindowState(appGlobalState).toJson(), + 'app_state': _appStateToWindowState( + appGlobalState, + gameInstallPaths: gameInstallPaths, + ).toJson(), })); - window.setTitle("Log 分析器"); + window.setFrame(const Rect.fromLTWH(0, 0, 900, 1200)); + window.setTitle(title); await window.center(); await window.show(); // sendAppStateBroadcast(appGlobalState); @@ -48,29 +56,28 @@ class MultiWindowManager { ); } - static MultiWindowAppState _appStateToWindowState(AppGlobalState appGlobalState) { + static MultiWindowAppState _appStateToWindowState(AppGlobalState appGlobalState, {List? gameInstallPaths}) { return MultiWindowAppState( backgroundColor: colorToHexCode(appGlobalState.themeConf.backgroundColor), menuColor: colorToHexCode(appGlobalState.themeConf.menuColor), micaColor: colorToHexCode(appGlobalState.themeConf.micaColor), languageCode: appGlobalState.appLocale?.languageCode, countryCode: appGlobalState.appLocale?.countryCode, + gameInstallPaths: gameInstallPaths ?? [], ); } static void runSubWindowApp(List args) { final argument = args[2].isEmpty ? const {} : jsonDecode(args[2]) as Map; + final windowAppState = MultiWindowAppState.fromJson(argument['app_state'] ?? {}); Widget? windowWidget; switch (argument["window_type"]) { case "log_analyze": - windowWidget = const ToolsLogAnalyzeDialogUI(); + windowWidget = ToolsLogAnalyzeDialogUI(appState: windowAppState); break; default: throw Exception('Unknown window type'); } - - final windowAppState = MultiWindowAppState.fromJson(argument['app_state'] ?? {}); - return runApp(ProviderScope( child: FluentApp( title: "StarCitizenToolBox", @@ -81,8 +88,9 @@ class MultiWindowManager { GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, FluentLocalizations.delegate, + S.delegate, ], - supportedLocales: const [Locale('en', 'US')], + supportedLocales: S.delegate.supportedLocales, home: windowWidget, theme: FluentThemeData( brightness: Brightness.dark, @@ -94,10 +102,10 @@ class MultiWindowManager { micaBackgroundColor: HexColor(windowAppState.micaColor), buttonTheme: ButtonThemeData( defaultButtonStyle: ButtonStyle( - shape: WidgetStateProperty.all(RoundedRectangleBorder( - borderRadius: BorderRadius.circular(4), - side: BorderSide(color: Colors.white.withValues(alpha: .01)))), - ))), + shape: WidgetStateProperty.all(RoundedRectangleBorder( + borderRadius: BorderRadius.circular(4), + side: BorderSide(color: Colors.white.withValues(alpha: .01)))), + ))), locale: windowAppState.languageCode != null ? Locale(windowAppState.languageCode!, windowAppState.countryCode) : null, diff --git a/lib/common/utils/multi_window_manager.freezed.dart b/lib/common/utils/multi_window_manager.freezed.dart index 58256d4..79e0cf1 100644 --- a/lib/common/utils/multi_window_manager.freezed.dart +++ b/lib/common/utils/multi_window_manager.freezed.dart @@ -23,6 +23,7 @@ mixin _$MultiWindowAppState { String get backgroundColor => throw _privateConstructorUsedError; String get menuColor => throw _privateConstructorUsedError; String get micaColor => throw _privateConstructorUsedError; + List get gameInstallPaths => throw _privateConstructorUsedError; String? get languageCode => throw _privateConstructorUsedError; String? get countryCode => throw _privateConstructorUsedError; @@ -46,6 +47,7 @@ abstract class $MultiWindowAppStateCopyWith<$Res> { {String backgroundColor, String menuColor, String micaColor, + List gameInstallPaths, String? languageCode, String? countryCode}); } @@ -68,6 +70,7 @@ class _$MultiWindowAppStateCopyWithImpl<$Res, $Val extends MultiWindowAppState> Object? backgroundColor = null, Object? menuColor = null, Object? micaColor = null, + Object? gameInstallPaths = null, Object? languageCode = freezed, Object? countryCode = freezed, }) { @@ -84,6 +87,10 @@ class _$MultiWindowAppStateCopyWithImpl<$Res, $Val extends MultiWindowAppState> ? _value.micaColor : micaColor // ignore: cast_nullable_to_non_nullable as String, + gameInstallPaths: null == gameInstallPaths + ? _value.gameInstallPaths + : gameInstallPaths // ignore: cast_nullable_to_non_nullable + as List, languageCode: freezed == languageCode ? _value.languageCode : languageCode // ignore: cast_nullable_to_non_nullable @@ -108,6 +115,7 @@ abstract class _$$MultiWindowAppStateImplCopyWith<$Res> {String backgroundColor, String menuColor, String micaColor, + List gameInstallPaths, String? languageCode, String? countryCode}); } @@ -128,6 +136,7 @@ class __$$MultiWindowAppStateImplCopyWithImpl<$Res> Object? backgroundColor = null, Object? menuColor = null, Object? micaColor = null, + Object? gameInstallPaths = null, Object? languageCode = freezed, Object? countryCode = freezed, }) { @@ -144,6 +153,10 @@ class __$$MultiWindowAppStateImplCopyWithImpl<$Res> ? _value.micaColor : micaColor // ignore: cast_nullable_to_non_nullable as String, + gameInstallPaths: null == gameInstallPaths + ? _value._gameInstallPaths + : gameInstallPaths // ignore: cast_nullable_to_non_nullable + as List, languageCode: freezed == languageCode ? _value.languageCode : languageCode // ignore: cast_nullable_to_non_nullable @@ -163,8 +176,10 @@ class _$MultiWindowAppStateImpl implements _MultiWindowAppState { {required this.backgroundColor, required this.menuColor, required this.micaColor, + required final List gameInstallPaths, this.languageCode, - this.countryCode}); + this.countryCode}) + : _gameInstallPaths = gameInstallPaths; factory _$MultiWindowAppStateImpl.fromJson(Map json) => _$$MultiWindowAppStateImplFromJson(json); @@ -175,6 +190,15 @@ class _$MultiWindowAppStateImpl implements _MultiWindowAppState { final String menuColor; @override final String micaColor; + final List _gameInstallPaths; + @override + List get gameInstallPaths { + if (_gameInstallPaths is EqualUnmodifiableListView) + return _gameInstallPaths; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_gameInstallPaths); + } + @override final String? languageCode; @override @@ -182,7 +206,7 @@ class _$MultiWindowAppStateImpl implements _MultiWindowAppState { @override String toString() { - return 'MultiWindowAppState(backgroundColor: $backgroundColor, menuColor: $menuColor, micaColor: $micaColor, languageCode: $languageCode, countryCode: $countryCode)'; + return 'MultiWindowAppState(backgroundColor: $backgroundColor, menuColor: $menuColor, micaColor: $micaColor, gameInstallPaths: $gameInstallPaths, languageCode: $languageCode, countryCode: $countryCode)'; } @override @@ -196,6 +220,8 @@ class _$MultiWindowAppStateImpl implements _MultiWindowAppState { other.menuColor == menuColor) && (identical(other.micaColor, micaColor) || other.micaColor == micaColor) && + const DeepCollectionEquality() + .equals(other._gameInstallPaths, _gameInstallPaths) && (identical(other.languageCode, languageCode) || other.languageCode == languageCode) && (identical(other.countryCode, countryCode) || @@ -204,8 +230,14 @@ class _$MultiWindowAppStateImpl implements _MultiWindowAppState { @JsonKey(includeFromJson: false, includeToJson: false) @override - int get hashCode => Object.hash(runtimeType, backgroundColor, menuColor, - micaColor, languageCode, countryCode); + int get hashCode => Object.hash( + runtimeType, + backgroundColor, + menuColor, + micaColor, + const DeepCollectionEquality().hash(_gameInstallPaths), + languageCode, + countryCode); /// Create a copy of MultiWindowAppState /// with the given fields replaced by the non-null parameter values. @@ -229,6 +261,7 @@ abstract class _MultiWindowAppState implements MultiWindowAppState { {required final String backgroundColor, required final String menuColor, required final String micaColor, + required final List gameInstallPaths, final String? languageCode, final String? countryCode}) = _$MultiWindowAppStateImpl; @@ -242,6 +275,8 @@ abstract class _MultiWindowAppState implements MultiWindowAppState { @override String get micaColor; @override + List get gameInstallPaths; + @override String? get languageCode; @override String? get countryCode; diff --git a/lib/common/utils/multi_window_manager.g.dart b/lib/common/utils/multi_window_manager.g.dart index a0ca374..851f7d6 100644 --- a/lib/common/utils/multi_window_manager.g.dart +++ b/lib/common/utils/multi_window_manager.g.dart @@ -12,6 +12,9 @@ _$MultiWindowAppStateImpl _$$MultiWindowAppStateImplFromJson( backgroundColor: json['backgroundColor'] as String, menuColor: json['menuColor'] as String, micaColor: json['micaColor'] as String, + gameInstallPaths: (json['gameInstallPaths'] as List) + .map((e) => e as String) + .toList(), languageCode: json['languageCode'] as String?, countryCode: json['countryCode'] as String?, ); @@ -22,6 +25,7 @@ Map _$$MultiWindowAppStateImplToJson( 'backgroundColor': instance.backgroundColor, 'menuColor': instance.menuColor, 'micaColor': instance.micaColor, + 'gameInstallPaths': instance.gameInstallPaths, 'languageCode': instance.languageCode, 'countryCode': instance.countryCode, }; diff --git a/lib/ui/tools/log_analyze_ui/log_analyze_provider.dart b/lib/ui/tools/log_analyze_ui/log_analyze_provider.dart index 2a2d1cc..31508da 100644 --- a/lib/ui/tools/log_analyze_ui/log_analyze_provider.dart +++ b/lib/ui/tools/log_analyze_ui/log_analyze_provider.dart @@ -1,12 +1,408 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:fluent_ui/fluent_ui.dart' show debugPrint; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:intl/intl.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:starcitizen_doctor/common/helper/log_helper.dart'; +import 'package:watcher/watcher.dart'; part 'log_analyze_provider.g.dart'; +part 'log_analyze_provider.freezed.dart'; + +const Map logAnalyzeSearchTypeMap = { + null: "全部", + "info": "基础信息", + "player_login": "账户相关", + "fatal_collision": "致命碰撞", + "vehicle_destruction": "载具损毁", + "actor_death": "角色死亡", + "statistics": "统计信息", + "game_crash": "游戏崩溃", + "request_location_inventory": "本地库存", +}; + +@freezed +class LogAnalyzeLineData with _$LogAnalyzeLineData { + const factory LogAnalyzeLineData({ + required String type, + required String title, + String? data, + String? dateTime, + }) = _LogAnalyzeLineData; +} @riverpod class ToolsLogAnalyze extends _$ToolsLogAnalyze { @override - void build() async { - return; + Future> build(String gameInstallPath) async { + final logFile = File("$gameInstallPath/Game.log"); + debugPrint("[ToolsLogAnalyze] logFile: ${logFile.absolute.path}"); + if (gameInstallPath.isEmpty || !(await logFile.exists())) { + return [ + LogAnalyzeLineData( + type: "error", + title: "未找到 log 文件", + ) + ]; + } + state = AsyncData([]); + _launchLogAnalyze(logFile); + return state.value ?? []; + } + + String _playerName = ""; // 记录玩家名称 + int _killCount = 0; // 记录击杀其他实体次数 + int _deathCount = 0; // 记录被击杀次数 + int _selfKillCount = 0; // 记录自杀次数 + DateTime? _gameStartTime; // 记录游戏开始时间 + int _gameCrashLineNumber = -1; // 记录游戏崩溃行号 + int _currentLineNumber = 0; // 当前行号 + + void _launchLogAnalyze(File logFile, {int startLine = 0}) async { + final logLines = utf8.decode((await logFile.readAsBytes()), allowMalformed: true).split("\n"); + debugPrint("[ToolsLogAnalyze] logLines: ${logLines.length}"); + if (startLine == 0) { + _killCount = 0; + _deathCount = 0; + _selfKillCount = 0; + _gameStartTime = null; + _gameCrashLineNumber = -1; + } else if (startLine > logLines.length) { + // 考虑文件重新写入的情况 + ref.invalidateSelf(); + } + _currentLineNumber = logLines.length; + // for i in logLines + for (var i = 0; i < logLines.length; i++) { + // 支持追加模式 + if (i < startLine) continue; + final line = logLines[i]; + if (line.isEmpty) continue; + final data = _handleLogLine(line, i); + if (data != null) { + _appendResult(data); + // wait for ui update + await Future.delayed(Duration(seconds: 0)); + } + } + + final lastLineDateTime = + _gameStartTime != null ? _getLogLineDateTime(logLines.lastWhere((e) => e.startsWith("<20"))) : null; + + // 检查游戏崩溃行号 + if (_gameCrashLineNumber > 0) { + // crashInfo 从 logLines _gameCrashLineNumber 开始到最后一行 + final crashInfo = logLines.sublist(_gameCrashLineNumber); + // 运行一键诊断 + final info = SCLoggerHelper.getGameRunningLogInfo(crashInfo); + crashInfo.add("----- 汉化盒子一键诊断 -----"); + if (info != null) { + crashInfo.add(info.key); + if (info.value.isNotEmpty) { + crashInfo.add("详细信息:${info.value}"); + } + } else { + crashInfo.add("未检测到游戏崩溃信息"); + } + _appendResult(LogAnalyzeLineData( + type: "game_crash", + title: "游戏崩溃 ", + data: crashInfo.join("\n"), + dateTime: lastLineDateTime != null ? _dateTimeFormatter.format(lastLineDateTime) : null, + )); + } + + // 击杀总结 + if (_killCount > 0 || _deathCount > 0) { + _appendResult(LogAnalyzeLineData( + type: "statistics", + title: "击杀总结", + data: "击杀次数:$_killCount 死亡次数:$_deathCount 自杀次数:$_selfKillCount", + )); + } + + // 统计游玩时长,_gameStartTime 减去 最后一行的时间 + if (_gameStartTime != null) { + if (lastLineDateTime != null) { + final duration = lastLineDateTime.difference(_gameStartTime!); + _appendResult(LogAnalyzeLineData( + type: "statistics", + title: "游玩时长", + data: "${duration.inHours} 小时 ${duration.inMinutes.remainder(60)} 分钟 ${duration.inSeconds.remainder(60)} 秒", + )); + } + } + + _startListenFile(logFile); + } + + // 避免重复调用 + bool _isListenEnabled = false; + + Future _startListenFile(File logFile) async { + _isListenEnabled = true; + debugPrint("[ToolsLogAnalyze] startListenFile: ${logFile.absolute.path}"); + // 监听文件 + late final StreamSubscription sub; + sub = FileWatcher(logFile.absolute.path, pollingDelay: Duration(seconds: 1)).events.listen((change) { + sub.cancel(); + if (!_isListenEnabled) return; + _isListenEnabled = false; + debugPrint("[ToolsLogAnalyze] logFile change: ${change.type}"); + switch (change.type) { + case ChangeType.MODIFY: + // 移除统计信息 + final newList = state.value?.where((e) => e.type != "statistics").toList(); + state = AsyncData(newList ?? []); + return _launchLogAnalyze(logFile, startLine: _currentLineNumber); + case ChangeType.ADD: + case ChangeType.REMOVE: + ref.invalidateSelf(); + return; + } + }); + ref.onDispose(() { + sub.cancel(); + }); + } + + LogAnalyzeLineData? _handleLogLine(String line, int index) { + // 处理 log 行,检测可以提取的内容 + if (_gameStartTime == null) { + _gameStartTime = _getLogLineDateTime(line); + return LogAnalyzeLineData( + type: "info", + title: "游戏启动", + dateTime: _getLogLineDateTimeString(line), + ); + } + // 读取游戏加载时间 + final gameLoading = _logGetGameLoading(line); + if (gameLoading != null) { + return LogAnalyzeLineData( + type: "info", + title: "游戏加载", + data: "模式:${gameLoading.$1} 用时:${gameLoading.$2} 秒", + dateTime: _getLogLineDateTimeString(line), + ); + } + + // 运行基础时间解析器 + final baseEvent = _baseEventDecoder(line); + if (baseEvent != null) { + switch (baseEvent) { + case "AccountLoginCharacterStatus_Character": + // 角色登录 + return _logGetCharacterName(line); + case "FatalCollision": + // 载具致命碰撞 + return _logGetFatalCollision(line); + case "Vehicle Destruction": + // 载具损毁 + return _logGetVehicleDestruction(line); + case "Actor Death": + // 角色死亡 + return _logGetActorDeath(line); + case "RequestLocationInventory": + // 请求本地库存 + return _logGetRequestLocationInventory(line); + } + } + + if (line.contains("[CIG] CCIGBroker::FastShutdown")) { + return LogAnalyzeLineData( + type: "info", + title: "游戏关闭", + dateTime: _getLogLineDateTimeString(line), + ); + } + + if (line.contains("Cloud Imperium Games public crash handler")) { + _gameCrashLineNumber = index; + } + + return null; + } + + void _appendResult(LogAnalyzeLineData data) { + // 追加结果到 state + final currentState = state.value; + if (currentState != null) { + state = AsyncData([...currentState, data]); + } else { + state = AsyncData([data]); + } + } + + final _baseRegExp = RegExp(r'\[Notice\]\s+<([^>]+)>'); + + String? _baseEventDecoder(String line) { + // 解析 log 行的基本信息 + final match = _baseRegExp.firstMatch(line); + if (match != null) { + final type = match.group(1); + return type; + } + return null; + } + + final _gameLoadingRegExp = + RegExp(r'<[^>]+>\s+Loading screen for\s+(\w+)\s+:\s+SC_Frontend closed after\s+(\d+\.\d+)\s+seconds'); + + (String, String)? _logGetGameLoading(String line) { + final match = _gameLoadingRegExp.firstMatch(line); + if (match != null) { + return (match.group(1) ?? "-", match.group(2) ?? "-"); + } + return null; + } + + final DateFormat _dateTimeFormatter = DateFormat('yyyy-MM-dd HH:mm:ss:SSS'); + final _logDateTimeRegExp = RegExp(r'<(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)>'); + + DateTime? _getLogLineDateTime(String line) { + // 提取 log 行的时间 + final match = _logDateTimeRegExp.firstMatch(line); + if (match != null) { + final dateTimeString = match.group(1); + if (dateTimeString != null) { + return DateTime.parse(dateTimeString); + } + } + return null; + } + + String? _getLogLineDateTimeString(String line) { + // 提取 log 行的时间 + final dateTime = _getLogLineDateTime(line); + if (dateTime != null) { + return _dateTimeFormatter.format(dateTime); + } + return null; + } + + // 安全提取函数 + String? safeExtract(RegExp pattern, String line) => pattern.firstMatch(line)?.group(1)?.trim(); + + LogAnalyzeLineData? _logGetFatalCollision(String line) { + final patterns = { + 'zone': RegExp(r'\[Part:[^\]]*?Zone:\s*([^,\]]+)'), + 'player_pilot': RegExp(r'PlayerPilot:\s*(\d)'), + 'hit_entity': RegExp(r'hitting entity:\s*(\w+)'), + 'hit_entity_vehicle': RegExp(r'hitting entity:[^\[]*\[Zone:\s*([^\s-]+)'), + 'distance': RegExp(r'Distance:\s*([\d.]+)') + }; + + final zone = safeExtract(patterns['zone']!, line) ?? 'Unknown'; + final playerPilot = (safeExtract(patterns['player_pilot']!, line) ?? '0') == '1'; + final hitEntity = safeExtract(patterns['hit_entity']!, line) ?? 'Unknown'; + final hitEntityVehicle = safeExtract(patterns['hit_entity_vehicle']!, line) ?? 'Unknown Vehicle'; + final distance = double.tryParse(safeExtract(patterns['distance']!, line) ?? '') ?? 0.0; + + return LogAnalyzeLineData( + type: "fatal_collision", + title: "致命碰撞", + data: "区域:$zone 玩家驾驶:${playerPilot ? '✅' : '❌'} 碰撞实体:$hitEntity \n碰撞载具: $hitEntityVehicle 碰撞距离:$distance ", + dateTime: _getLogLineDateTimeString(line), + ); + } + + LogAnalyzeLineData? _logGetVehicleDestruction(String line) { + final pattern = RegExp(r"Vehicle\s+'([^']+)'.*?" // 载具型号 + r"in zone\s+'([^']+)'.*?" // Zone + r"destroy level \d+ to (\d+).*?" // 损毁等级 + r"caused by\s+'([^']+)'" // 责任方 + ); + final match = pattern.firstMatch(line); + if (match != null) { + final vehicleModel = match.group(1) ?? 'Unknown'; + final zone = match.group(2) ?? 'Unknown'; + final destructionLevel = int.tryParse(match.group(3) ?? '') ?? 0; + final causedBy = match.group(4) ?? 'Unknown'; + + const destructionLevelMap = {1: "软死亡", 2: "解体"}; + + return LogAnalyzeLineData( + type: "vehicle_destruction", + title: "载具损毁", + data: + "载具型号:$vehicleModel \n区域:$zone \n损毁等级:$destructionLevel (${destructionLevelMap[destructionLevel]}) 责任方:$causedBy", + dateTime: _getLogLineDateTimeString(line), + ); + } + return null; + } + + LogAnalyzeLineData? _logGetActorDeath(String line) { + final pattern = RegExp(r"CActor::Kill: '([^']+)'.*?" // 受害者ID + r"in zone '([^']+)'.*?" // 死亡位置区域 + r"killed by '([^']+)'.*?" // 击杀者ID + r"with damage type '([^']+)'" // 伤害类型 + ); + + final match = pattern.firstMatch(line); + if (match != null) { + final victimId = match.group(1) ?? 'Unknown'; + final zone = match.group(2) ?? 'Unknown'; + final killerId = match.group(3) ?? 'Unknown'; + final damageType = match.group(4) ?? 'Unknown'; + + if (victimId.trim() == killerId.trim()) { + // 自杀 + _selfKillCount++; + } else { + if (victimId.trim() == _playerName) { + _deathCount++; + } + if (killerId.trim() == _playerName) { + _killCount++; + } + } + + return LogAnalyzeLineData( + type: "actor_death", + title: "角色死亡", + data: "受害者ID:$victimId 死因:$damageType \n击杀者ID:$killerId \n区域:$zone", + dateTime: _getLogLineDateTimeString(line), + ); + } + + return null; + } + + LogAnalyzeLineData? _logGetCharacterName(String line) { + final pattern = RegExp(r"name\s+([^-]+)"); + final match = pattern.firstMatch(line); + if (match != null) { + final characterName = match.group(1)?.trim() ?? 'Unknown'; + _playerName = characterName.trim(); // 更新玩家名称 + return LogAnalyzeLineData( + type: "player_login", + title: "玩家 $characterName 登录 ...", + dateTime: _getLogLineDateTimeString(line), + ); + } + return null; + } + + LogAnalyzeLineData? _logGetRequestLocationInventory(String line) { + final pattern = RegExp(r"Player\[([^\]]+)\].*?Location\[([^\]]+)\]"); + final match = pattern.firstMatch(line); + if (match != null) { + final playerId = match.group(1) ?? 'Unknown'; + final location = match.group(2) ?? 'Unknown'; + + return LogAnalyzeLineData( + type: "request_location_inventory", + title: "查看本地库存", + data: "玩家ID:$playerId 位置:$location", + dateTime: _getLogLineDateTimeString(line), + ); + } + return null; } } diff --git a/lib/ui/tools/log_analyze_ui/log_analyze_provider.freezed.dart b/lib/ui/tools/log_analyze_ui/log_analyze_provider.freezed.dart new file mode 100644 index 0000000..56065fa --- /dev/null +++ b/lib/ui/tools/log_analyze_ui/log_analyze_provider.freezed.dart @@ -0,0 +1,198 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'log_analyze_provider.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models'); + +/// @nodoc +mixin _$LogAnalyzeLineData { + String get type => throw _privateConstructorUsedError; + String get title => throw _privateConstructorUsedError; + String? get data => throw _privateConstructorUsedError; + String? get dateTime => throw _privateConstructorUsedError; + + /// Create a copy of LogAnalyzeLineData + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + $LogAnalyzeLineDataCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $LogAnalyzeLineDataCopyWith<$Res> { + factory $LogAnalyzeLineDataCopyWith( + LogAnalyzeLineData value, $Res Function(LogAnalyzeLineData) then) = + _$LogAnalyzeLineDataCopyWithImpl<$Res, LogAnalyzeLineData>; + @useResult + $Res call({String type, String title, String? data, String? dateTime}); +} + +/// @nodoc +class _$LogAnalyzeLineDataCopyWithImpl<$Res, $Val extends LogAnalyzeLineData> + implements $LogAnalyzeLineDataCopyWith<$Res> { + _$LogAnalyzeLineDataCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + /// Create a copy of LogAnalyzeLineData + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? type = null, + Object? title = null, + Object? data = freezed, + Object? dateTime = freezed, + }) { + return _then(_value.copyWith( + type: null == type + ? _value.type + : type // ignore: cast_nullable_to_non_nullable + as String, + title: null == title + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String, + data: freezed == data + ? _value.data + : data // ignore: cast_nullable_to_non_nullable + as String?, + dateTime: freezed == dateTime + ? _value.dateTime + : dateTime // ignore: cast_nullable_to_non_nullable + as String?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$LogAnalyzeLineDataImplCopyWith<$Res> + implements $LogAnalyzeLineDataCopyWith<$Res> { + factory _$$LogAnalyzeLineDataImplCopyWith(_$LogAnalyzeLineDataImpl value, + $Res Function(_$LogAnalyzeLineDataImpl) then) = + __$$LogAnalyzeLineDataImplCopyWithImpl<$Res>; + @override + @useResult + $Res call({String type, String title, String? data, String? dateTime}); +} + +/// @nodoc +class __$$LogAnalyzeLineDataImplCopyWithImpl<$Res> + extends _$LogAnalyzeLineDataCopyWithImpl<$Res, _$LogAnalyzeLineDataImpl> + implements _$$LogAnalyzeLineDataImplCopyWith<$Res> { + __$$LogAnalyzeLineDataImplCopyWithImpl(_$LogAnalyzeLineDataImpl _value, + $Res Function(_$LogAnalyzeLineDataImpl) _then) + : super(_value, _then); + + /// Create a copy of LogAnalyzeLineData + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? type = null, + Object? title = null, + Object? data = freezed, + Object? dateTime = freezed, + }) { + return _then(_$LogAnalyzeLineDataImpl( + type: null == type + ? _value.type + : type // ignore: cast_nullable_to_non_nullable + as String, + title: null == title + ? _value.title + : title // ignore: cast_nullable_to_non_nullable + as String, + data: freezed == data + ? _value.data + : data // ignore: cast_nullable_to_non_nullable + as String?, + dateTime: freezed == dateTime + ? _value.dateTime + : dateTime // ignore: cast_nullable_to_non_nullable + as String?, + )); + } +} + +/// @nodoc + +class _$LogAnalyzeLineDataImpl implements _LogAnalyzeLineData { + const _$LogAnalyzeLineDataImpl( + {required this.type, required this.title, this.data, this.dateTime}); + + @override + final String type; + @override + final String title; + @override + final String? data; + @override + final String? dateTime; + + @override + String toString() { + return 'LogAnalyzeLineData(type: $type, title: $title, data: $data, dateTime: $dateTime)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$LogAnalyzeLineDataImpl && + (identical(other.type, type) || other.type == type) && + (identical(other.title, title) || other.title == title) && + (identical(other.data, data) || other.data == data) && + (identical(other.dateTime, dateTime) || + other.dateTime == dateTime)); + } + + @override + int get hashCode => Object.hash(runtimeType, type, title, data, dateTime); + + /// Create a copy of LogAnalyzeLineData + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$LogAnalyzeLineDataImplCopyWith<_$LogAnalyzeLineDataImpl> get copyWith => + __$$LogAnalyzeLineDataImplCopyWithImpl<_$LogAnalyzeLineDataImpl>( + this, _$identity); +} + +abstract class _LogAnalyzeLineData implements LogAnalyzeLineData { + const factory _LogAnalyzeLineData( + {required final String type, + required final String title, + final String? data, + final String? dateTime}) = _$LogAnalyzeLineDataImpl; + + @override + String get type; + @override + String get title; + @override + String? get data; + @override + String? get dateTime; + + /// Create a copy of LogAnalyzeLineData + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$LogAnalyzeLineDataImplCopyWith<_$LogAnalyzeLineDataImpl> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/lib/ui/tools/log_analyze_ui/log_analyze_provider.g.dart b/lib/ui/tools/log_analyze_ui/log_analyze_provider.g.dart index 698374c..7932242 100644 --- a/lib/ui/tools/log_analyze_ui/log_analyze_provider.g.dart +++ b/lib/ui/tools/log_analyze_ui/log_analyze_provider.g.dart @@ -6,21 +6,175 @@ part of 'log_analyze_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$toolsLogAnalyzeHash() => r'a31922fe5ee020b06e8d494486c39bdd261af34c'; +String _$toolsLogAnalyzeHash() => r'cc8aed5b4eeb6c8feb35c59ef484dc61c92a5549'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +abstract class _$ToolsLogAnalyze + extends BuildlessAutoDisposeAsyncNotifier> { + late final String gameInstallPath; + + FutureOr> build( + String gameInstallPath, + ); +} /// See also [ToolsLogAnalyze]. @ProviderFor(ToolsLogAnalyze) -final toolsLogAnalyzeProvider = - AutoDisposeNotifierProvider.internal( - ToolsLogAnalyze.new, - name: r'toolsLogAnalyzeProvider', - debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product') - ? null - : _$toolsLogAnalyzeHash, - dependencies: null, - allTransitiveDependencies: null, -); +const toolsLogAnalyzeProvider = ToolsLogAnalyzeFamily(); -typedef _$ToolsLogAnalyze = AutoDisposeNotifier; +/// See also [ToolsLogAnalyze]. +class ToolsLogAnalyzeFamily + extends Family>> { + /// See also [ToolsLogAnalyze]. + const ToolsLogAnalyzeFamily(); + + /// See also [ToolsLogAnalyze]. + ToolsLogAnalyzeProvider call( + String gameInstallPath, + ) { + return ToolsLogAnalyzeProvider( + gameInstallPath, + ); + } + + @override + ToolsLogAnalyzeProvider getProviderOverride( + covariant ToolsLogAnalyzeProvider provider, + ) { + return call( + provider.gameInstallPath, + ); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'toolsLogAnalyzeProvider'; +} + +/// See also [ToolsLogAnalyze]. +class ToolsLogAnalyzeProvider extends AutoDisposeAsyncNotifierProviderImpl< + ToolsLogAnalyze, List> { + /// See also [ToolsLogAnalyze]. + ToolsLogAnalyzeProvider( + String gameInstallPath, + ) : this._internal( + () => ToolsLogAnalyze()..gameInstallPath = gameInstallPath, + from: toolsLogAnalyzeProvider, + name: r'toolsLogAnalyzeProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$toolsLogAnalyzeHash, + dependencies: ToolsLogAnalyzeFamily._dependencies, + allTransitiveDependencies: + ToolsLogAnalyzeFamily._allTransitiveDependencies, + gameInstallPath: gameInstallPath, + ); + + ToolsLogAnalyzeProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.gameInstallPath, + }) : super.internal(); + + final String gameInstallPath; + + @override + FutureOr> runNotifierBuild( + covariant ToolsLogAnalyze notifier, + ) { + return notifier.build( + gameInstallPath, + ); + } + + @override + Override overrideWith(ToolsLogAnalyze Function() create) { + return ProviderOverride( + origin: this, + override: ToolsLogAnalyzeProvider._internal( + () => create()..gameInstallPath = gameInstallPath, + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + gameInstallPath: gameInstallPath, + ), + ); + } + + @override + AutoDisposeAsyncNotifierProviderElement> createElement() { + return _ToolsLogAnalyzeProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is ToolsLogAnalyzeProvider && + other.gameInstallPath == gameInstallPath; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, gameInstallPath.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin ToolsLogAnalyzeRef + on AutoDisposeAsyncNotifierProviderRef> { + /// The parameter `gameInstallPath` of this provider. + String get gameInstallPath; +} + +class _ToolsLogAnalyzeProviderElement + extends AutoDisposeAsyncNotifierProviderElement> with ToolsLogAnalyzeRef { + _ToolsLogAnalyzeProviderElement(super.provider); + + @override + String get gameInstallPath => + (origin as ToolsLogAnalyzeProvider).gameInstallPath; +} // ignore_for_file: type=lint // ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/ui/tools/log_analyze_ui/log_analyze_ui.dart b/lib/ui/tools/log_analyze_ui/log_analyze_ui.dart index 0512801..49169b3 100644 --- a/lib/ui/tools/log_analyze_ui/log_analyze_ui.dart +++ b/lib/ui/tools/log_analyze_ui/log_analyze_ui.dart @@ -1,18 +1,253 @@ import 'package:fluent_ui/fluent_ui.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:starcitizen_doctor/common/utils/multi_window_manager.dart'; + +import 'log_analyze_provider.dart'; class ToolsLogAnalyzeDialogUI extends HookConsumerWidget { - const ToolsLogAnalyzeDialogUI({super.key}); + final MultiWindowAppState appState; + + const ToolsLogAnalyzeDialogUI({super.key, required this.appState}); @override Widget build(BuildContext context, WidgetRef ref) { + final selectedPath = useState(appState.gameInstallPaths.firstOrNull); + final logResp = ref.watch(toolsLogAnalyzeProvider(selectedPath.value ?? "")); + final searchText = useState(""); + final searchType = useState(null); + final lastListSize = useState(0); + + final listCtrl = useScrollController(); + + _diffData(logResp, lastListSize, listCtrl); return ScaffoldPage( - header: const PageHeader( - title: Text("Log 分析器"), - ), content: Column( - children: [], + children: [ + // game Path selector + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + children: [ + const Text("游戏安装路径"), + const SizedBox(width: 10), + Expanded( + child: ComboBox( + isExpanded: true, + value: selectedPath.value, + items: [ + for (final path in appState.gameInstallPaths) + ComboBoxItem( + value: path, + child: Text(path), + ), + ], + onChanged: (value) => selectedPath.value = value, + placeholder: const Text("请选择游戏安装路径"), + ), + ), + const SizedBox(width: 10), + // 刷新 IconButton + Button( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), + child: const Icon(FluentIcons.refresh), + ), + onPressed: () { + ref.invalidate(toolsLogAnalyzeProvider(selectedPath.value ?? "")); + }, + ), + ], + ), + ), + SizedBox(height: 8), + // 搜索,筛选 + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14), + child: Row( + children: [ + // 输入框 + Expanded( + child: TextFormBox( + prefix: Padding( + padding: const EdgeInsets.only(left: 12), + child: Icon(FluentIcons.search), + ), + placeholder: "输入关键字搜索内容", + onChanged: (value) { + searchText.value = value.trim(); + }, + ), + ), + SizedBox(width: 6), + // 筛选 ComboBox + ComboBox( + isExpanded: false, + value: searchType.value, + placeholder: const Text("全部"), + items: logAnalyzeSearchTypeMap.entries + .map((e) => ComboBoxItem( + value: e.key, + child: Text(e.value), + )) + .toList(), + onChanged: (value) { + searchType.value = value; + }, + ), + ], + ), + ), + SizedBox(height: 3), + Container( + margin: EdgeInsets.symmetric(vertical: 12, horizontal: 14), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: Colors.white.withValues(alpha: 0.1), + width: 1, + ), + ), + ), + ), + // log analyze result + if (!logResp.hasValue) + Expanded( + child: Center( + child: ProgressRing(), + )) + else + Expanded( + child: ListView.builder( + controller: listCtrl, + itemCount: logResp.value!.length, + padding: const EdgeInsets.symmetric(horizontal: 14), + itemBuilder: (BuildContext context, int index) { + final item = logResp.value![index]; + if (searchText.value.isNotEmpty) { + // 搜索 + if (!item.toString().contains(searchText.value)) { + return const SizedBox.shrink(); + } + } + if (searchType.value != null) { + if (item.type != searchType.value) { + return const SizedBox.shrink(); + } + } + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: SelectionArea( + child: Container( + decoration: BoxDecoration( + color: _getBackgroundColor(item.type), + borderRadius: BorderRadius.circular(8), + ), + padding: EdgeInsets.symmetric( + vertical: 8, + horizontal: 10, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + _getIconWidget(item.type), + const SizedBox(width: 10), + Expanded( + child: Text.rich( + TextSpan(children: [ + TextSpan( + text: item.title, + ), + if (item.dateTime != null) + TextSpan( + text: " (${item.dateTime})", + style: TextStyle( + color: Colors.white.withValues(alpha: 0.5), + fontSize: 12, + ), + ), + ]), + ), + ), + ], + ), + if (item.data != null) + Container( + margin: EdgeInsets.only(top: 8), + child: Text( + item.data!, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.8), + fontSize: 13, + ), + ), + ), + ], + ), + ), + ), + ); + }, + )) + ], ), ); } + + Widget _getIconWidget(String key) { + const iconMap = { + "info": Icon(FluentIcons.info), + "account_login": Icon(FluentIcons.accounts), + "player_login": Icon(FontAwesomeIcons.solidIdCard), + "fatal_collision": Icon(FontAwesomeIcons.personFallingBurst), + "vehicle_destruction": Icon(FontAwesomeIcons.carBurst), + "actor_death": Icon(FontAwesomeIcons.skull), + "statistics": Icon(FontAwesomeIcons.chartSimple), + "game_crash": Icon(FontAwesomeIcons.bug), + "request_location_inventory": Icon(FontAwesomeIcons.box), + }; + return iconMap[key] ?? const Icon(FluentIcons.info); + } + + Color _getBackgroundColor(String type) { + switch (type) { + case "actor_death": + case "fatal_collision": + return Colors.red.withValues(alpha: .3); + case "game_crash": + return Color.fromRGBO(0, 0, 128, 1); + case "vehicle_destruction": + return Colors.yellow.withValues(alpha: .1); + default: + return Colors.white.withValues(alpha: .06); + } + } + + void _diffData( + AsyncValue> logResp, ValueNotifier lastListSize, ScrollController listCtrl) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (lastListSize.value == 0) { + lastListSize.value = logResp.value?.length ?? 0; + } else { + // 判断当前列表是否在底部 + if (listCtrl.position.pixels >= listCtrl.position.maxScrollExtent) { + // 如果在底部,判断数据是否有变化 + if ((logResp.value?.length ?? 0) > lastListSize.value) { + Future.delayed(Duration(milliseconds: 100)).then((_) { + listCtrl.jumpTo(listCtrl.position.maxScrollExtent); + }); + lastListSize.value = logResp.value?.length ?? 0; + } else { + // 回顶部 + if (listCtrl.position.pixels > 0) { + listCtrl.jumpTo(0); + } + } + } + } + }); + } } diff --git a/lib/ui/tools/tools_ui_model.dart b/lib/ui/tools/tools_ui_model.dart index 1ab42ed..041bd71 100644 --- a/lib/ui/tools/tools_ui_model.dart +++ b/lib/ui/tools/tools_ui_model.dart @@ -568,6 +568,6 @@ class ToolsUIModel extends _$ToolsUIModel { return; } if (!context.mounted) return; - await MultiWindowManager.launchSubWindow("log_analyze", appGlobalState); + await MultiWindowManager.launchSubWindow("log_analyze", "SC汉化盒子: log 分析器", appGlobalState); } } diff --git a/lib/ui/tools/tools_ui_model.g.dart b/lib/ui/tools/tools_ui_model.g.dart index df2406d..789b826 100644 --- a/lib/ui/tools/tools_ui_model.g.dart +++ b/lib/ui/tools/tools_ui_model.g.dart @@ -6,7 +6,7 @@ part of 'tools_ui_model.dart'; // RiverpodGenerator // ************************************************************************** -String _$toolsUIModelHash() => r'af6e6de30c191a7c9a4e2a1c96a688fba6ee086c'; +String _$toolsUIModelHash() => r'cd72f7833fa5696baf9022d16d10d7951387df7e'; /// See also [ToolsUIModel]. @ProviderFor(ToolsUIModel) diff --git a/pubspec.yaml b/pubspec.yaml index 97d10dc..4655ffb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -17,7 +17,7 @@ dependencies: riverpod_annotation: ^2.6.1 flutter_hooks: ^0.21.2 hooks_riverpod: ^2.6.1 - json_annotation: ^4.8.1 + json_annotation: ^4.9.0 go_router: ^14.0.1 window_manager: ^0.4.0 fluent_ui: ^4.8.6 @@ -67,6 +67,7 @@ dependencies: shelf: ^1.4.1 qr_flutter: ^4.1.0 desktop_multi_window: ^0.2.1 + watcher: ^1.1.1 dependency_overrides: http: ^1.1.2