25 changed files with 1025 additions and 175 deletions
@ -0,0 +1,91 @@ |
|||||||
|
import 'package:file_picker/file_picker.dart'; |
||||||
|
import 'package:win_text_editor/framework/controllers/logger.dart'; |
||||||
|
import 'package:win_text_editor/modules/data_extract/models/search_result.dart'; |
||||||
|
import 'package:win_text_editor/modules/data_extract/models/xml_rule.dart'; |
||||||
|
import 'package:win_text_editor/modules/data_extract/services/xml_extract_service.dart'; |
||||||
|
import 'package:win_text_editor/shared/base/base_content_controller.dart'; |
||||||
|
|
||||||
|
class DataExtractController extends BaseContentController { |
||||||
|
String _searchDirectory = ''; |
||||||
|
String _fileType = '*.*'; |
||||||
|
final List<SearchResult> _results = []; |
||||||
|
final List<XmlRule> _rules = []; |
||||||
|
final XmlExtractService _extractService = XmlExtractService(); |
||||||
|
|
||||||
|
List<SearchResult> get results => _results; |
||||||
|
List<XmlRule> get rules => _rules; |
||||||
|
|
||||||
|
String get searchDirectory => _searchDirectory; |
||||||
|
String get fileType => _fileType; |
||||||
|
|
||||||
|
bool _isExtracting = false; |
||||||
|
|
||||||
|
bool onlyFileName = false; |
||||||
|
bool get isExtracting => _isExtracting; |
||||||
|
|
||||||
|
Future<void> executeExtraction() async { |
||||||
|
Logger().info("开始提取目录:$_searchDirectory, 文件名:$_fileType"); |
||||||
|
if (_searchDirectory.isEmpty || _rules.isEmpty) return; |
||||||
|
|
||||||
|
_isExtracting = true; |
||||||
|
notifyListeners(); |
||||||
|
|
||||||
|
_results.clear(); |
||||||
|
notifyListeners(); |
||||||
|
|
||||||
|
try { |
||||||
|
final newResults = await _extractService.extractFromDirectory( |
||||||
|
directory: _searchDirectory, |
||||||
|
fileType: _fileType, |
||||||
|
rule: _rules[0], |
||||||
|
); |
||||||
|
_results.addAll(newResults); |
||||||
|
} catch (e) { |
||||||
|
Logger().error("提取目录出错:$e"); |
||||||
|
_results.add(SearchResult(rowNum: 1, filePath: 'Error', content: 'Extraction failed: $e')); |
||||||
|
} finally { |
||||||
|
_isExtracting = false; |
||||||
|
notifyListeners(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
set searchDirectory(String value) { |
||||||
|
_searchDirectory = value; |
||||||
|
notifyListeners(); |
||||||
|
} |
||||||
|
|
||||||
|
set fileType(String value) { |
||||||
|
_fileType = value; |
||||||
|
notifyListeners(); |
||||||
|
} |
||||||
|
|
||||||
|
Future<void> pickDirectory() async { |
||||||
|
final dir = await FilePicker.platform.getDirectoryPath(); |
||||||
|
if (dir != null) { |
||||||
|
searchDirectory = dir; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
void setRule(XmlRule rule) { |
||||||
|
_rules.clear(); |
||||||
|
rules.add(rule); |
||||||
|
notifyListeners(); |
||||||
|
} |
||||||
|
|
||||||
|
void removeRule(int index) { |
||||||
|
_rules.removeAt(index); |
||||||
|
notifyListeners(); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void onOpenFile(String filePath) { |
||||||
|
// TODO: implement onOpenFile |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void onOpenFolder(String folderPath) { |
||||||
|
searchDirectory = folderPath; |
||||||
|
} |
||||||
|
|
||||||
|
void cancelExtraction() {} |
||||||
|
} |
@ -0,0 +1,8 @@ |
|||||||
|
// search_result.dart |
||||||
|
class SearchResult { |
||||||
|
final int rowNum; |
||||||
|
final String filePath; |
||||||
|
final String content; |
||||||
|
|
||||||
|
SearchResult({required this.rowNum, required this.filePath, required this.content}); |
||||||
|
} |
@ -0,0 +1,18 @@ |
|||||||
|
// xml_rule.dart |
||||||
|
class XmlRule { |
||||||
|
final String nodePath; |
||||||
|
final String attributeName; |
||||||
|
final bool isFirstOccurrence; |
||||||
|
final String? namespacePrefix; |
||||||
|
|
||||||
|
XmlRule({ |
||||||
|
required this.nodePath, |
||||||
|
required this.attributeName, |
||||||
|
this.isFirstOccurrence = false, |
||||||
|
this.namespacePrefix, |
||||||
|
}); |
||||||
|
|
||||||
|
String toxPath() { |
||||||
|
return '${namespacePrefix != null ? '$namespacePrefix:' : ''}$nodePath${isFirstOccurrence ? '[0]' : ''}/${attributeName.isNotEmpty ? '@$attributeName' : 'text()'}'; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,100 @@ |
|||||||
|
import 'package:xml/xml.dart' as xml; |
||||||
|
|
||||||
|
class SimpleXPath { |
||||||
|
static List<xml.XmlNode> query(xml.XmlNode node, String path) { |
||||||
|
final segments = path.split('/').where((s) => s.isNotEmpty).toList(); |
||||||
|
var current = [node]; |
||||||
|
|
||||||
|
for (var i = 0; i < segments.length; i++) { |
||||||
|
final segment = segments[i]; |
||||||
|
final isRecursive = segment.isEmpty; // 修正:空段表示前导// |
||||||
|
|
||||||
|
current = current.expand((n) => _findNodes(n, segment, isRecursive)).toList(); |
||||||
|
if (current.isEmpty) break; |
||||||
|
} |
||||||
|
|
||||||
|
return current; |
||||||
|
} |
||||||
|
|
||||||
|
static Iterable<xml.XmlNode> _findNodes(xml.XmlNode node, String segment, bool recursive) sync* { |
||||||
|
if (segment == '..') { |
||||||
|
if (node.parent != null) yield node.parent!; |
||||||
|
} else if (segment == '.') { |
||||||
|
yield node; |
||||||
|
} else if (segment == 'text()') { |
||||||
|
if (node is xml.XmlText || node is xml.XmlAttribute) { |
||||||
|
yield node; |
||||||
|
} else { |
||||||
|
yield* node.children.whereType<xml.XmlText>(); |
||||||
|
} |
||||||
|
} else if (segment.startsWith('@')) { |
||||||
|
final attrName = segment.substring(1); |
||||||
|
if (node is xml.XmlElement) { |
||||||
|
yield* node.attributes.where((a) => a.name.local == attrName); |
||||||
|
} |
||||||
|
} else { |
||||||
|
if (node is xml.XmlElement) { |
||||||
|
if (recursive) { |
||||||
|
// 递归查找所有子节点 |
||||||
|
yield* _findNodesRecursive(node, segment); |
||||||
|
} else { |
||||||
|
// 只查找直接子节点 |
||||||
|
yield* node.children.whereType<xml.XmlElement>().where( |
||||||
|
(child) => child.name.local == segment, |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 新增递归查找方法 |
||||||
|
static Iterable<xml.XmlNode> _findNodesRecursive(xml.XmlNode node, String segment) sync* { |
||||||
|
if (node is xml.XmlElement) { |
||||||
|
if (node.name.local == segment) { |
||||||
|
yield node; |
||||||
|
} |
||||||
|
for (final child in node.children) { |
||||||
|
yield* _findNodesRecursive(child, segment); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
void main() { |
||||||
|
final doc = xml.XmlDocument.parse(''' |
||||||
|
<root> |
||||||
|
<items> |
||||||
|
<item id="1">Text 1</item> |
||||||
|
<items> |
||||||
|
<item id="2">Text 2</item> |
||||||
|
</items> |
||||||
|
</items> |
||||||
|
</root> |
||||||
|
'''); |
||||||
|
|
||||||
|
void printResults(String query, List<xml.XmlNode> results) { |
||||||
|
print('\nQuery: "$query"'); |
||||||
|
if (results.isEmpty) { |
||||||
|
print('No results found'); |
||||||
|
} else { |
||||||
|
print('Found ${results.length} results:'); |
||||||
|
results.forEach((node) { |
||||||
|
if (node is xml.XmlAttribute) { |
||||||
|
print('Attribute: ${node.name}=${node.value}'); |
||||||
|
} else if (node is xml.XmlText) { |
||||||
|
print('Text: ${node.text}'); |
||||||
|
} else { |
||||||
|
print('Element: ${node.toXmlString()}'); |
||||||
|
} |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// 测试查询 |
||||||
|
printResults('//items', SimpleXPath.query(doc, '//items')); |
||||||
|
printResults('//item', SimpleXPath.query(doc, '//item')); |
||||||
|
printResults('//item/text()', SimpleXPath.query(doc, '//item/text()')); |
||||||
|
printResults('//item/@id', SimpleXPath.query(doc, '//item/@id')); |
||||||
|
printResults('/root/items/item', SimpleXPath.query(doc, '/root/items/item')); |
||||||
|
printResults('//nonexistent', SimpleXPath.query(doc, '//nonexistent')); |
||||||
|
} |
@ -0,0 +1,53 @@ |
|||||||
|
// xml_extract_service.dart |
||||||
|
import 'dart:io'; |
||||||
|
import 'package:win_text_editor/framework/controllers/logger.dart'; |
||||||
|
import 'package:win_text_editor/modules/data_extract/services/simple_xpath.dart'; |
||||||
|
import 'package:win_text_editor/shared/utils/file_utils.dart'; |
||||||
|
import 'package:xml/xml.dart' as xml; |
||||||
|
import 'package:win_text_editor/modules/data_extract/models/search_result.dart'; |
||||||
|
import 'package:win_text_editor/modules/data_extract/models/xml_rule.dart'; |
||||||
|
|
||||||
|
class XmlExtractService { |
||||||
|
Future<List<SearchResult>> extractFromDirectory({ |
||||||
|
required String directory, |
||||||
|
required String fileType, |
||||||
|
required XmlRule rule, |
||||||
|
}) async { |
||||||
|
final results = <SearchResult>[]; |
||||||
|
final dir = Directory(directory); |
||||||
|
int rowNum = 1; |
||||||
|
|
||||||
|
await for (var entity in dir.list(recursive: true)) { |
||||||
|
if (entity is File && FileUtils.matchesFileType(entity.path, fileType)) { |
||||||
|
try { |
||||||
|
final fileContent = await entity.readAsString(); |
||||||
|
final document = xml.XmlDocument.parse(fileContent); |
||||||
|
final values = _extractWithRule(document, rule); |
||||||
|
for (var value in values) { |
||||||
|
results.add(SearchResult(rowNum: rowNum++, filePath: entity.path, content: value)); |
||||||
|
} |
||||||
|
} catch (e) { |
||||||
|
Logger().error('XmlExtractService.extractFromDirectory方法执行出错: $e'); |
||||||
|
results.add(SearchResult(rowNum: rowNum++, filePath: entity.path, content: 'Error: $e')); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return results; |
||||||
|
} |
||||||
|
|
||||||
|
List<String> _extractWithRule(xml.XmlDocument document, XmlRule rule) { |
||||||
|
//final nodes = document.findAllElements(rule.nodePath); |
||||||
|
final nodes = SimpleXPath.query(document, rule.toxPath()); |
||||||
|
if (rule.isFirstOccurrence && nodes.isNotEmpty) { |
||||||
|
final attr = nodes.first.getAttribute(rule.attributeName); |
||||||
|
return attr != null ? [attr] : []; |
||||||
|
} else { |
||||||
|
return nodes |
||||||
|
.map((node) => node.getAttribute(rule.attributeName)) |
||||||
|
.where((attr) => attr != null) |
||||||
|
.cast<String>() |
||||||
|
.toList(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,150 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:provider/provider.dart'; |
||||||
|
import 'package:win_text_editor/modules/data_extract/controllers/data_extract_controller.dart'; |
||||||
|
import 'package:win_text_editor/modules/data_extract/models/xml_rule.dart'; |
||||||
|
import 'package:win_text_editor/shared/components/my_checkbox.dart'; |
||||||
|
|
||||||
|
class ConditionSetting extends StatefulWidget { |
||||||
|
const ConditionSetting({super.key}); |
||||||
|
|
||||||
|
@override |
||||||
|
State<ConditionSetting> createState() => _ConditionSettingState(); |
||||||
|
} |
||||||
|
|
||||||
|
class _ConditionSettingState extends State<ConditionSetting> { |
||||||
|
final _nodePathController = TextEditingController(); |
||||||
|
final _attributeNameController = TextEditingController(); |
||||||
|
final _namespacePrefixController = TextEditingController(); |
||||||
|
bool _isFirstOccurrence = false; |
||||||
|
bool _isExtracting = false; |
||||||
|
|
||||||
|
@override |
||||||
|
void dispose() { |
||||||
|
_nodePathController.dispose(); |
||||||
|
_attributeNameController.dispose(); |
||||||
|
_namespacePrefixController.dispose(); |
||||||
|
super.dispose(); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
final controller = context.watch<DataExtractController>(); |
||||||
|
|
||||||
|
return Card( |
||||||
|
child: Padding( |
||||||
|
padding: const EdgeInsets.all(16.0), |
||||||
|
child: Column( |
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch, |
||||||
|
children: [ |
||||||
|
const Text('数据提取规则设置:', style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)), |
||||||
|
const SizedBox(height: 16), |
||||||
|
|
||||||
|
// 规则输入表单 |
||||||
|
TextField( |
||||||
|
controller: _nodePathController, |
||||||
|
decoration: const InputDecoration( |
||||||
|
labelText: '节点路径 (XPath)', |
||||||
|
hintText: '如: //business:Service 或 /root/items', |
||||||
|
border: OutlineInputBorder(), |
||||||
|
), |
||||||
|
), |
||||||
|
const SizedBox(height: 12), |
||||||
|
TextField( |
||||||
|
controller: _attributeNameController, |
||||||
|
decoration: const InputDecoration( |
||||||
|
labelText: '属性名称', |
||||||
|
hintText: '如: chineseName 或 name', |
||||||
|
border: OutlineInputBorder(), |
||||||
|
), |
||||||
|
), |
||||||
|
|
||||||
|
const SizedBox(height: 12), |
||||||
|
const Text('命名空间配置 (可选):'), |
||||||
|
TextField( |
||||||
|
controller: _namespacePrefixController, |
||||||
|
decoration: const InputDecoration( |
||||||
|
labelText: '命名空间前缀', |
||||||
|
hintText: '如: business', |
||||||
|
border: OutlineInputBorder(), |
||||||
|
), |
||||||
|
), |
||||||
|
const SizedBox(height: 12), |
||||||
|
|
||||||
|
MyCheckbox( |
||||||
|
title: '仅提取第一个匹配项', |
||||||
|
value: _isFirstOccurrence, |
||||||
|
onChanged: (value) => setState(() => _isFirstOccurrence = value ?? false), |
||||||
|
), |
||||||
|
const SizedBox(height: 16), |
||||||
|
// 操作按钮行 |
||||||
|
Row( |
||||||
|
children: [ |
||||||
|
Expanded( |
||||||
|
child: ElevatedButton.icon( |
||||||
|
icon: const Icon(Icons.play_arrow), |
||||||
|
label: const Text('开始'), |
||||||
|
onPressed: _isExtracting ? null : () => _startExtraction(controller), |
||||||
|
), |
||||||
|
), |
||||||
|
const SizedBox(width: 12), |
||||||
|
Expanded( |
||||||
|
child: ElevatedButton.icon( |
||||||
|
icon: const Icon(Icons.stop, color: Colors.red), |
||||||
|
label: const Text('结束', style: TextStyle(color: Colors.red)), |
||||||
|
onPressed: _isExtracting ? _stopExtraction : null, |
||||||
|
), |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
void _setRule() { |
||||||
|
final nodePath = _nodePathController.text.trim(); |
||||||
|
final attributeName = _attributeNameController.text.trim(); |
||||||
|
|
||||||
|
if (nodePath.isNotEmpty && attributeName.isNotEmpty) { |
||||||
|
final controller = Provider.of<DataExtractController>(context, listen: false); |
||||||
|
controller.setRule( |
||||||
|
XmlRule( |
||||||
|
nodePath: nodePath, |
||||||
|
attributeName: attributeName, |
||||||
|
isFirstOccurrence: _isFirstOccurrence, |
||||||
|
namespacePrefix: |
||||||
|
_namespacePrefixController.text.trim().isNotEmpty |
||||||
|
? _namespacePrefixController.text.trim() |
||||||
|
: null, |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
Future<void> _startExtraction(DataExtractController controller) async { |
||||||
|
if (controller.searchDirectory.isEmpty) { |
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请先选择搜索目录'))); |
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
_setRule(); |
||||||
|
|
||||||
|
setState(() => _isExtracting = true); |
||||||
|
try { |
||||||
|
await controller.executeExtraction(); |
||||||
|
} finally { |
||||||
|
if (mounted) { |
||||||
|
setState(() => _isExtracting = false); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
void _stopExtraction() { |
||||||
|
// 这里需要确保控制器中有取消提取的逻辑 |
||||||
|
final controller = Provider.of<DataExtractController>(context, listen: false); |
||||||
|
// 假设控制器中有cancelExtraction方法 |
||||||
|
controller.cancelExtraction(); |
||||||
|
setState(() => _isExtracting = false); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,63 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:provider/provider.dart'; |
||||||
|
import 'results_view.dart'; |
||||||
|
import 'package:win_text_editor/modules/data_extract/widgets/condition_setting.dart'; |
||||||
|
import 'directory.dart'; |
||||||
|
import 'package:win_text_editor/framework/controllers/tab_items_controller.dart'; |
||||||
|
import 'package:win_text_editor/modules/data_extract/controllers/data_extract_controller.dart'; |
||||||
|
|
||||||
|
class DataExtractView extends StatefulWidget { |
||||||
|
final String tabId; |
||||||
|
const DataExtractView({super.key, required this.tabId}); |
||||||
|
|
||||||
|
@override |
||||||
|
DataExtractViewState createState() => DataExtractViewState(); |
||||||
|
} |
||||||
|
|
||||||
|
class DataExtractViewState extends State<DataExtractView> { |
||||||
|
late final DataExtractController _controller; |
||||||
|
|
||||||
|
get tabManager => Provider.of<TabItemsController>(context, listen: false); |
||||||
|
|
||||||
|
@override |
||||||
|
void initState() { |
||||||
|
super.initState(); |
||||||
|
_controller = tabManager.getController(widget.tabId) ?? DataExtractController(); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void dispose() { |
||||||
|
super.dispose(); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
return ChangeNotifierProvider.value( |
||||||
|
value: _controller, |
||||||
|
child: Consumer<DataExtractController>( |
||||||
|
builder: (context, controller, child) { |
||||||
|
return const Padding( |
||||||
|
padding: EdgeInsets.all(4.0), |
||||||
|
child: Column( |
||||||
|
children: [ |
||||||
|
Directory(), |
||||||
|
Expanded( |
||||||
|
child: Row( |
||||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||||
|
children: [ |
||||||
|
// 左侧部分 (50%) |
||||||
|
Expanded(flex: 3, child: ConditionSetting()), |
||||||
|
SizedBox(width: 8), |
||||||
|
// 右侧部分 (50%) |
||||||
|
Expanded(flex: 7, child: ResultsView()), |
||||||
|
], |
||||||
|
), |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
); |
||||||
|
}, |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,86 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:provider/provider.dart'; |
||||||
|
import 'package:win_text_editor/modules/data_extract/controllers/data_extract_controller.dart'; |
||||||
|
|
||||||
|
class Directory extends StatefulWidget { |
||||||
|
const Directory({super.key}); |
||||||
|
|
||||||
|
@override |
||||||
|
State<Directory> createState() => _DirectoryState(); |
||||||
|
} |
||||||
|
|
||||||
|
class _DirectoryState extends State<Directory> { |
||||||
|
late TextEditingController _searchDirectoryController; |
||||||
|
late TextEditingController _fileTypeController; |
||||||
|
|
||||||
|
@override |
||||||
|
void initState() { |
||||||
|
super.initState(); |
||||||
|
final controller = context.read<DataExtractController>(); |
||||||
|
_searchDirectoryController = TextEditingController(text: controller.searchDirectory); |
||||||
|
_fileTypeController = TextEditingController(text: controller.fileType); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
void dispose() { |
||||||
|
_searchDirectoryController.dispose(); |
||||||
|
_fileTypeController.dispose(); |
||||||
|
super.dispose(); |
||||||
|
} |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
return Consumer<DataExtractController>( |
||||||
|
builder: (context, controller, child) { |
||||||
|
// 同步 TextEditingController 的值 |
||||||
|
if (_searchDirectoryController.text != controller.searchDirectory) { |
||||||
|
_searchDirectoryController.text = controller.searchDirectory; |
||||||
|
} |
||||||
|
if (_fileTypeController.text != controller.fileType) { |
||||||
|
_fileTypeController.text = controller.fileType; |
||||||
|
} |
||||||
|
|
||||||
|
return Card( |
||||||
|
child: Padding( |
||||||
|
padding: const EdgeInsets.all(8.0), |
||||||
|
child: Row( |
||||||
|
children: [ |
||||||
|
Expanded( |
||||||
|
child: TextField( |
||||||
|
controller: _searchDirectoryController, |
||||||
|
decoration: const InputDecoration( |
||||||
|
labelText: '搜索目录', |
||||||
|
border: OutlineInputBorder(), |
||||||
|
), |
||||||
|
onChanged: (value) => controller.searchDirectory = value, |
||||||
|
), |
||||||
|
), |
||||||
|
const SizedBox(width: 8), |
||||||
|
SizedBox( |
||||||
|
width: 100, |
||||||
|
child: TextField( |
||||||
|
controller: _fileTypeController, |
||||||
|
decoration: const InputDecoration( |
||||||
|
labelText: '文件类型', |
||||||
|
border: OutlineInputBorder(), |
||||||
|
), |
||||||
|
onChanged: (value) => controller.fileType = value, |
||||||
|
), |
||||||
|
), |
||||||
|
const SizedBox(width: 8), |
||||||
|
IconButton( |
||||||
|
icon: const Icon(Icons.folder_open), |
||||||
|
onPressed: () async { |
||||||
|
await controller.pickDirectory(); |
||||||
|
// 不需要手动更新 _searchDirectoryController.text, |
||||||
|
// 因为 Consumer 会触发重建并自动同步 |
||||||
|
}, |
||||||
|
), |
||||||
|
], |
||||||
|
), |
||||||
|
), |
||||||
|
); |
||||||
|
}, |
||||||
|
); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,138 @@ |
|||||||
|
import 'package:flutter/material.dart'; |
||||||
|
import 'package:provider/provider.dart'; |
||||||
|
import 'package:syncfusion_flutter_datagrid/datagrid.dart'; |
||||||
|
import 'package:path/path.dart' as path; |
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart'; |
||||||
|
import 'dart:io'; |
||||||
|
|
||||||
|
import 'package:win_text_editor/modules/data_extract/controllers/data_extract_controller.dart'; |
||||||
|
import 'package:win_text_editor/shared/components/my_grid_column.dart'; |
||||||
|
|
||||||
|
class ResultsView extends StatelessWidget { |
||||||
|
const ResultsView({super.key}); |
||||||
|
|
||||||
|
@override |
||||||
|
Widget build(BuildContext context) { |
||||||
|
final controller = context.watch<DataExtractController>(); |
||||||
|
|
||||||
|
return Card( |
||||||
|
child: GestureDetector( |
||||||
|
onSecondaryTapDown: (details) { |
||||||
|
_showContextMenu(context, details.globalPosition, controller); |
||||||
|
}, |
||||||
|
child: _buildLocateGrid(controller), |
||||||
|
), |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
Future<void> _showContextMenu( |
||||||
|
BuildContext context, |
||||||
|
Offset position, |
||||||
|
DataExtractController controller, |
||||||
|
) async { |
||||||
|
// 获取渲染对象以正确定位菜单 |
||||||
|
final renderBox = context.findRenderObject() as RenderBox; |
||||||
|
final localPosition = renderBox.globalToLocal(position); |
||||||
|
|
||||||
|
// 显示菜单并等待选择结果 |
||||||
|
final result = await showMenu<String>( |
||||||
|
context: context, |
||||||
|
position: RelativeRect.fromLTRB( |
||||||
|
position.dx, |
||||||
|
position.dy, |
||||||
|
position.dx + renderBox.size.width - localPosition.dx, |
||||||
|
position.dy + renderBox.size.height - localPosition.dy, |
||||||
|
), |
||||||
|
items: [const PopupMenuItem<String>(value: 'export', child: Text('导出(csv)'))], |
||||||
|
); |
||||||
|
|
||||||
|
// 处理菜单选择结果 |
||||||
|
if (result == 'export' && context.mounted) { |
||||||
|
try { |
||||||
|
await _exportToCsv(controller); |
||||||
|
} catch (e) { |
||||||
|
if (context.mounted) { |
||||||
|
ScaffoldMessenger.of( |
||||||
|
context, |
||||||
|
).showSnackBar(SnackBar(content: Text('导出失败: ${e.toString()}'))); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
Future<void> _exportToCsv(DataExtractController controller) async { |
||||||
|
String csvData = ''; |
||||||
|
csvData = '文件\t行号\t内容\n'; |
||||||
|
for (var result in controller.results) { |
||||||
|
csvData += '${path.basename(result.filePath)}\t${result.content}\n'; |
||||||
|
} |
||||||
|
|
||||||
|
final filePath = await FilePicker.platform.saveFile( |
||||||
|
dialogTitle: '保存导出结果', |
||||||
|
fileName: 'search_results.csv', |
||||||
|
type: FileType.custom, |
||||||
|
allowedExtensions: ['csv'], |
||||||
|
); |
||||||
|
|
||||||
|
if (filePath != null) { |
||||||
|
final file = File(filePath); |
||||||
|
await file.writeAsString(csvData); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
Widget _buildLocateGrid(DataExtractController controller) { |
||||||
|
return SfDataGrid( |
||||||
|
rowHeight: 32, |
||||||
|
headerRowHeight: 32, |
||||||
|
source: LocateDataSource(controller), |
||||||
|
columns: [ |
||||||
|
MyGridColumn(columnName: 'rowNum', label: '序号'), |
||||||
|
MyGridColumn(columnName: 'file', label: '文件', minimumWidth: 300), |
||||||
|
MyGridColumn(columnName: 'content', label: '内容'), |
||||||
|
], |
||||||
|
selectionMode: SelectionMode.multiple, |
||||||
|
navigationMode: GridNavigationMode.cell, |
||||||
|
gridLinesVisibility: GridLinesVisibility.both, |
||||||
|
headerGridLinesVisibility: GridLinesVisibility.both, |
||||||
|
allowSorting: false, |
||||||
|
allowFiltering: false, |
||||||
|
columnWidthMode: ColumnWidthMode.fill, |
||||||
|
isScrollbarAlwaysShown: true, |
||||||
|
allowColumnsResizing: true, // 关键开关 |
||||||
|
columnResizeMode: ColumnResizeMode.onResizeEnd, |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
class LocateDataSource extends DataGridSource { |
||||||
|
final DataExtractController controller; |
||||||
|
|
||||||
|
LocateDataSource(this.controller); |
||||||
|
|
||||||
|
@override |
||||||
|
List<DataGridRow> get rows => |
||||||
|
controller.results.map((result) { |
||||||
|
return DataGridRow( |
||||||
|
cells: [ |
||||||
|
DataGridCell(columnName: 'rowNum', value: result.rowNum), |
||||||
|
DataGridCell(columnName: 'file', value: path.basename(result.filePath)), |
||||||
|
DataGridCell(columnName: 'content', value: result.content), |
||||||
|
], |
||||||
|
); |
||||||
|
}).toList(); |
||||||
|
|
||||||
|
@override |
||||||
|
DataGridRowAdapter? buildRow(DataGridRow row) { |
||||||
|
return DataGridRowAdapter( |
||||||
|
cells: |
||||||
|
row.getCells().map<Widget>((cell) { |
||||||
|
return Container( |
||||||
|
alignment: Alignment.centerLeft, |
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8), |
||||||
|
child: Text(cell.value.toString(), overflow: TextOverflow.ellipsis), |
||||||
|
); |
||||||
|
}).toList(), |
||||||
|
); |
||||||
|
} |
||||||
|
} |
Loading…
Reference in new issue