feat: init YearlyReportUI

This commit is contained in:
xkeyC 2025-12-17 12:26:57 +08:00
parent 9a28257f4a
commit 6ec973144e
12 changed files with 1914 additions and 221 deletions

View File

@ -104,6 +104,22 @@ class GameLogAnalyzer {
// //
static final _requestLocationInventoryPattern = RegExp(r"Player\[([^\]]+)\].*?Location\[([^\]]+)\]"); static final _requestLocationInventoryPattern = RegExp(r"Player\[([^\]]+)\].*?Location\[([^\]]+)\]");
//
static final vehicleControlPattern = RegExp(r"granted control token for '([^']+)'\s+\[(\d+)\]");
/// 使
static DateTime? getLogLineDateTime(String line) => _getLogLineDateTime(line);
///
static String? getLogLineDateTimeString(String line) => _getLogLineDateTimeString(line);
/// ID
/// : ANVL_Hornet_F7A_Mk2_3467069517923 -> ANVL_Hornet_F7A_Mk2
static String removeVehicleId(String vehicleName) {
final regex = RegExp(r'_\d+$');
return vehicleName.replaceAll(regex, '');
}
/// ///
/// ///
/// [logFile] /// [logFile]

View File

@ -1,7 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'dart:convert'; import 'dart:convert';
import 'dart:isolate';
import 'package:starcitizen_doctor/common/helper/game_log_analyzer.dart'; import 'package:starcitizen_doctor/common/helper/game_log_analyzer.dart';
import 'package:starcitizen_doctor/common/utils/log.dart';
/// ///
class YearlyReportData { class YearlyReportData {
@ -18,6 +18,13 @@ class YearlyReportData {
final DateTime? earliestPlayDate; // (05:00) final DateTime? earliestPlayDate; // (05:00)
final DateTime? latestPlayDate; // (04:00) final DateTime? latestPlayDate; // (04:00)
//
final Duration? longestSession; //
final DateTime? longestSessionDate; //
final Duration? shortestSession; // (5)
final DateTime? shortestSessionDate; //
final Duration? averageSessionTime; //
// //
final int yearlyVehicleDestructionCount; // final int yearlyVehicleDestructionCount; //
final String? mostDestroyedVehicle; // final String? mostDestroyedVehicle; //
@ -25,19 +32,23 @@ class YearlyReportData {
final String? mostPilotedVehicle; // final String? mostPilotedVehicle; //
final int mostPilotedVehicleCount; // final int mostPilotedVehicleCount; //
//
final int yearlyKillCount; //
final int yearlyDeathCount; //
// //
final int accountCount; // final int accountCount; //
final String? mostPlayedAccount; // final String? mostPlayedAccount; //
final int mostPlayedAccountSessionCount; // final int mostPlayedAccountSessionCount; //
//
final List<MapEntry<String, int>> topLocations; // Top 访
// (K/D)
final int yearlyKillCount; //
final int yearlyDeathCount; //
final int yearlySelfKillCount; //
// () // ()
final Map<String, int> vehicleDestructionDetails; //
final Map<String, int> vehiclePilotedDetails; // final Map<String, int> vehiclePilotedDetails; //
final Map<String, int> accountSessionDetails; // final Map<String, int> accountSessionDetails; //
final Map<String, int> locationDetails; // 访
const YearlyReportData({ const YearlyReportData({
required this.totalLaunchCount, required this.totalLaunchCount,
@ -49,49 +60,44 @@ class YearlyReportData {
this.yearlyFirstLaunchTime, this.yearlyFirstLaunchTime,
this.earliestPlayDate, this.earliestPlayDate,
this.latestPlayDate, this.latestPlayDate,
this.longestSession,
this.longestSessionDate,
this.shortestSession,
this.shortestSessionDate,
this.averageSessionTime,
required this.yearlyVehicleDestructionCount, required this.yearlyVehicleDestructionCount,
this.mostDestroyedVehicle, this.mostDestroyedVehicle,
required this.mostDestroyedVehicleCount, required this.mostDestroyedVehicleCount,
this.mostPilotedVehicle, this.mostPilotedVehicle,
required this.mostPilotedVehicleCount, required this.mostPilotedVehicleCount,
required this.yearlyKillCount,
required this.yearlyDeathCount,
required this.accountCount, required this.accountCount,
this.mostPlayedAccount, this.mostPlayedAccount,
required this.mostPlayedAccountSessionCount, required this.mostPlayedAccountSessionCount,
required this.vehicleDestructionDetails, required this.topLocations,
required this.yearlyKillCount,
required this.yearlyDeathCount,
required this.yearlySelfKillCount,
required this.vehiclePilotedDetails, required this.vehiclePilotedDetails,
required this.accountSessionDetails, required this.accountSessionDetails,
required this.locationDetails,
}); });
/// DateTime ISO 8601 /// DateTime UTC
/// : 2025-12-17T10:30:00.000+08:00 static int? _toUtcTimestamp(DateTime? dateTime) {
static String? _toIso8601WithTimezone(DateTime? dateTime) {
if (dateTime == null) return null; if (dateTime == null) return null;
final local = dateTime.toLocal(); return dateTime.toUtc().millisecondsSinceEpoch;
final offset = local.timeZoneOffset;
final sign = offset.isNegative ? '-' : '+';
final hours = offset.inHours.abs().toString().padLeft(2, '0');
final minutes = (offset.inMinutes.abs() % 60).toString().padLeft(2, '0');
// 使 ISO
final isoString = local.toIso8601String();
// 'Z' UTC
final baseString = isoString.endsWith('Z') ? isoString.substring(0, isoString.length - 1) : isoString;
return '$baseString$sign$hours:$minutes';
} }
/// JSON Map /// JSON Map
///
/// 使 UTC (int) timezoneOffsetMinutes
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
final now = DateTime.now(); final now = DateTime.now();
final offset = now.timeZoneOffset; final offset = now.timeZoneOffset;
final sign = offset.isNegative ? '-' : '+';
final hours = offset.inHours.abs().toString().padLeft(2, '0');
final minutes = (offset.inMinutes.abs() % 60).toString().padLeft(2, '0');
return { return {
// //
'generatedAt': _toIso8601WithTimezone(now), 'generatedAtUtc': _toUtcTimestamp(now),
'timezoneOffset': '$sign$hours:$minutes',
'timezoneOffsetMinutes': offset.inMinutes, 'timezoneOffsetMinutes': offset.inMinutes,
// //
@ -102,10 +108,17 @@ class YearlyReportData {
'totalCrashCount': totalCrashCount, 'totalCrashCount': totalCrashCount,
'yearlyCrashCount': yearlyCrashCount, 'yearlyCrashCount': yearlyCrashCount,
// () // (UTC )
'yearlyFirstLaunchTime': _toIso8601WithTimezone(yearlyFirstLaunchTime), 'yearlyFirstLaunchTimeUtc': _toUtcTimestamp(yearlyFirstLaunchTime),
'earliestPlayDate': _toIso8601WithTimezone(earliestPlayDate), 'earliestPlayDateUtc': _toUtcTimestamp(earliestPlayDate),
'latestPlayDate': _toIso8601WithTimezone(latestPlayDate), 'latestPlayDateUtc': _toUtcTimestamp(latestPlayDate),
//
'longestSessionMs': longestSession?.inMilliseconds,
'longestSessionDateUtc': _toUtcTimestamp(longestSessionDate),
'shortestSessionMs': shortestSession?.inMilliseconds,
'shortestSessionDateUtc': _toUtcTimestamp(shortestSessionDate),
'averageSessionTimeMs': averageSessionTime?.inMilliseconds,
// //
'yearlyVehicleDestructionCount': yearlyVehicleDestructionCount, 'yearlyVehicleDestructionCount': yearlyVehicleDestructionCount,
@ -114,28 +127,26 @@ class YearlyReportData {
'mostPilotedVehicle': mostPilotedVehicle, 'mostPilotedVehicle': mostPilotedVehicle,
'mostPilotedVehicleCount': mostPilotedVehicleCount, 'mostPilotedVehicleCount': mostPilotedVehicleCount,
//
'yearlyKillCount': yearlyKillCount,
'yearlyDeathCount': yearlyDeathCount,
// //
'accountCount': accountCount, 'accountCount': accountCount,
'mostPlayedAccount': mostPlayedAccount, 'mostPlayedAccount': mostPlayedAccount,
'mostPlayedAccountSessionCount': mostPlayedAccountSessionCount, 'mostPlayedAccountSessionCount': mostPlayedAccountSessionCount,
//
'topLocations': topLocations.map((e) => {'location': e.key, 'count': e.value}).toList(),
//
'yearlyKillCount': yearlyKillCount,
'yearlyDeathCount': yearlyDeathCount,
'yearlySelfKillCount': yearlySelfKillCount,
// //
'vehicleDestructionDetails': vehicleDestructionDetails,
'vehiclePilotedDetails': vehiclePilotedDetails, 'vehiclePilotedDetails': vehiclePilotedDetails,
'accountSessionDetails': accountSessionDetails, 'accountSessionDetails': accountSessionDetails,
'locationDetails': locationDetails,
}; };
} }
/// JSON
String toJsonString() => jsonEncode(toJson());
/// Base64 JSON
String toJsonBase64() => base64Encode(utf8.encode(toJsonString()));
@override @override
String toString() { String toString() {
return '''YearlyReportData( return '''YearlyReportData(
@ -148,13 +159,15 @@ class YearlyReportData {
yearlyFirstLaunchTime: $yearlyFirstLaunchTime, yearlyFirstLaunchTime: $yearlyFirstLaunchTime,
earliestPlayDate: $earliestPlayDate, earliestPlayDate: $earliestPlayDate,
latestPlayDate: $latestPlayDate, latestPlayDate: $latestPlayDate,
longestSession: $longestSession (on $longestSessionDate),
shortestSession: $shortestSession (on $shortestSessionDate),
averageSessionTime: $averageSessionTime,
yearlyVehicleDestructionCount: $yearlyVehicleDestructionCount, yearlyVehicleDestructionCount: $yearlyVehicleDestructionCount,
mostDestroyedVehicle: $mostDestroyedVehicle ($mostDestroyedVehicleCount), mostDestroyedVehicle: $mostDestroyedVehicle ($mostDestroyedVehicleCount),
mostPilotedVehicle: $mostPilotedVehicle ($mostPilotedVehicleCount), mostPilotedVehicle: $mostPilotedVehicle ($mostPilotedVehicleCount),
yearlyKillCount: $yearlyKillCount,
yearlyDeathCount: $yearlyDeathCount,
accountCount: $accountCount, accountCount: $accountCount,
mostPlayedAccount: $mostPlayedAccount ($mostPlayedAccountSessionCount), mostPlayedAccount: $mostPlayedAccount ($mostPlayedAccountSessionCount),
topLocations: ${topLocations.take(5).map((e) => '${e.key}: ${e.value}').join(', ')},
)'''; )''';
} }
} }
@ -166,6 +179,7 @@ class _LogFileStats {
bool hasCrash = false; bool hasCrash = false;
int killCount = 0; int killCount = 0;
int deathCount = 0; int deathCount = 0;
int selfKillCount = 0;
Set<String> playerNames = {}; Set<String> playerNames = {};
String? currentPlayerName; String? currentPlayerName;
String? firstPlayerName; // String? firstPlayerName; //
@ -176,9 +190,11 @@ class _LogFileStats {
// : (ID后) -> // : (ID后) ->
Map<String, int> vehiclePiloted = {}; Map<String, int> vehiclePiloted = {};
// // 访: ->
List<DateTime> yearlyStartTimes = []; Map<String, int> locationVisits = {};
List<DateTime> yearlyEndTimes = [];
//
List<_SessionInfo> yearlySessions = [];
/// ///
/// ///
@ -190,10 +206,20 @@ class _LogFileStats {
} }
} }
///
class _SessionInfo {
final DateTime startTime;
final DateTime endTime;
_SessionInfo({required this.startTime, required this.endTime});
Duration get duration => endTime.difference(startTime);
}
/// ///
class YearlyReportAnalyzer { class YearlyReportAnalyzer {
// GameLogAnalyzer //
static final _characterNamePattern = RegExp(r"name\s+([^-]+)"); static final _characterNamePattern = RegExp(r'name\s+(\w+)\s+signedIn');
static final _vehicleDestructionPattern = RegExp( static final _vehicleDestructionPattern = RegExp(
r"Vehicle\s+'([^']+)'.*?" // r"Vehicle\s+'([^']+)'.*?" //
r"in zone\s+'([^']+)'.*?" // Zone r"in zone\s+'([^']+)'.*?" // Zone
@ -206,6 +232,16 @@ class YearlyReportAnalyzer {
r"to zone '([^']+)'", // r"to zone '([^']+)'", //
); );
// Legacy ()
static final _legacyCharacterNamePattern = RegExp(r"name\s+([^-]+)");
static final _legacyActorDeathPattern = RegExp(
r"CActor::Kill: '([^']+)'.*?" // ID
r"in zone '([^']+)'.*?" //
r"killed by '([^']+)'.*?" // ID
r"with damage type '([^']+)'", //
);
static final _requestLocationInventoryPattern = RegExp(r"Player\[([^\]]+)\].*?Location\[([^\]]+)\]");
/// ///
static Future<_LogFileStats> _analyzeLogFile(File logFile, int targetYear) async { static Future<_LogFileStats> _analyzeLogFile(File logFile, int targetYear) async {
final stats = _LogFileStats(); final stats = _LogFileStats();
@ -231,21 +267,6 @@ class YearlyReportAnalyzer {
// () // ()
if (lineTime != null) { if (lineTime != null) {
stats.endTime = lineTime; stats.endTime = lineTime;
//
if (lineTime.year == targetYear) {
if (stats.yearlyStartTimes.isEmpty ||
stats.yearlyStartTimes.last.difference(lineTime).abs().inMinutes > 30) {
//
stats.yearlyStartTimes.add(lineTime);
}
//
if (stats.yearlyEndTimes.isEmpty) {
stats.yearlyEndTimes.add(lineTime);
} else {
stats.yearlyEndTimes[stats.yearlyEndTimes.length - 1] = lineTime;
}
}
} }
// //
@ -253,20 +274,21 @@ class YearlyReportAnalyzer {
stats.hasCrash = true; stats.hasCrash = true;
} }
// // ()
final nameMatch = _characterNamePattern.firstMatch(line); var nameMatch = _characterNamePattern.firstMatch(line);
nameMatch ??= _legacyCharacterNamePattern.firstMatch(line);
if (nameMatch != null) { if (nameMatch != null) {
final playerName = nameMatch.group(1)?.trim(); final playerName = nameMatch.group(1)?.trim();
if (playerName != null && playerName.isNotEmpty) { if (playerName != null && playerName.isNotEmpty && !playerName.contains(' ')) {
stats.currentPlayerName = playerName; stats.currentPlayerName = playerName;
stats.playerNames.add(playerName); stats.playerNames.add(playerName);
//
stats.firstPlayerName ??= playerName; stats.firstPlayerName ??= playerName;
} }
} }
// () //
if (lineTime != null && lineTime.year == targetYear) { if (lineTime != null && lineTime.year == targetYear) {
//
final destructionMatch = _vehicleDestructionPattern.firstMatch(line); final destructionMatch = _vehicleDestructionPattern.firstMatch(line);
if (destructionMatch != null) { if (destructionMatch != null) {
final vehicleModel = destructionMatch.group(1); final vehicleModel = destructionMatch.group(1);
@ -291,29 +313,86 @@ class YearlyReportAnalyzer {
} }
} }
// // ()
final deathMatch = _actorDeathPattern.firstMatch(line); var deathMatch = _actorDeathPattern.firstMatch(line);
if (deathMatch != null) { if (deathMatch != null) {
final victimId = deathMatch.group(1)?.trim(); final victimId = deathMatch.group(1)?.trim();
if (victimId != null && stats.currentPlayerName != null && victimId == stats.currentPlayerName) { if (victimId != null && stats.currentPlayerName != null && victimId == stats.currentPlayerName) {
stats.deathCount++; stats.deathCount++;
} }
} }
// ( - Legacy)
final legacyDeathMatch = _legacyActorDeathPattern.firstMatch(line);
if (legacyDeathMatch != null) {
final victimId = legacyDeathMatch.group(1)?.trim();
final killerId = legacyDeathMatch.group(3)?.trim();
if (victimId != null && stats.currentPlayerName != null) {
//
if (victimId == killerId) {
if (victimId == stats.currentPlayerName) {
stats.selfKillCount++;
}
} else {
//
if (victimId == stats.currentPlayerName) {
stats.deathCount++;
}
//
if (killerId == stats.currentPlayerName) {
stats.killCount++;
}
}
}
}
// 访 (RequestLocationInventory)
final locationMatch = _requestLocationInventoryPattern.firstMatch(line);
if (locationMatch != null) {
final location = locationMatch.group(2)?.trim();
if (location != null && location.isNotEmpty) {
// ID后缀
final cleanLocation = _cleanLocationName(location);
stats.locationVisits[cleanLocation] = (stats.locationVisits[cleanLocation] ?? 0) + 1;
}
}
} }
} }
//
if (stats.startTime != null && stats.endTime != null && stats.startTime!.year == targetYear) {
stats.yearlySessions.add(_SessionInfo(startTime: stats.startTime!, endTime: stats.endTime!));
}
} catch (e) { } catch (e) {
dPrint('[YearlyReportAnalyzer] Error analyzing log file: $e'); // Error handled silently in isolate
} }
return stats; return stats;
} }
/// ID后缀
static String _cleanLocationName(String location) {
// ID ( "_12345678")
final cleanPattern = RegExp(r'_\d{6,}$');
return location.replaceAll(cleanPattern, '');
}
/// ///
/// ///
/// [gameInstallPaths] ( ["D:/Games/StarCitizen/LIVE", "D:/Games/StarCitizen/PTU"]) /// [gameInstallPaths] ( ["D:/Games/StarCitizen/LIVE", "D:/Games/StarCitizen/PTU"])
/// [targetYear] /// [targetYear]
///
/// Isolate UI
static Future<YearlyReportData> generateReport(List<String> gameInstallPaths, int targetYear) async { static Future<YearlyReportData> generateReport(List<String> gameInstallPaths, int targetYear) async {
// Isolate UI
return await Isolate.run(() async {
return await _generateReportInIsolate(gameInstallPaths, targetYear);
});
}
/// Isolate
static Future<YearlyReportData> _generateReportInIsolate(List<String> gameInstallPaths, int targetYear) async {
final List<File> allLogFiles = []; final List<File> allLogFiles = [];
// //
@ -322,7 +401,6 @@ class YearlyReportAnalyzer {
// //
if (!await installDir.exists()) { if (!await installDir.exists()) {
dPrint('[YearlyReportAnalyzer] Install path does not exist: $installPath');
continue; continue;
} }
@ -344,10 +422,6 @@ class YearlyReportAnalyzer {
} }
} }
dPrint(
'[YearlyReportAnalyzer] Found ${allLogFiles.length} log files from ${gameInstallPaths.length} install paths',
);
// //
final futures = allLogFiles.map((file) => _analyzeLogFile(file, targetYear)); final futures = allLogFiles.map((file) => _analyzeLogFile(file, targetYear));
final allStatsRaw = await Future.wait(futures); final allStatsRaw = await Future.wait(futures);
@ -359,18 +433,13 @@ class YearlyReportAnalyzer {
for (final stats in allStatsRaw) { for (final stats in allStatsRaw) {
final key = stats.uniqueKey; final key = stats.uniqueKey;
if (key == null) { if (key == null) {
//
allStats.add(stats); allStats.add(stats);
} else if (!seenKeys.contains(key)) { } else if (!seenKeys.contains(key)) {
seenKeys.add(key); seenKeys.add(key);
allStats.add(stats); allStats.add(stats);
} else {
dPrint('[YearlyReportAnalyzer] Skipping duplicate log: $key');
} }
} }
dPrint('[YearlyReportAnalyzer] After deduplication: ${allStats.length} unique logs');
// //
int totalLaunchCount = allStats.length; int totalLaunchCount = allStats.length;
Duration totalPlayTime = Duration.zero; Duration totalPlayTime = Duration.zero;
@ -381,12 +450,23 @@ class YearlyReportAnalyzer {
DateTime? yearlyFirstLaunchTime; DateTime? yearlyFirstLaunchTime;
DateTime? earliestPlayDate; DateTime? earliestPlayDate;
DateTime? latestPlayDate; DateTime? latestPlayDate;
//
Duration? longestSession;
DateTime? longestSessionDate;
Duration? shortestSession;
DateTime? shortestSessionDate;
List<Duration> allSessionDurations = [];
// K/D
int yearlyKillCount = 0; int yearlyKillCount = 0;
int yearlyDeathCount = 0; int yearlyDeathCount = 0;
int yearlySelfKillCount = 0;
final Map<String, int> vehicleDestructionDetails = {}; final Map<String, int> vehicleDestructionDetails = {};
final Map<String, int> vehiclePilotedDetails = {}; final Map<String, int> vehiclePilotedDetails = {};
final Map<String, int> accountSessionDetails = {}; final Map<String, int> accountSessionDetails = {};
final Map<String, int> locationDetails = {};
for (final stats in allStats) { for (final stats in allStats) {
// //
@ -397,46 +477,57 @@ class YearlyReportAnalyzer {
// //
if (stats.hasCrash) { if (stats.hasCrash) {
totalCrashCount++; totalCrashCount++;
//
if (stats.endTime != null && stats.endTime!.year == targetYear) { if (stats.endTime != null && stats.endTime!.year == targetYear) {
yearlyCrashCount++; yearlyCrashCount++;
} }
} }
// //
for (int i = 0; i < stats.yearlyStartTimes.length; i++) { for (final session in stats.yearlySessions) {
yearlyLaunchCount++; yearlyLaunchCount++;
final startTime = stats.yearlyStartTimes[i]; final sessionDuration = session.duration;
final endTime = i < stats.yearlyEndTimes.length ? stats.yearlyEndTimes[i] : startTime; yearlyPlayTime += sessionDuration;
yearlyPlayTime += endTime.difference(startTime); allSessionDurations.add(sessionDuration);
// //
if (yearlyFirstLaunchTime == null || startTime.isBefore(yearlyFirstLaunchTime)) { if (yearlyFirstLaunchTime == null || session.startTime.isBefore(yearlyFirstLaunchTime)) {
yearlyFirstLaunchTime = startTime; yearlyFirstLaunchTime = session.startTime;
} }
// (05:00) // (05:00)
if (startTime.hour >= 5) { if (session.startTime.hour >= 5) {
if (earliestPlayDate == null || _timeOfDayIsEarlier(startTime, earliestPlayDate)) { if (earliestPlayDate == null || _timeOfDayIsEarlier(session.startTime, earliestPlayDate)) {
earliestPlayDate = startTime; earliestPlayDate = session.startTime;
} }
} }
// (04:00) // (04:00)
if (endTime.hour <= 4) { if (session.endTime.hour <= 4) {
if (latestPlayDate == null || _timeOfDayIsLater(endTime, latestPlayDate)) { if (latestPlayDate == null || _timeOfDayIsLater(session.endTime, latestPlayDate)) {
latestPlayDate = endTime; latestPlayDate = session.endTime;
}
}
//
if (longestSession == null || sessionDuration > longestSession) {
longestSession = sessionDuration;
longestSessionDate = session.startTime;
}
// (5)
if (sessionDuration.inMinutes >= 5) {
if (shortestSession == null || sessionDuration < shortestSession) {
shortestSession = sessionDuration;
shortestSessionDate = session.startTime;
} }
} }
} }
// // ( PU )
yearlyKillCount += stats.killCount;
yearlyDeathCount += stats.deathCount;
//
for (final entry in stats.vehicleDestruction.entries) { for (final entry in stats.vehicleDestruction.entries) {
vehicleDestructionDetails[entry.key] = (vehicleDestructionDetails[entry.key] ?? 0) + entry.value; if (!entry.key.contains('PU_')) {
vehicleDestructionDetails[entry.key] = (vehicleDestructionDetails[entry.key] ?? 0) + entry.value;
}
} }
// //
@ -444,10 +535,36 @@ class YearlyReportAnalyzer {
vehiclePilotedDetails[entry.key] = (vehiclePilotedDetails[entry.key] ?? 0) + entry.value; vehiclePilotedDetails[entry.key] = (vehiclePilotedDetails[entry.key] ?? 0) + entry.value;
} }
// K/D
yearlyKillCount += stats.killCount;
yearlyDeathCount += stats.deathCount;
yearlySelfKillCount += stats.selfKillCount;
// //
for (final playerName in stats.playerNames) { for (final playerName in stats.playerNames) {
accountSessionDetails[playerName] = (accountSessionDetails[playerName] ?? 0) + 1; if (playerName.length > 16) continue;
String targetKey = playerName;
// key
for (final key in accountSessionDetails.keys) {
if (key.toLowerCase() == playerName.toLowerCase()) {
targetKey = key;
break;
}
}
accountSessionDetails[targetKey] = (accountSessionDetails[targetKey] ?? 0) + 1;
} }
// 访
for (final entry in stats.locationVisits.entries) {
locationDetails[entry.key] = (locationDetails[entry.key] ?? 0) + entry.value;
}
}
//
Duration? averageSessionTime;
if (allSessionDurations.isNotEmpty) {
final totalMs = allSessionDurations.fold<int>(0, (sum, d) => sum + d.inMilliseconds);
averageSessionTime = Duration(milliseconds: totalMs ~/ allSessionDurations.length);
} }
// //
@ -480,6 +597,10 @@ class YearlyReportAnalyzer {
} }
} }
// Top 10
final sortedLocations = locationDetails.entries.toList()..sort((a, b) => b.value.compareTo(a.value));
final topLocations = sortedLocations.take(10).toList();
return YearlyReportData( return YearlyReportData(
totalLaunchCount: totalLaunchCount, totalLaunchCount: totalLaunchCount,
totalPlayTime: totalPlayTime, totalPlayTime: totalPlayTime,
@ -490,19 +611,26 @@ class YearlyReportAnalyzer {
yearlyFirstLaunchTime: yearlyFirstLaunchTime, yearlyFirstLaunchTime: yearlyFirstLaunchTime,
earliestPlayDate: earliestPlayDate, earliestPlayDate: earliestPlayDate,
latestPlayDate: latestPlayDate, latestPlayDate: latestPlayDate,
longestSession: longestSession,
longestSessionDate: longestSessionDate,
shortestSession: shortestSession,
shortestSessionDate: shortestSessionDate,
averageSessionTime: averageSessionTime,
yearlyVehicleDestructionCount: yearlyVehicleDestructionCount, yearlyVehicleDestructionCount: yearlyVehicleDestructionCount,
mostDestroyedVehicle: mostDestroyedVehicle, mostDestroyedVehicle: mostDestroyedVehicle,
mostDestroyedVehicleCount: mostDestroyedVehicleCount, mostDestroyedVehicleCount: mostDestroyedVehicleCount,
mostPilotedVehicle: mostPilotedVehicle, mostPilotedVehicle: mostPilotedVehicle,
mostPilotedVehicleCount: mostPilotedVehicleCount, mostPilotedVehicleCount: mostPilotedVehicleCount,
yearlyKillCount: yearlyKillCount,
yearlyDeathCount: yearlyDeathCount,
accountCount: accountSessionDetails.length, accountCount: accountSessionDetails.length,
mostPlayedAccount: mostPlayedAccount, mostPlayedAccount: mostPlayedAccount,
mostPlayedAccountSessionCount: mostPlayedAccountSessionCount, mostPlayedAccountSessionCount: mostPlayedAccountSessionCount,
vehicleDestructionDetails: vehicleDestructionDetails, topLocations: topLocations,
yearlyKillCount: yearlyKillCount,
yearlyDeathCount: yearlyDeathCount,
yearlySelfKillCount: yearlySelfKillCount,
vehiclePilotedDetails: vehiclePilotedDetails, vehiclePilotedDetails: vehiclePilotedDetails,
accountSessionDetails: accountSessionDetails, accountSessionDetails: accountSessionDetails,
locationDetails: locationDetails,
); );
} }

