mirror of
https://github.com/StarCitizenToolBox/app.git
synced 2026-01-13 19:50:28 +00:00
feat: init YearlyReportUI
This commit is contained in:
parent
9a28257f4a
commit
6ec973144e
@ -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] 日志文件
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -41,7 +41,7 @@ final class DcbViewerModelProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$dcbViewerModelHash() => r'f0af2a7b4451f746288e2c9565a418af80f58835';
|
||||
String _$dcbViewerModelHash() => r'dfed59de5291e5a19cc481d0115fe91f5bcaf301';
|
||||
|
||||
abstract class _$DcbViewerModel extends $Notifier<DcbViewerState> {
|
||||
DcbViewerState build();
|
||||
|
||||
@ -42,7 +42,7 @@ final class HomeGameLoginUIModelProvider
|
||||
}
|
||||
|
||||
String _$homeGameLoginUIModelHash() =>
|
||||
r'217a57f797b37f3467be2e7711f220610e9e67d8';
|
||||
r'd81831f54c6b1e98ea8a1e94b5e6049fe552996f';
|
||||
|
||||
abstract class _$HomeGameLoginUIModel extends $Notifier<HomeGameLoginState> {
|
||||
HomeGameLoginState build();
|
||||
|
||||
@ -42,7 +42,7 @@ final class LocalizationUIModelProvider
|
||||
}
|
||||
|
||||
String _$localizationUIModelHash() =>
|
||||
r'122f9f85da6e112165f4ff88667b45cf3cf3f43e';
|
||||
r'7b398d3b2ddd306ff8f328be39f28200fe8bf49e';
|
||||
|
||||
abstract class _$LocalizationUIModel extends $Notifier<LocalizationUIState> {
|
||||
LocalizationUIState build();
|
||||
|
||||
@ -42,7 +42,7 @@ final class HomePerformanceUIModelProvider
|
||||
}
|
||||
|
||||
String _$homePerformanceUIModelHash() =>
|
||||
r'4c5c33fe7d85dc8f6bf0d019c1b870d285d594ff';
|
||||
r'ae4771c7804abb1e5ba89bb1687061612f96b46b';
|
||||
|
||||
abstract class _$HomePerformanceUIModel
|
||||
extends $Notifier<HomePerformanceUIState> {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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<
|
||||
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
))
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
/// 图形渲染器切换对话框
|
||||
|
||||
@ -41,7 +41,7 @@ final class ToolsUIModelProvider
|
||||
}
|
||||
}
|
||||
|
||||
String _$toolsUIModelHash() => r'b0fefd36bd8f1e23fdd6123d487f73d78e40ad06';
|
||||
String _$toolsUIModelHash() => r'a801ad7f4ac2a45a2fa6872c1c004b83d09a3dca';
|
||||
|
||||
abstract class _$ToolsUIModel extends $Notifier<ToolsUIState> {
|
||||
ToolsUIState build();
|
||||
|
||||
1415
lib/ui/tools/yearly_report_ui/yearly_report_ui.dart
Normal file
1415
lib/ui/tools/yearly_report_ui/yearly_report_ui.dart
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user