22 changed files with 732 additions and 74 deletions
@ -0,0 +1,91 @@
@@ -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/xml_search/models/search_result.dart'; |
||||
import 'package:win_text_editor/modules/xml_search/models/xml_rule.dart'; |
||||
import 'package:win_text_editor/modules/xml_search/services/xml_search_service.dart'; |
||||
import 'package:win_text_editor/shared/base/base_content_controller.dart'; |
||||
|
||||
class XmlSearchController extends BaseContentController { |
||||
String _searchDirectory = ''; |
||||
String _fileType = '*.*'; |
||||
final List<SearchResult> _results = []; |
||||
final List<XmlRule> _rules = []; |
||||
final XmlSearchService _searchService = XmlSearchService(); |
||||
|
||||
List<SearchResult> get results => _results; |
||||
List<XmlRule> get rules => _rules; |
||||
|
||||
String get searchDirectory => _searchDirectory; |
||||
String get fileType => _fileType; |
||||
|
||||
bool _isSearching = false; |
||||
|
||||
bool onlyFileName = false; |
||||
bool get isSearching => _isSearching; |
||||
|
||||
Future<void> executeSearching() async { |
||||
Logger().info("开始提取目录:$_searchDirectory, 文件名:$_fileType"); |
||||
if (_searchDirectory.isEmpty || _rules.isEmpty) return; |
||||
|
||||
_isSearching = true; |
||||
notifyListeners(); |
||||
|
||||
_results.clear(); |
||||
notifyListeners(); |
||||
|
||||
try { |
||||
final newResults = await _searchService.searchFromDirectory( |
||||
directory: _searchDirectory, |
||||
fileType: _fileType, |
||||
rule: _rules[0], |
||||
); |
||||
_results.addAll(newResults); |
||||
} catch (e) { |
||||
Logger().error("提取目录出错:$e"); |
||||
_results.add(SearchResult(rowNum: 1, filePath: 'Error', content: 'Searchion failed: $e')); |
||||
} finally { |
||||
_isSearching = 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 cancelSearchion() {} |
||||
} |
@ -0,0 +1,8 @@
@@ -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 @@
@@ -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,52 @@
@@ -0,0 +1,52 @@
|
||||
// xml_search_service.dart |
||||
import 'dart:io'; |
||||
import 'package:win_text_editor/framework/controllers/logger.dart'; |
||||
import 'package:win_text_editor/shared/utils/file_utils.dart'; |
||||
import 'package:xml/xml.dart' as xml; |
||||
import 'package:win_text_editor/modules/xml_search/models/search_result.dart'; |
||||
import 'package:win_text_editor/modules/xml_search/models/xml_rule.dart'; |
||||
|
||||
class XmlSearchService { |
||||
Future<List<SearchResult>> searchFromDirectory({ |
||||
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 = _searchWithRule(document, rule); |
||||
for (var value in values) { |
||||
results.add(SearchResult(rowNum: rowNum++, filePath: entity.path, content: value)); |
||||
} |
||||
} catch (e) { |
||||
Logger().error('xmlSearchService.searchFromDirectory方法执行出错: $e'); |
||||
results.add(SearchResult(rowNum: rowNum++, filePath: entity.path, content: 'Error: $e')); |
||||
} |
||||
} |
||||
} |
||||
|
||||
return results; |
||||
} |
||||
|
||||
List<String> _searchWithRule(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,140 @@
@@ -0,0 +1,140 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:provider/provider.dart'; |
||||
import 'package:win_text_editor/modules/xml_search/controllers/xml_search_controller.dart'; |
||||
import 'package:win_text_editor/modules/xml_search/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<XmlSearchController>(); |
||||
|
||||
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: '节点名称', |
||||
hintText: '如: business:Service', |
||||
border: OutlineInputBorder(), |
||||
), |
||||
), |
||||
const SizedBox(height: 12), |
||||
TextField( |
||||
controller: _attributeNameController, |
||||
decoration: const InputDecoration( |
||||
labelText: '属性名称', |
||||
hintText: '如: chineseName 或 name', |
||||
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 : () => _startSearching(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<XmlSearchController>(context, listen: false); |
||||
controller.setRule( |
||||
XmlRule( |
||||
nodePath: nodePath, |
||||
attributeName: attributeName, |
||||
isFirstOccurrence: _isFirstOccurrence, |
||||
namespacePrefix: |
||||
_namespacePrefixController.text.trim().isNotEmpty |
||||
? _namespacePrefixController.text.trim() |
||||
: null, |
||||
), |
||||
); |
||||
} |
||||
} |
||||
|
||||
Future<void> _startSearching(XmlSearchController controller) async { |
||||
if (controller.searchDirectory.isEmpty) { |
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请先选择搜索目录'))); |
||||
return; |
||||
} |
||||
|
||||
_setRule(); |
||||
|
||||
setState(() => _isExtracting = true); |
||||
try { |
||||
await controller.executeSearching(); |
||||
} finally { |
||||
if (mounted) { |
||||
setState(() => _isExtracting = false); |
||||
} |
||||
} |
||||
} |
||||
|
||||
void _stopExtraction() { |
||||
// 这里需要确保控制器中有取消提取的逻辑 |
||||
final controller = Provider.of<XmlSearchController>(context, listen: false); |
||||
// 假设控制器中有cancelExtraction方法 |
||||
controller.executeSearching(); |
||||
setState(() => _isExtracting = false); |
||||
} |
||||
} |
@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:provider/provider.dart'; |
||||
import 'package:win_text_editor/modules/xml_search/controllers/xml_search_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<XmlSearchController>(); |
||||
_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<XmlSearchController>( |
||||
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,154 @@
@@ -0,0 +1,154 @@
|
||||
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/xml_search/controllers/xml_search_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<XmlSearchController>(); |
||||
|
||||
return Card( |
||||
child: GestureDetector( |
||||
onSecondaryTapDown: (details) { |
||||
_showContextMenu(context, details.globalPosition, controller); |
||||
}, |
||||
child: _buildLocateGrid(controller), |
||||
), |
||||
); |
||||
} |
||||
|
||||
Future<void> _showContextMenu( |
||||
BuildContext context, |
||||
Offset position, |
||||
XmlSearchController 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: 'exportFileName', child: Text('导出文件名(csv)')), |
||||
const PopupMenuItem<String>(value: 'exportContent', child: Text('导出内容(csv)')), |
||||
const PopupMenuItem<String>(value: 'exportAll', child: Text('导出全部(csv)')), |
||||
], |
||||
); |
||||
|
||||
// 处理菜单选择结果 |
||||
if (result != null && result.startsWith('export') && context.mounted) { |
||||
try { |
||||
await _exportToCsv(controller, result); |
||||
} catch (e) { |
||||
if (context.mounted) { |
||||
ScaffoldMessenger.of( |
||||
context, |
||||
).showSnackBar(SnackBar(content: Text('导出失败: ${e.toString()}'))); |
||||
} |
||||
} |
||||
} |
||||
} |
||||
|
||||
Future<void> _exportToCsv(XmlSearchController controller, String? exportType) async { |
||||
String csvData = ''; |
||||
csvData = |
||||
exportType == 'exportFileName' |
||||
? '文件名称\n' |
||||
: (exportType == 'exportContent' ? '内容\n' : '文件名称\t内容\n'); |
||||
for (var result in controller.results) { |
||||
switch (exportType) { |
||||
case 'exportFileName': |
||||
csvData += '${path.basename(result.filePath)}\n'; |
||||
break; |
||||
case 'exportContent': |
||||
csvData += '${result.content}\n'; |
||||
break; |
||||
default: |
||||
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(XmlSearchController controller) { |
||||
return SfDataGrid( |
||||
rowHeight: 32, |
||||
headerRowHeight: 32, |
||||
source: LocateDataSource(controller), |
||||
columns: [ |
||||
ShortGridColumn(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 XmlSearchController 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(), |
||||
); |
||||
} |
||||
} |
@ -0,0 +1,63 @@
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart'; |
||||
import 'package:provider/provider.dart'; |
||||
import 'package:win_text_editor/modules/xml_search/controllers/xml_search_controller.dart'; |
||||
import 'package:win_text_editor/modules/xml_search/widgets/condition_setting.dart'; |
||||
import 'results_view.dart'; |
||||
import 'directory.dart'; |
||||
import 'package:win_text_editor/framework/controllers/tab_items_controller.dart'; |
||||
|
||||
class XmlSearchView extends StatefulWidget { |
||||
final String tabId; |
||||
const XmlSearchView({super.key, required this.tabId}); |
||||
|
||||
@override |
||||
XmlSearchViewState createState() => XmlSearchViewState(); |
||||
} |
||||
|
||||
class XmlSearchViewState extends State<XmlSearchView> { |
||||
late final XmlSearchController _controller; |
||||
|
||||
get tabManager => Provider.of<TabItemsController>(context, listen: false); |
||||
|
||||
@override |
||||
void initState() { |
||||
super.initState(); |
||||
_controller = tabManager.getController(widget.tabId) ?? XmlSearchController(); |
||||
} |
||||
|
||||
@override |
||||
void dispose() { |
||||
super.dispose(); |
||||
} |
||||
|
||||
@override |
||||
Widget build(BuildContext context) { |
||||
return ChangeNotifierProvider.value( |
||||
value: _controller, |
||||
child: Consumer<XmlSearchController>( |
||||
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()), |
||||
], |
||||
), |
||||
), |
||||
], |
||||
), |
||||
); |
||||
}, |
||||
), |
||||
); |
||||
} |
||||
} |
Loading…
Reference in new issue