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 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] 日志文件

View File

@@ -1,7 +1,7 @@
import 'dart:io';
import 'dart:convert';
import 'dart:isolate';
import 'package:starcitizen_doctor/common/helper/game_log_analyzer.dart';
import 'package:starcitizen_doctor/common/utils/log.dart';
/// 年度报告数据类
class YearlyReportData {
@@ -18,6 +18,13 @@ class YearlyReportData {
final DateTime? earliestPlayDate; // 年度游玩最早的一天 (05: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 String? mostDestroyedVehicle; // 年度炸的最多的船
@@ -25,19 +32,23 @@ class YearlyReportData {
final String? mostPilotedVehicle; // 年度最爱驾驶的载具
final int mostPilotedVehicleCount; // 驾驶次数
// 战斗统计
final int yearlyKillCount; // 年度击杀次数
final int yearlyDeathCount; // 年度死亡次数
// 账号统计
final int accountCount; // 账号数量
final String? mostPlayedAccount; // 游玩最多的账号
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> accountSessionDetails; // 账号会话详情
final Map<String, int> locationDetails; // 地点访问详情
const YearlyReportData({
required this.totalLaunchCount,
@@ -49,49 +60,44 @@ class YearlyReportData {
this.yearlyFirstLaunchTime,
this.earliestPlayDate,
this.latestPlayDate,
this.longestSession,
this.longestSessionDate,
this.shortestSession,
this.shortestSessionDate,
this.averageSessionTime,
required this.yearlyVehicleDestructionCount,
this.mostDestroyedVehicle,
required this.mostDestroyedVehicleCount,
this.mostPilotedVehicle,
required this.mostPilotedVehicleCount,
required this.yearlyKillCount,
required this.yearlyDeathCount,
required this.accountCount,
this.mostPlayedAccount,
required this.mostPlayedAccountSessionCount,
required this.vehicleDestructionDetails,
required this.topLocations,
required this.yearlyKillCount,
required this.yearlyDeathCount,
required this.yearlySelfKillCount,
required this.vehiclePilotedDetails,
required this.accountSessionDetails,
required this.locationDetails,
});
/// 将 DateTime 转换为带时区的 ISO 8601 字符串
/// 输出格式: 2025-12-17T10:30:00.000+08:00
static String? _toIso8601WithTimezone(DateTime? dateTime) {
/// 将 DateTime 转换为 UTC 毫秒时间戳
static int? _toUtcTimestamp(DateTime? dateTime) {
if (dateTime == null) return null;
final local = dateTime.toLocal();
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';
return dateTime.toUtc().millisecondsSinceEpoch;
}
/// 转换为 JSON Map
///
/// 时间字段使用 UTC 毫秒时间戳 (int),配合 timezoneOffsetMinutes 可在客户端还原本地时间
Map<String, dynamic> toJson() {
final now = DateTime.now();
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 {
// 元数据
'generatedAt': _toIso8601WithTimezone(now),
'timezoneOffset': '$sign$hours:$minutes',
'generatedAtUtc': _toUtcTimestamp(now),
'timezoneOffsetMinutes': offset.inMinutes,
// 基础统计
@@ -102,10 +108,17 @@ class YearlyReportData {
'totalCrashCount': totalCrashCount,
'yearlyCrashCount': yearlyCrashCount,
// 时间统计 (带时区)
'yearlyFirstLaunchTime': _toIso8601WithTimezone(yearlyFirstLaunchTime),
'earliestPlayDate': _toIso8601WithTimezone(earliestPlayDate),
'latestPlayDate': _toIso8601WithTimezone(latestPlayDate),
// 时间统计 (UTC 毫秒时间戳)
'yearlyFirstLaunchTimeUtc': _toUtcTimestamp(yearlyFirstLaunchTime),
'earliestPlayDateUtc': _toUtcTimestamp(earliestPlayDate),
'latestPlayDateUtc': _toUtcTimestamp(latestPlayDate),
// 游玩时长统计
'longestSessionMs': longestSession?.inMilliseconds,
'longestSessionDateUtc': _toUtcTimestamp(longestSessionDate),
'shortestSessionMs': shortestSession?.inMilliseconds,
'shortestSessionDateUtc': _toUtcTimestamp(shortestSessionDate),
'averageSessionTimeMs': averageSessionTime?.inMilliseconds,
// 载具统计
'yearlyVehicleDestructionCount': yearlyVehicleDestructionCount,
@@ -114,28 +127,26 @@ class YearlyReportData {
'mostPilotedVehicle': mostPilotedVehicle,
'mostPilotedVehicleCount': mostPilotedVehicleCount,
// 战斗统计
'yearlyKillCount': yearlyKillCount,
'yearlyDeathCount': yearlyDeathCount,
// 账号统计
'accountCount': accountCount,
'mostPlayedAccount': mostPlayedAccount,
'mostPlayedAccountSessionCount': mostPlayedAccountSessionCount,
// 地点统计
'topLocations': topLocations.map((e) => {'location': e.key, 'count': e.value}).toList(),
// 击杀统计
'yearlyKillCount': yearlyKillCount,
'yearlyDeathCount': yearlyDeathCount,
'yearlySelfKillCount': yearlySelfKillCount,
// 详细数据
'vehicleDestructionDetails': vehicleDestructionDetails,
'vehiclePilotedDetails': vehiclePilotedDetails,
'accountSessionDetails': accountSessionDetails,
'locationDetails': locationDetails,
};
}
/// 转换为 JSON 字符串
String toJsonString() => jsonEncode(toJson());
/// 转换为 Base64 编码的 JSON 字符串
String toJsonBase64() => base64Encode(utf8.encode(toJsonString()));
@override
String toString() {
return '''YearlyReportData(
@@ -148,13 +159,15 @@ class YearlyReportData {
yearlyFirstLaunchTime: $yearlyFirstLaunchTime,
earliestPlayDate: $earliestPlayDate,
latestPlayDate: $latestPlayDate,
longestSession: $longestSession (on $longestSessionDate),
shortestSession: $shortestSession (on $shortestSessionDate),
averageSessionTime: $averageSessionTime,
yearlyVehicleDestructionCount: $yearlyVehicleDestructionCount,
mostDestroyedVehicle: $mostDestroyedVehicle ($mostDestroyedVehicleCount),
mostPilotedVehicle: $mostPilotedVehicle ($mostPilotedVehicleCount),
yearlyKillCount: $yearlyKillCount,
yearlyDeathCount: $yearlyDeathCount,
accountCount: $accountCount,
mostPlayedAccount: $mostPlayedAccount ($mostPlayedAccountSessionCount),
topLocations: ${topLocations.take(5).map((e) => '${e.key}: ${e.value}').join(', ')},
)''';
}
}
@@ -166,6 +179,7 @@ class _LogFileStats {
bool hasCrash = false;
int killCount = 0;
int deathCount = 0;
int selfKillCount = 0;
Set<String> playerNames = {};
String? currentPlayerName;
String? firstPlayerName; // 第一个检测到的玩家名,用于去重
@@ -176,9 +190,11 @@ class _LogFileStats {
// 驾驶载具: 载具型号 (去除ID后) -> 次数
Map<String, int> vehiclePiloted = {};
// 年度内的时间记录
List<DateTime> yearlyStartTimes = [];
List<DateTime> yearlyEndTimes = [];
// 地点访问: 地点名 -> 次数
Map<String, int> locationVisits = {};
// 年度内的会话记录
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 {
// 复用 GameLogAnalyzer 中的正则表达式和方法
static final _characterNamePattern = RegExp(r"name\s+([^-]+)");
// 新版日志格式的正则表达式
static final _characterNamePattern = RegExp(r'name\s+(\w+)\s+signedIn');
static final _vehicleDestructionPattern = RegExp(
r"Vehicle\s+'([^']+)'.*?" // 载具型号
r"in zone\s+'([^']+)'.*?" // Zone
@@ -206,6 +232,16 @@ class YearlyReportAnalyzer {
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 {
final stats = _LogFileStats();
@@ -231,21 +267,6 @@ class YearlyReportAnalyzer {
// 更新结束时间 (最后一个有效时间)
if (lineTime != null) {
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;
}
// 检测玩家登录
final nameMatch = _characterNamePattern.firstMatch(line);
// 检测玩家登录 (尝试新版格式,失败则用旧版)
var nameMatch = _characterNamePattern.firstMatch(line);
nameMatch ??= _legacyCharacterNamePattern.firstMatch(line);
if (nameMatch != null) {
final playerName = nameMatch.group(1)?.trim();
if (playerName != null && playerName.isNotEmpty) {
if (playerName != null && playerName.isNotEmpty && !playerName.contains(' ')) {
stats.currentPlayerName = playerName;
stats.playerNames.add(playerName);
// 记录第一个玩家名用于去重
stats.firstPlayerName ??= playerName;
}
}
// 检测载具损毁 (仅年度内)
// 年度内的统计
if (lineTime != null && lineTime.year == targetYear) {
// 检测载具损毁
final destructionMatch = _vehicleDestructionPattern.firstMatch(line);
if (destructionMatch != null) {
final vehicleModel = destructionMatch.group(1);
@@ -291,29 +313,86 @@ class YearlyReportAnalyzer {
}
}
// 检测死亡
final deathMatch = _actorDeathPattern.firstMatch(line);
// 检测死亡 (新版格式)
var deathMatch = _actorDeathPattern.firstMatch(line);
if (deathMatch != null) {
final victimId = deathMatch.group(1)?.trim();
if (victimId != null && stats.currentPlayerName != null && victimId == stats.currentPlayerName) {
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) {
dPrint('[YearlyReportAnalyzer] Error analyzing log file: $e');
// Error handled silently in isolate
}
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"])
/// [targetYear] 目标年份
///
/// 该方法在独立 Isolate 中运行,避免阻塞 UI
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 = [];
// 从所有安装路径收集日志文件
@@ -322,7 +401,6 @@ class YearlyReportAnalyzer {
// 检查安装目录是否存在
if (!await installDir.exists()) {
dPrint('[YearlyReportAnalyzer] Install path does not exist: $installPath');
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 allStatsRaw = await Future.wait(futures);
@@ -359,18 +433,13 @@ class YearlyReportAnalyzer {
for (final stats in allStatsRaw) {
final key = stats.uniqueKey;
if (key == null) {
// 无法生成唯一标识的日志仍然保留
allStats.add(stats);
} else if (!seenKeys.contains(key)) {
seenKeys.add(key);
allStats.add(stats);
} else {
dPrint('[YearlyReportAnalyzer] Skipping duplicate log: $key');
}
}
dPrint('[YearlyReportAnalyzer] After deduplication: ${allStats.length} unique logs');
// 合并统计数据
int totalLaunchCount = allStats.length;
Duration totalPlayTime = Duration.zero;
@@ -381,12 +450,23 @@ class YearlyReportAnalyzer {
DateTime? yearlyFirstLaunchTime;
DateTime? earliestPlayDate;
DateTime? latestPlayDate;
// 会话时长统计
Duration? longestSession;
DateTime? longestSessionDate;
Duration? shortestSession;
DateTime? shortestSessionDate;
List<Duration> allSessionDurations = [];
// K/D 统计
int yearlyKillCount = 0;
int yearlyDeathCount = 0;
int yearlySelfKillCount = 0;
final Map<String, int> vehicleDestructionDetails = {};
final Map<String, int> vehiclePilotedDetails = {};
final Map<String, int> accountSessionDetails = {};
final Map<String, int> locationDetails = {};
for (final stats in allStats) {
// 累计游玩时长
@@ -397,46 +477,57 @@ class YearlyReportAnalyzer {
// 崩溃统计
if (stats.hasCrash) {
totalCrashCount++;
// 检查是否为年度内的崩溃
if (stats.endTime != null && stats.endTime!.year == targetYear) {
yearlyCrashCount++;
}
}
// 年度统计
for (int i = 0; i < stats.yearlyStartTimes.length; i++) {
// 年度会话统计
for (final session in stats.yearlySessions) {
yearlyLaunchCount++;
final startTime = stats.yearlyStartTimes[i];
final endTime = i < stats.yearlyEndTimes.length ? stats.yearlyEndTimes[i] : startTime;
yearlyPlayTime += endTime.difference(startTime);
final sessionDuration = session.duration;
yearlyPlayTime += sessionDuration;
allSessionDurations.add(sessionDuration);
// 年度第一次启动时间
if (yearlyFirstLaunchTime == null || startTime.isBefore(yearlyFirstLaunchTime)) {
yearlyFirstLaunchTime = startTime;
if (yearlyFirstLaunchTime == null || session.startTime.isBefore(yearlyFirstLaunchTime)) {
yearlyFirstLaunchTime = session.startTime;
}
// 最早游玩的一天 (05:00及以后开始游戏)
if (startTime.hour >= 5) {
if (earliestPlayDate == null || _timeOfDayIsEarlier(startTime, earliestPlayDate)) {
earliestPlayDate = startTime;
if (session.startTime.hour >= 5) {
if (earliestPlayDate == null || _timeOfDayIsEarlier(session.startTime, earliestPlayDate)) {
earliestPlayDate = session.startTime;
}
}
// 最晚游玩的一天 (04:00及以前结束游戏)
if (endTime.hour <= 4) {
if (latestPlayDate == null || _timeOfDayIsLater(endTime, latestPlayDate)) {
latestPlayDate = endTime;
if (session.endTime.hour <= 4) {
if (latestPlayDate == null || _timeOfDayIsLater(session.endTime, latestPlayDate)) {
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;
}
}
}
// 累加战斗统计
yearlyKillCount += stats.killCount;
yearlyDeathCount += stats.deathCount;
// 合并载具损毁详情
// 合并载具损毁详情 (过滤包含 PU 的载具)
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;
}
// 累计 K/D
yearlyKillCount += stats.killCount;
yearlyDeathCount += stats.deathCount;
yearlySelfKillCount += stats.selfKillCount;
// 合并账号会话详情
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(
totalLaunchCount: totalLaunchCount,
totalPlayTime: totalPlayTime,
@@ -490,19 +611,26 @@ class YearlyReportAnalyzer {
yearlyFirstLaunchTime: yearlyFirstLaunchTime,
earliestPlayDate: earliestPlayDate,
latestPlayDate: latestPlayDate,
longestSession: longestSession,
longestSessionDate: longestSessionDate,
shortestSession: shortestSession,
shortestSessionDate: shortestSessionDate,
averageSessionTime: averageSessionTime,
yearlyVehicleDestructionCount: yearlyVehicleDestructionCount,
mostDestroyedVehicle: mostDestroyedVehicle,
mostDestroyedVehicleCount: mostDestroyedVehicleCount,
mostPilotedVehicle: mostPilotedVehicle,
mostPilotedVehicleCount: mostPilotedVehicleCount,
yearlyKillCount: yearlyKillCount,
yearlyDeathCount: yearlyDeathCount,
accountCount: accountSessionDetails.length,
mostPlayedAccount: mostPlayedAccount,
mostPlayedAccountSessionCount: mostPlayedAccountSessionCount,
vehicleDestructionDetails: vehicleDestructionDetails,
topLocations: topLocations,
yearlyKillCount: yearlyKillCount,
yearlyDeathCount: yearlyDeathCount,
yearlySelfKillCount: yearlySelfKillCount,
vehiclePilotedDetails: vehiclePilotedDetails,
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> {
DcbViewerState build();

View File

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

View File

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

View File

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

View File

@@ -21,28 +21,84 @@ final Map<String?, String> logAnalyzeSearchTypeMap = {
"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
class ToolsLogAnalyze extends _$ToolsLogAnalyze {
@override
Future<List<LogAnalyzeLineData>> build(String gameInstallPath, bool listSortReverse) async {
final logFile = File("$gameInstallPath/Game.log");
Future<List<LogAnalyzeLineData>> build(
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}");
if (gameInstallPath.isEmpty || !(await logFile.exists())) {
return [const LogAnalyzeLineData(type: "error", title: "未找到日志文件")];
}
state = const AsyncData([]);
_launchLogAnalyze(logFile);
_launchLogAnalyze(logFile, selectedLogFile == null);
return state.value ?? [];
}
void _launchLogAnalyze(File logFile) async {
void _launchLogAnalyze(File logFile, bool enableWatch) async {
// 使用新的 GameLogAnalyzer 工具类
final result = await GameLogAnalyzer.analyzeLogFile(logFile);
final (results, _) = result;
_setResult(results);
_startListenFile(logFile);
// 只有当前 Game.log 才需要监听变化
if (enableWatch) {
_startListenFile(logFile);
}
}
// 避免重复调用
@@ -60,7 +116,7 @@ class ToolsLogAnalyze extends _$ToolsLogAnalyze {
debugPrint("[ToolsLogAnalyze] logFile change: ${change.type}");
switch (change.type) {
case ChangeType.MODIFY:
return _launchLogAnalyze(logFile);
return _launchLogAnalyze(logFile, true);
case ChangeType.ADD:
case ChangeType.REMOVE:
ref.invalidateSelf();

View File

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

View File

@@ -16,7 +16,26 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final selectedPath = useState<String?>(appState.gameInstallPaths.firstOrNull);
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 searchText = useState<String>("");
final searchType = useState<String?>(null);
@@ -38,12 +57,12 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget {
value: selectedPath.value,
items: [
for (final path in appState.gameInstallPaths)
ComboBoxItem<String>(
value: path,
child: Text(path),
),
ComboBoxItem<String>(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),
),
),
@@ -55,13 +74,50 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget {
child: const Icon(FluentIcons.refresh),
),
onPressed: () {
// 重新加载日志文件列表
if (selectedPath.value != null) {
getAvailableLogFiles(selectedPath.value!).then((files) {
availableLogFiles.value = files;
});
}
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: const EdgeInsets.symmetric(horizontal: 14),
@@ -70,10 +126,7 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget {
// 输入框
Expanded(
child: TextFormBox(
prefix: Padding(
padding: const EdgeInsets.only(left: 12),
child: Icon(FluentIcons.search),
),
prefix: Padding(padding: const EdgeInsets.only(left: 12), child: Icon(FluentIcons.search)),
placeholder: S.current.log_analyzer_search_placeholder,
onChanged: (value) {
searchText.value = value.trim();
@@ -88,10 +141,7 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget {
value: searchType.value,
placeholder: Text(S.current.log_analyzer_filter_all),
items: logAnalyzeSearchTypeMap.entries
.map((e) => ComboBoxItem<String>(
value: e.key,
child: Text(e.value),
))
.map((e) => ComboBoxItem<String>(value: e.key, child: Text(e.value)))
.toList(),
onChanged: (value) {
searchType.value = value;
@@ -103,7 +153,9 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget {
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
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: () {
listSortReverse.value = !listSortReverse.value;
@@ -116,95 +168,79 @@ class ToolsLogAnalyzeDialogUI extends HookConsumerWidget {
Container(
margin: EdgeInsets.symmetric(vertical: 12, horizontal: 14),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Colors.white.withValues(alpha: 0.1),
width: 1,
),
),
border: Border(bottom: BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1)),
),
),
// log analyze result
if (!logResp.hasValue)
Expanded(
child: Center(
child: ProgressRing(),
))
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();
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();
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: [
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(
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/rsi_launcher_enhance_dialog_ui.dart';
import 'yearly_report_ui/yearly_report_ui.dart';
part 'tools_ui_model.g.dart';
@@ -80,6 +81,23 @@ class ToolsUIModel extends _$ToolsUIModel {
),
];
// 2025 年度报告入口 - 2026年1月20日前显示
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;
items.add(await _addP4kCard(context));
items.addAll([
@@ -747,6 +765,17 @@ class ToolsUIModel extends _$ToolsUIModel {
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> {
ToolsUIState build();

File diff suppressed because it is too large Load Diff