feat: log 分析器

This commit is contained in:
xkeyC 2025-04-06 00:00:30 +08:00
parent 8dd7ef53a1
commit fdc4060ac0
10 changed files with 1070 additions and 39 deletions

View File

@ -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<String> gameInstallPaths,
String? languageCode,
String? countryCode,
}) = _MultiWindowAppState;
@ -29,12 +32,17 @@ class MultiWindowAppState with _$MultiWindowAppState {
}
class MultiWindowManager {
static Future<void> launchSubWindow(String type, AppGlobalState appGlobalState) async {
static Future<void> 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<String>? 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<String> args) {
final argument = args[2].isEmpty ? const {} : jsonDecode(args[2]) as Map<String, dynamic>;
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,

View File

@ -23,6 +23,7 @@ mixin _$MultiWindowAppState {
String get backgroundColor => throw _privateConstructorUsedError;
String get menuColor => throw _privateConstructorUsedError;
String get micaColor => throw _privateConstructorUsedError;
List<String> 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<String> 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<String>,
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<String> 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<String>,
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<String> gameInstallPaths,
this.languageCode,
this.countryCode});
this.countryCode})
: _gameInstallPaths = gameInstallPaths;
factory _$MultiWindowAppStateImpl.fromJson(Map<String, dynamic> json) =>
_$$MultiWindowAppStateImplFromJson(json);
@ -175,6 +190,15 @@ class _$MultiWindowAppStateImpl implements _MultiWindowAppState {
final String menuColor;
@override
final String micaColor;
final List<String> _gameInstallPaths;
@override
List<String> 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<String> gameInstallPaths,
final String? languageCode,
final String? countryCode}) = _$MultiWindowAppStateImpl;
@ -242,6 +275,8 @@ abstract class _MultiWindowAppState implements MultiWindowAppState {
@override
String get micaColor;
@override
List<String> get gameInstallPaths;
@override
String? get languageCode;
@override
String? get countryCode;

View File

@ -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<dynamic>)
.map((e) => e as String)
.toList(),
languageCode: json['languageCode'] as String?,
countryCode: json['countryCode'] as String?,
);
@ -22,6 +25,7 @@ Map<String, dynamic> _$$MultiWindowAppStateImplToJson(
'backgroundColor': instance.backgroundColor,
'menuColor': instance.menuColor,
'micaColor': instance.micaColor,
'gameInstallPaths': instance.gameInstallPaths,
'languageCode': instance.languageCode,
'countryCode': instance.countryCode,
};

View File

@ -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<String?, String> 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 {
Future<List<LogAnalyzeLineData>> 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<void> _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;
}
}

View File

@ -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>(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<LogAnalyzeLineData> 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;
}

View File

@ -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<List<LogAnalyzeLineData>> {
late final String gameInstallPath;
FutureOr<List<LogAnalyzeLineData>> build(
String gameInstallPath,
);
}
/// See also [ToolsLogAnalyze].
@ProviderFor(ToolsLogAnalyze)
final toolsLogAnalyzeProvider =
AutoDisposeNotifierProvider<ToolsLogAnalyze, void>.internal(
ToolsLogAnalyze.new,
const toolsLogAnalyzeProvider = ToolsLogAnalyzeFamily();
/// See also [ToolsLogAnalyze].
class ToolsLogAnalyzeFamily
extends Family<AsyncValue<List<LogAnalyzeLineData>>> {
/// 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<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'toolsLogAnalyzeProvider';
}
/// See also [ToolsLogAnalyze].
class ToolsLogAnalyzeProvider extends AutoDisposeAsyncNotifierProviderImpl<
ToolsLogAnalyze, List<LogAnalyzeLineData>> {
/// See also [ToolsLogAnalyze].
ToolsLogAnalyzeProvider(
String gameInstallPath,
) : this._internal(
() => ToolsLogAnalyze()..gameInstallPath = gameInstallPath,
from: toolsLogAnalyzeProvider,
name: r'toolsLogAnalyzeProvider',
debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$toolsLogAnalyzeHash,
dependencies: null,
allTransitiveDependencies: null,
dependencies: ToolsLogAnalyzeFamily._dependencies,
allTransitiveDependencies:
ToolsLogAnalyzeFamily._allTransitiveDependencies,
gameInstallPath: gameInstallPath,
);
typedef _$ToolsLogAnalyze = AutoDisposeNotifier<void>;
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<List<LogAnalyzeLineData>> 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<ToolsLogAnalyze,
List<LogAnalyzeLineData>> 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<List<LogAnalyzeLineData>> {
/// The parameter `gameInstallPath` of this provider.
String get gameInstallPath;
}
class _ToolsLogAnalyzeProviderElement
extends AutoDisposeAsyncNotifierProviderElement<ToolsLogAnalyze,
List<LogAnalyzeLineData>> 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

View File

@ -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<String?>(appState.gameInstallPaths.firstOrNull);
final logResp = ref.watch(toolsLogAnalyzeProvider(selectedPath.value ?? ""));
final searchText = useState<String>("");
final searchType = useState<String?>(null);
final lastListSize = useState<int>(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<String>(
isExpanded: true,
value: selectedPath.value,
items: [
for (final path in appState.gameInstallPaths)
ComboBoxItem<String>(
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<String>(
isExpanded: false,
value: searchType.value,
placeholder: const Text("全部"),
items: logAnalyzeSearchTypeMap.entries
.map((e) => ComboBoxItem<String>(
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<List<LogAnalyzeLineData>> logResp, ValueNotifier<int> 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);
}
}
}
}
});
}
}

View File

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

View File

@ -6,7 +6,7 @@ part of 'tools_ui_model.dart';
// RiverpodGenerator
// **************************************************************************
String _$toolsUIModelHash() => r'af6e6de30c191a7c9a4e2a1c96a688fba6ee086c';
String _$toolsUIModelHash() => r'cd72f7833fa5696baf9022d16d10d7951387df7e';
/// See also [ToolsUIModel].
@ProviderFor(ToolsUIModel)

View File

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