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

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