From a969eba4737bfc026afe132dd2b9af55da33c082 Mon Sep 17 00:00:00 2001 From: ngfchl Date: Sat, 18 Jul 2026 12:54:45 +0800 Subject: [PATCH] feat: generate fast transfer JSON from local files --- lib/pages/workspace_tools_page.dart | 128 ++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/lib/pages/workspace_tools_page.dart b/lib/pages/workspace_tools_page.dart index d631911..39044b2 100644 --- a/lib/pages/workspace_tools_page.dart +++ b/lib/pages/workspace_tools_page.dart @@ -1,5 +1,8 @@ import 'dart:convert'; +import 'dart:io'; +import 'package:crypto/crypto.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; @@ -1621,6 +1624,16 @@ class _FastTransferTool extends ConsumerStatefulWidget { enum _FastTransferTaskState { imported, skipped, failed, cancelled } +class _DigestSink implements Sink { + Digest? value; + + @override + void add(Digest data) => value = data; + + @override + void close() {} +} + class _FastTransferTaskResult { final FastTransferEntry entry; final _FastTransferTaskState state; @@ -1660,6 +1673,10 @@ class _FastTransferToolState extends ConsumerState<_FastTransferTool> { bool _cancelRequested = false; bool _createDirectories = true; bool _skipExisting = true; + bool _generating = false; + int _generated = 0; + int _generationTotal = 0; + String _generationName = ''; String _result = ''; var _taskResults = <_FastTransferTaskResult>[]; @@ -1838,6 +1855,87 @@ class _FastTransferToolState extends ConsumerState<_FastTransferTool> { } } + Future _generateLocalJson() async { + if (_generating || _running) return; + final picked = await FilePicker.pickFiles(type: FileType.any); + if (picked == null) return; + final files = picked.paths + .whereType() + .map(File.new) + .where((file) => file.existsSync()) + .toList(); + if (files.isEmpty) return; + setState(() { + _generating = true; + _generated = 0; + _generationTotal = files.length; + _generationName = ''; + _result = ''; + }); + final entries = []; + var failures = 0; + try { + for (final file in files) { + if (!mounted) return; + setState(() => _generationName = file.path.split('/').last); + try { + final stat = await file.stat(); + final hashes = await _calculateLocalHashes(file, stat.size); + entries.add( + FastTransferEntry( + path: file.path.split('/').last, + size: stat.size, + md5: hashes.$1, + gcid: hashes.$2, + ), + ); + } catch (_) { + failures += 1; + } + if (mounted) setState(() => _generated += 1); + } + if (!mounted) return; + setState(() { + _json.text = const JsonEncoder.withIndent( + ' ', + ).convert({'files': entries.map((entry) => entry.toJson()).toList()}); + _result = failures == 0 + ? '已生成 ${entries.length} 个本地文件的秒传 JSON' + : '已生成 ${entries.length} 项,$failures 项无法读取'; + }); + } finally { + if (mounted) setState(() => _generating = false); + } + } + + Future<(String, String)> _calculateLocalHashes(File file, int size) async { + final chunkSize = switch (size) { + <= 0x8000000 => 262144, + <= 0x10000000 => 524288, + <= 0x20000000 => 1048576, + _ => 2097152, + }; + final fileHandle = await file.open(mode: FileMode.read); + final md5Sink = _DigestSink(); + final md5Converter = md5.startChunkedConversion(md5Sink); + final chunkHashes = []; + try { + while (true) { + final chunk = await fileHandle.read(chunkSize); + if (chunk.isEmpty) break; + md5Converter.add(chunk); + chunkHashes.addAll(sha1.convert(chunk).bytes); + } + } finally { + md5Converter.close(); + await fileHandle.close(); + } + final md5Text = md5Sink.value?.toString(); + if (md5Text == null) throw StateError('无法计算 MD5'); + final gcid = sha1.convert(chunkHashes).toString().toUpperCase(); + return (md5Text, gcid); + } + void _recordTaskResult( FastTransferEntry entry, _FastTransferTaskState state, @@ -2043,6 +2141,36 @@ class _FastTransferToolState extends ConsumerState<_FastTransferTool> { ], ), const SizedBox(height: 10), + Align( + alignment: Alignment.centerLeft, + child: ShadButton.outline( + onPressed: _generating || _running + ? null + : _generateLocalJson, + leading: Icon( + _generating + ? Icons.hourglass_top_rounded + : Icons.file_open_rounded, + size: 16, + ), + child: Text(_generating ? '正在生成 JSON' : '从本地文件生成 JSON'), + ), + ), + if (_generating) ...[ + const SizedBox(height: 8), + Semantics( + label: '正在计算本地文件校验值:$_generated / $_generationTotal', + child: const ShadProgress(), + ), + const SizedBox(height: 4), + Text( + '$_generated / $_generationTotal ${_generationName.isEmpty ? '' : _generationName}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(fontSize: 12, color: cs.mutedForeground), + ), + ], + const SizedBox(height: 10), SizedBox( height: 220, child: ShadInput(