mirror of
https://github.com/StarCitizenToolBox/app.git
synced 2026-02-06 15:10:20 +00:00
feat: init YearlyReportUI
This commit is contained in:
@@ -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
Reference in New Issue
Block a user