View File

@ -41,7 +41,7 @@ final class DcbViewerModelProvider
} }
} }
String _$dcbViewerModelHash() => r'f0af2a7b4451f746288e2c9565a418af80f58835'; String _$dcbViewerModelHash() => r'dfed59de5291e5a19cc481d0115fe91f5bcaf301';
abstract class _$DcbViewerModel extends $Notifier<DcbViewerState> { abstract class _$DcbViewerModel extends $Notifier<DcbViewerState> {
DcbViewerState build(); DcbViewerState build();

View File

@ -42,7 +42,7 @@ final class HomeGameLoginUIModelProvider
} }
String _$homeGameLoginUIModelHash() => String _$homeGameLoginUIModelHash() =>
r'217a57f797b37f3467be2e7711f220610e9e67d8'; r'd81831f54c6b1e98ea8a1e94b5e6049fe552996f';
abstract class _$HomeGameLoginUIModel extends $Notifier<HomeGameLoginState> { abstract class _$HomeGameLoginUIModel extends $Notifier<HomeGameLoginState> {
HomeGameLoginState build(); HomeGameLoginState build();

View File

@ -42,7 +42,7 @@ final class LocalizationUIModelProvider
} }
String _$localizationUIModelHash() => String _$localizationUIModelHash() =>
r'122f9f85da6e112165f4ff88667b45cf3cf3f43e'; r'7b398d3b2ddd306ff8f328be39f28200fe8bf49e';
abstract class _$LocalizationUIModel extends $Notifier<LocalizationUIState> { abstract class _$LocalizationUIModel extends $Notifier<LocalizationUIState> {
LocalizationUIState build(); LocalizationUIState build();

View File

@ -42,7 +42,7 @@ final class HomePerformanceUIModelProvider
} }
String _$homePerformanceUIModelHash() => String _$homePerformanceUIModelHash() =>
r'4c5c33fe7d85dc8f6bf0d019c1b870d285d594ff'; r'ae4771c7804abb1e5ba89bb1687061612f96b46b';
abstract class _$HomePerformanceUIModel abstract class _$HomePerformanceUIModel
extends $Notifier<HomePerformanceUIState> { extends $Notifier<HomePerformanceUIState> {

View File

@ -21,28 +21,84 @@ final Map<String?, String> logAnalyzeSearchTypeMap = {
"request_location_inventory": S.current.log_analyzer_filter_local_inventory, "request_location_inventory": S.current.log_analyzer_filter_local_inventory,
}; };
///
class LogFileInfo {
final String path;
final String displayName;
final bool isCurrentLog;
const LogFileInfo({required this.path, required this.displayName, required this.isCurrentLog});
}
///
Future<List<LogFileInfo>> getAvailableLogFiles(String gameInstallPath) async {
final List<LogFileInfo> logFiles = [];
if (gameInstallPath.isEmpty) return logFiles;
// Game.log
final currentLogFile = File('$gameInstallPath/Game.log');
if (await currentLogFile.exists()) {
logFiles.add(LogFileInfo(path: currentLogFile.path, displayName: 'Game.log (当前)', isCurrentLog: true));
}
// logbackups
final logBackupsDir = Directory('$gameInstallPath/logbackups');
if (await logBackupsDir.exists()) {
final entities = await logBackupsDir.list().toList();
//
entities.sort((a, b) => b.path.compareTo(a.path));
for (final entity in entities) {
if (entity is File && entity.path.endsWith('.log')) {
final fileName = entity.path.split(Platform.pathSeparator).last;
logFiles.add(LogFileInfo(path: entity.path, displayName: fileName, isCurrentLog: false));
}
}
}
return logFiles;
}
@riverpod @riverpod
class ToolsLogAnalyze extends _$ToolsLogAnalyze { class ToolsLogAnalyze extends _$ToolsLogAnalyze {
@override @override
Future<List<LogAnalyzeLineData>> build(String gameInstallPath, bool listSortReverse) async { Future<List<LogAnalyzeLineData>> build(
final logFile = File("$gameInstallPath/Game.log"); String gameInstallPath,
bool listSortReverse, {
String? selectedLogFile,
}) async {
//
final String logFilePath;
if (selectedLogFile != null && selectedLogFile.isNotEmpty) {
logFilePath = selectedLogFile;
} else {
logFilePath = "$gameInstallPath/Game.log";
}
final logFile = File(logFilePath);
debugPrint("[ToolsLogAnalyze] logFile: ${logFile.absolute.path}"); debugPrint("[ToolsLogAnalyze] logFile: ${logFile.absolute.path}");
if (gameInstallPath.isEmpty || !(await logFile.exists())) { if (gameInstallPath.isEmpty || !(await logFile.exists())) {
return [const LogAnalyzeLineData(type: "error", title: "未找到日志文件")]; return [const LogAnalyzeLineData(type: "error", title: "未找到日志文件")];
} }
state = const AsyncData([]); state = const AsyncData([]);
_launchLogAnalyze(logFile); _launchLogAnalyze(logFile, selectedLogFile == null);
return state.value ?? []; return state.value ?? [];
} }
void _launchLogAnalyze(File logFile) async { void _launchLogAnalyze(File logFile, bool enableWatch) async {
// 使 GameLogAnalyzer // 使 GameLogAnalyzer
final result = await GameLogAnalyzer.analyzeLogFile(logFile); final result = await GameLogAnalyzer.analyzeLogFile(logFile);
final (results, _) = result; final (results, _) = result;
_setResult(results); _setResult(results);
_startListenFile(logFile); // Game.log
if (enableWatch) {
_startListenFile(logFile);
}
} }
// //
@ -60,7 +116,7 @@ class ToolsLogAnalyze extends _$ToolsLogAnalyze {
debugPrint("[ToolsLogAnalyze] logFile change: ${change.type}"); debugPrint("[ToolsLogAnalyze] logFile change: ${change.type}");
switch (change.type) { switch (change.type) {
case ChangeType.MODIFY: case ChangeType.MODIFY:
return _launchLogAnalyze(logFile); return _launchLogAnalyze(logFile, true);
case ChangeType.ADD: case ChangeType.ADD:
case ChangeType.REMOVE: case ChangeType.REMOVE:
ref.invalidateSelf(); ref.invalidateSelf();

View File

@ -16,7 +16,7 @@ final class ToolsLogAnalyzeProvider
extends $AsyncNotifierProvider<ToolsLogAnalyze, List<LogAnalyzeLineData>> { extends $AsyncNotifierProvider<ToolsLogAnalyze, List<LogAnalyzeLineData>> {
const ToolsLogAnalyzeProvider._({ const ToolsLogAnalyzeProvider._({
required ToolsLogAnalyzeFamily super.from, required ToolsLogAnalyzeFamily super.from,
required (String, bool) super.argument, required (String, bool, {String? selectedLogFile}) super.argument,
}) : super( }) : super(
retry: null, retry: null,
name: r'toolsLogAnalyzeProvider', name: r'toolsLogAnalyzeProvider',
@ -50,7 +50,7 @@ final class ToolsLogAnalyzeProvider
} }
} }
String _$toolsLogAnalyzeHash() => r'4c1aea03394e5c5641b2eb40a31d37892bb978bf'; String _$toolsLogAnalyzeHash() => r'7fa6e068a3ee33fbf1eb0c718035eececd625ece';
final class ToolsLogAnalyzeFamily extends $Family final class ToolsLogAnalyzeFamily extends $Family
with with
@ -59,7 +59,7 @@ final class ToolsLogAnalyzeFamily extends $Family
AsyncValue<List<LogAnalyzeLineData>>, AsyncValue<List<LogAnalyzeLineData>>,
List<LogAnalyzeLineData>, List<LogAnalyzeLineData>,
FutureOr<List<LogAnalyzeLineData>>, FutureOr<List<LogAnalyzeLineData>>,
(String, bool) (String, bool, {String? selectedLogFile})
> { > {
const ToolsLogAnalyzeFamily._() const ToolsLogAnalyzeFamily._()
: super( : super(
@ -70,11 +70,18 @@ final class ToolsLogAnalyzeFamily extends $Family
isAutoDispose: true, isAutoDispose: true,
); );
ToolsLogAnalyzeProvider call(String gameInstallPath, bool listSortReverse) => ToolsLogAnalyzeProvider call(
ToolsLogAnalyzeProvider._( String gameInstallPath,
argument: (gameInstallPath, listSortReverse), bool listSortReverse, {
from: this, String? selectedLogFile,
); }) => ToolsLogAnalyzeProvider._(
argument: (
gameInstallPath,
listSortReverse,
selectedLogFile: selectedLogFile,
),
from: this,
);
@override @override
String toString() => r'toolsLogAnalyzeProvider'; String toString() => r'toolsLogAnalyzeProvider';
@ -82,18 +89,24 @@ final class ToolsLogAnalyzeFamily extends $Family
abstract class _$ToolsLogAnalyze abstract class _$ToolsLogAnalyze
extends $AsyncNotifier<List<LogAnalyzeLineData>> { extends $AsyncNotifier<List<LogAnalyzeLineData>> {
late final _$args = ref.$arg as (String, bool); late final _$args = ref.$arg as (String, bool, {String? selectedLogFile});
String get gameInstallPath => _$args.$1; String get gameInstallPath => _$args.$1;
bool get listSortReverse => _$args.$2; bool get listSortReverse => _$args.$2;
String? get selectedLogFile => _$args.selectedLogFile;
FutureOr<List<LogAnalyzeLineData>> build( FutureOr<List<LogAnalyzeLineData>> build(
String gameInstallPath, String gameInstallPath,
bool listSortReverse, bool listSortReverse, {
); String? selectedLogFile,
});
@$mustCallSuper @$mustCallSuper
@override @override
void runBuild() { void runBuild() {
final created = build(_$args.$1, _$args.$2); final created = build(
_$args.$1,
_$args.$2,
selectedLogFile: _$args.selectedLogFile,
);
final ref = final ref =
this.ref this.ref
as $Ref< as $Ref<

View File

@ -16,7 +16,26 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final selectedPath = useState<String?>(appState.gameInstallPaths.firstOrNull); final selectedPath = useState<String?>(appState.gameInstallPaths.firstOrNull);
final listSortReverse = useState<bool>(false); final listSortReverse = useState<bool>(false);
final provider = toolsLogAnalyzeProvider(selectedPath.value ?? "", listSortReverse.value); final selectedLogFile = useState<String?>(null); // null 使 Game.log
final availableLogFiles = useState<List<LogFileInfo>>([]);
//
useEffect(() {
if (selectedPath.value != null) {
getAvailableLogFiles(selectedPath.value!).then((files) {
availableLogFiles.value = files;
//
selectedLogFile.value = null;
});
}
return null;
}, [selectedPath.value]);
final provider = toolsLogAnalyzeProvider(
selectedPath.value ?? "",
listSortReverse.value,
selectedLogFile: selectedLogFile.value,
);
final logResp = ref.watch(provider); final logResp = ref.watch(provider);
final searchText = useState<String>(""); final searchText = useState<String>("");
final searchType = useState<String?>(null); final searchType = useState<String?>(null);
@ -38,12 +57,12 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget {
value: selectedPath.value, value: selectedPath.value,
items: [ items: [
for (final path in appState.gameInstallPaths) for (final path in appState.gameInstallPaths)
ComboBoxItem<String>( ComboBoxItem<String>(value: path, child: Text(path)),
value: path,
child: Text(path),
),
], ],
onChanged: (value) => selectedPath.value = value, onChanged: (value) {
selectedPath.value = value;
selectedLogFile.value = null; //
},
placeholder: Text(S.current.log_analyzer_select_game_path), placeholder: Text(S.current.log_analyzer_select_game_path),
), ),
), ),
@ -55,13 +74,50 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget {
child: const Icon(FluentIcons.refresh), child: const Icon(FluentIcons.refresh),
), ),
onPressed: () { onPressed: () {
//
if (selectedPath.value != null) {
getAvailableLogFiles(selectedPath.value!).then((files) {
availableLogFiles.value = files;
});
}
ref.invalidate(provider); ref.invalidate(provider);
}, },
), ),
], ],
), ),
), ),
SizedBox(height: 8), const SizedBox(height: 8),
//
Padding(
padding: const EdgeInsets.symmetric(horizontal: 14),
child: Row(
children: [
const Text("日志文件:"),
const SizedBox(width: 10),
Expanded(
child: ComboBox<String?>(
isExpanded: true,
value: selectedLogFile.value,
items: [
for (final logFile in availableLogFiles.value)
ComboBoxItem<String?>(
value: logFile.isCurrentLog ? null : logFile.path,
child: Text(
logFile.displayName,
style: logFile.isCurrentLog ? const TextStyle(fontWeight: FontWeight.bold) : null,
),
),
],
onChanged: (value) {
selectedLogFile.value = value;
},
placeholder: const Text("选择日志文件"),
),
),
],
),
),
const SizedBox(height: 8),
// //
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 14), padding: const EdgeInsets.symmetric(horizontal: 14),
@ -70,10 +126,7 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget {
// //
Expanded( Expanded(
child: TextFormBox( child: TextFormBox(
prefix: Padding( prefix: Padding(padding: const EdgeInsets.only(left: 12), child: Icon(FluentIcons.search)),
padding: const EdgeInsets.only(left: 12),
child: Icon(FluentIcons.search),
),
placeholder: S.current.log_analyzer_search_placeholder, placeholder: S.current.log_analyzer_search_placeholder,
onChanged: (value) { onChanged: (value) {
searchText.value = value.trim(); searchText.value = value.trim();
@ -88,10 +141,7 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget {
value: searchType.value, value: searchType.value,
placeholder: Text(S.current.log_analyzer_filter_all), placeholder: Text(S.current.log_analyzer_filter_all),
items: logAnalyzeSearchTypeMap.entries items: logAnalyzeSearchTypeMap.entries
.map((e) => ComboBoxItem<String>( .map((e) => ComboBoxItem<String>(value: e.key, child: Text(e.value)))
value: e.key,
child: Text(e.value),
))
.toList(), .toList(),
onChanged: (value) { onChanged: (value) {
searchType.value = value; searchType.value = value;
@ -103,7 +153,9 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
child: Transform.rotate( child: Transform.rotate(
angle: listSortReverse.value ? 3.14 : 0, child: const Icon(FluentIcons.sort_lines)), angle: listSortReverse.value ? 3.14 : 0,
child: const Icon(FluentIcons.sort_lines),
),
), ),
onPressed: () { onPressed: () {
listSortReverse.value = !listSortReverse.value; listSortReverse.value = !listSortReverse.value;
@ -116,95 +168,79 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget {
Container( Container(
margin: EdgeInsets.symmetric(vertical: 12, horizontal: 14), margin: EdgeInsets.symmetric(vertical: 12, horizontal: 14),
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(bottom: BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1)),
bottom: BorderSide(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
), ),
), ),
// log analyze result // log analyze result
if (!logResp.hasValue) if (!logResp.hasValue)
Expanded( Expanded(child: Center(child: ProgressRing()))
child: Center(
child: ProgressRing(),
))
else else
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
controller: listCtrl, controller: listCtrl,
itemCount: logResp.value!.length, itemCount: logResp.value!.length,
padding: const EdgeInsets.symmetric(horizontal: 14), padding: const EdgeInsets.symmetric(horizontal: 14),
itemBuilder: (BuildContext context, int index) { itemBuilder: (BuildContext context, int index) {
final item = logResp.value![index]; final item = logResp.value![index];
if (searchText.value.isNotEmpty) { if (searchText.value.isNotEmpty) {
// //
if (!item.toString().contains(searchText.value)) { if (!item.toString().contains(searchText.value)) {
return const SizedBox.shrink(); return const SizedBox.shrink();
}
} }
} if (searchType.value != null) {
if (searchType.value != null) { if (item.type != searchType.value) {
if (item.type != searchType.value) { return const SizedBox.shrink();
return const SizedBox.shrink(); }
} }
} return Padding(
return Padding( padding: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.only(bottom: 8), child: SelectionArea(
child: SelectionArea( child: Container(
child: Container( decoration: BoxDecoration(
decoration: BoxDecoration( color: _getBackgroundColor(item.type),
color: _getBackgroundColor(item.type), borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(8), ),
), padding: EdgeInsets.symmetric(vertical: 8, horizontal: 10),
padding: EdgeInsets.symmetric( child: Column(
vertical: 8, crossAxisAlignment: CrossAxisAlignment.start,
horizontal: 10, children: [
), Row(
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, _getIconWidget(item.type),
children: [ const SizedBox(width: 10),
Row( Expanded(
children: [ child: Text.rich(
_getIconWidget(item.type),
const SizedBox(width: 10),
Expanded(
child: Text.rich(
TextSpan(children: [
TextSpan( TextSpan(
text: item.title, 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.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,
),
),
), ),
], 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),
),
),
],
),
), ),
), ),
), );
); },
}, ),
)) ),
], ],
), ),
); );

View File

@ -25,6 +25,7 @@ import 'package:xml/xml.dart';
import 'dialogs/hosts_booster_dialog_ui.dart'; import 'dialogs/hosts_booster_dialog_ui.dart';
import 'dialogs/rsi_launcher_enhance_dialog_ui.dart'; import 'dialogs/rsi_launcher_enhance_dialog_ui.dart';
import 'yearly_report_ui/yearly_report_ui.dart';
part 'tools_ui_model.g.dart'; part 'tools_ui_model.g.dart';
@ -80,6 +81,23 @@ class ToolsUIModel extends _$ToolsUIModel {
), ),
]; ];
// 2025 - 2026120
final deadline = DateTime(2026, 1, 20);
if (DateTime.now().isBefore(deadline)) {
items.insert(
0,
ToolsItemData(
"yearly_report",
"2025 年度报告(限时)",
"查看您在2025年的星际公民游玩统计数据来自本地 log ,请确保在常用电脑上查看。",
const Icon(FontAwesomeIcons.star, size: 22),
onTap: () async {
_openYearlyReport(context);
},
),
);
}
if (!context.mounted) return; if (!context.mounted) return;
items.add(await _addP4kCard(context)); items.add(await _addP4kCard(context));
items.addAll([ items.addAll([
@ -747,6 +765,17 @@ class ToolsUIModel extends _$ToolsUIModel {
appGlobalState, appGlobalState,
); );
} }
void _openYearlyReport(BuildContext context) {
if (state.scInstallPaths.isEmpty) {
showToast(context, S.current.tools_action_info_valid_game_directory_needed);
return;
}
Navigator.of(
context,
).push(FluentPageRoute(builder: (context) => YearlyReportUI(gameInstallPaths: state.scInstallPaths)));
}
} }
/// ///

View File

@ -41,7 +41,7 @@ final class ToolsUIModelProvider
} }
} }
String _$toolsUIModelHash() => r'b0fefd36bd8f1e23fdd6123d487f73d78e40ad06'; String _$toolsUIModelHash() => r'a801ad7f4ac2a45a2fa6872c1c004b83d09a3dca';
abstract class _$ToolsUIModel extends $Notifier<ToolsUIState> { abstract class _$ToolsUIModel extends $Notifier<ToolsUIState> {
ToolsUIState build(); ToolsUIState build();

File diff suppressed because it is too large Load Diff