import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import '../widgets/layout/app_layout.dart'; import '../widgets/common/app_button.dart'; import '../widgets/common/app_card.dart'; import '../providers/document_provider.dart'; import '../providers/element_provider.dart'; import '../models/element.dart'; import '../models/graph.dart'; import '../services/mock_data_service.dart'; import '../utils/constants.dart'; import '../theme/app_colors.dart'; /// 人工牵引页 class TractionPage extends StatefulWidget { final String documentId; const TractionPage({Key? key, required this.documentId}) : super(key: key); @override State createState() => _TractionPageState(); } class _TractionPageState extends State { ElementType? _filterType; List _graphNodes = []; List _graphEdges = []; bool _showSelectedOnly = false; @override void initState() { super.initState(); _graphNodes = MockDataService.getMockGraphNodes(); _graphEdges = MockDataService.getMockGraphEdges(); } @override Widget build(BuildContext context) { return Consumer2( builder: (context, docProvider, elementProvider, child) { final document = docProvider.getDocumentById(widget.documentId); final parsedText = document?.parsedText ?? _getMockText(); final elements = elementProvider.filteredElements; return AppLayout( maxContentWidth: 1920, contentPadding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 24), // 标题栏 _buildHeader(context, document), const SizedBox(height: 32), // 三栏布局 _buildThreeColumnLayout(context, parsedText, elements), ], ), ); }, ); } Widget _buildHeader(BuildContext context, document) { return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.border), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.04), blurRadius: 12, offset: const Offset(0, 4), ), ], ), child: Row( children: [ IconButton( icon: const Icon(Icons.arrow_back), onPressed: () => context.pop(), style: IconButton.styleFrom( backgroundColor: AppColors.backgroundLight, padding: const EdgeInsets.all(12), ), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( gradient: LinearGradient( colors: [AppColors.primary, AppColors.primaryLight], ), borderRadius: BorderRadius.circular(8), ), child: const Icon( LucideIcons.git_branch, color: Colors.white, size: 18, ), ), const SizedBox(width: 12), const Text( '人工牵引', style: TextStyle( fontSize: 22, fontWeight: FontWeight.bold, color: AppColors.textPrimary, ), ), ], ), const SizedBox(height: 8), Text( '从文档标记 → 标签池 → 拖拽构建逻辑', style: TextStyle( fontSize: 14, color: AppColors.textSecondary, ), ), ], ), ), AppButton( text: '保存', type: ButtonType.secondary, size: ButtonSize.small, icon: LucideIcons.save, onPressed: () {}, ), ], ), ); } Widget _buildThreeColumnLayout( BuildContext context, String text, List elements, ) { final screenWidth = MediaQuery.of(context).size.width; final isWide = screenWidth > AppConstants.desktopBreakpoint; final veryWide = screenWidth > 1920; final elementPanelWidth = veryWide ? 540.0 : 310.0; final relationPanelWidth = veryWide ? 560.0 : 400.0; final content = isWide ? Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 左侧:要素池 SizedBox( width: elementPanelWidth, child: _buildElementPool(context, elements), ), SizedBox(width: veryWide ? 28 : 24), // 中间:主文档区 Expanded( flex: 3, child: _buildDocumentView(context, text, elements), ), SizedBox(width: veryWide ? 28 : 24), // 右侧:关系构建区 SizedBox( width: relationPanelWidth, child: _buildRelationshipBuilder(context), ), ], ) : Column( children: [ _buildDocumentView(context, text, elements), const SizedBox(height: 20), _buildRelationshipBuilder(context), ], ); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // _buildActionBar(context), // const SizedBox(height: 20), content, ], ); } Widget _buildActionBar(BuildContext context) { return Container( padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.border), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.02), blurRadius: 12, offset: const Offset(0, 4), ), ], ), child: Wrap( spacing: 16, runSpacing: 12, crossAxisAlignment: WrapCrossAlignment.center, children: [ Row( mainAxisSize: MainAxisSize.min, children: [ Container( padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: AppColors.primary.withOpacity(0.1), borderRadius: BorderRadius.circular(10), ), child: const Icon( LucideIcons.sparkles, color: AppColors.primary, size: 18, ), ), const SizedBox(width: 12), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '人工牵引操作台', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), const SizedBox(height: 4), Text( '拖拽左侧要素到右侧画布,构建规则逻辑网络', style: Theme.of(context).textTheme.bodySmall?.copyWith( color: AppColors.textSecondary, ), ), ], ), ], ), const SizedBox(width: 12), AppButton( text: '自动布局', type: ButtonType.secondary, size: ButtonSize.small, icon: LucideIcons.layout_panel_left, onPressed: _autoLayoutGraph, ), AppButton( text: '对齐网格', type: ButtonType.secondary, size: ButtonSize.small, icon: LucideIcons.grid_3x3, onPressed: _alignNodesToGrid, ), AppButton( text: '导出节点', type: ButtonType.secondary, size: ButtonSize.small, icon: LucideIcons.download, onPressed: () {}, ), ], ), ); } void _autoLayoutGraph() { if (_graphNodes.isEmpty) return; const double horizontalGap = 240; const double verticalGap = 160; const double startX = 80; const double startY = 80; const int columns = 2; setState(() { for (int index = 0; index < _graphNodes.length; index++) { final node = _graphNodes[index]; final col = index % columns; final row = index ~/ columns; final newPosition = Offset( startX + col * horizontalGap, startY + row * verticalGap, ); _graphNodes[index] = node.copyWith(position: newPosition); } }); } void _alignNodesToGrid() { if (_graphNodes.isEmpty) return; const double gridSize = 40; setState(() { for (int index = 0; index < _graphNodes.length; index++) { final node = _graphNodes[index]; final alignedX = (node.position.dx / gridSize).roundToDouble() * gridSize; final alignedY = (node.position.dy / gridSize).roundToDouble() * gridSize; _graphNodes[index] = node.copyWith(position: Offset(alignedX, alignedY)); } }); } Widget _buildElementPool(BuildContext context, List elements) { final theme = Theme.of(context); final elementProvider = Provider.of(context); final allElements = elementProvider.elements; final selectedElements = elementProvider.selectedElements; final displayElements = _showSelectedOnly ? elements.where((e) => selectedElements.contains(e)).toList() : elements; final typeCounts = { for (final type in ElementType.values) type: allElements.where((e) => e.type == type).length, }; final groupedElements = >{}; for (final type in ElementType.values) { final group = displayElements.where((element) => element.type == type).toList(); if (group.isNotEmpty) { groupedElements[type] = group; } } return AppCard( title: Row( children: [ const Icon(LucideIcons.tags, size: 18), const SizedBox(width: 8), const Text('要素池'), const Spacer(), Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: AppColors.primary.withOpacity(0.1), borderRadius: BorderRadius.circular(6), ), child: Text( '${elements.length}', style: TextStyle( fontSize: 11, fontWeight: FontWeight.bold, color: AppColors.primary, ), ), ), ], ), child: Container( padding: const EdgeInsets.all(15), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ // 搜索与筛选 Row( children: [ Expanded( child: TextField( decoration: InputDecoration( hintText: '搜索要素或关键词...', prefixIcon: const Icon(LucideIcons.search, size: 18), isDense: true, contentPadding: const EdgeInsets.symmetric( horizontal: 14, vertical: 12, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: AppColors.border), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: AppColors.border), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(10), borderSide: const BorderSide( color: AppColors.primary, width: 2, ), ), ), onChanged: (value) { Provider.of(context, listen: false).setSearchQuery(value); }, ), ), const SizedBox(width: 12), Tooltip( message: '只查看已选择的要素', child: InkWell( onTap: () { setState(() { _showSelectedOnly = !_showSelectedOnly; }); }, borderRadius: BorderRadius.circular(12), child: Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), decoration: BoxDecoration( color: _showSelectedOnly ? AppColors.primary.withOpacity(0.15) : AppColors.borderLight, borderRadius: BorderRadius.circular(12), border: Border.all( color: _showSelectedOnly ? AppColors.primary : AppColors.borderLight, ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( LucideIcons.circle_check, size: 16, color: _showSelectedOnly ? AppColors.primary : AppColors.textSecondary, ), const SizedBox(width: 6), Text( '仅已选', style: theme.textTheme.bodySmall?.copyWith( color: _showSelectedOnly ? AppColors.primary : AppColors.textSecondary, fontWeight: FontWeight.w600, ), ), ], ), ), ), ), ], ), const SizedBox(height: 16), // 类型筛选 Wrap( spacing: 8, runSpacing: 8, children: [ _buildFilterChip('全部', null, count: allElements.length), _buildFilterChip('金额', ElementType.amount, count: typeCounts[ElementType.amount] ?? 0), _buildFilterChip('公司', ElementType.company, count: typeCounts[ElementType.company] ?? 0), _buildFilterChip('人名', ElementType.person, count: typeCounts[ElementType.person] ?? 0), _buildFilterChip('地名', ElementType.location, count: typeCounts[ElementType.location] ?? 0), _buildFilterChip('日期', ElementType.date, count: typeCounts[ElementType.date] ?? 0), _buildFilterChip('其他', ElementType.other, count: typeCounts[ElementType.other] ?? 0), ], ), const SizedBox(height: 20), // 要素列表 SizedBox( height: 340, child: displayElements.isEmpty ? _buildEmptyElementState() : ListView( padding: EdgeInsets.zero, children: groupedElements.entries .map( (entry) => _buildElementGroupSection( entry.key, entry.value, selectedElements, ), ) .toList(), ), ), const SizedBox(height: 16), AppButton( text: '添加要素', type: ButtonType.primary, size: ButtonSize.small, icon: LucideIcons.plus, fullWidth: true, onPressed: () {}, ), ], ), ), ); } Widget _buildFilterChip(String label, ElementType? type, {int? count}) { final isSelected = _filterType == type; final displayLabel = count != null ? '$label · $count' : label; return FilterChip( label: Text(displayLabel), selected: isSelected, onSelected: (selected) { setState(() { _filterType = selected ? type : null; }); Provider.of(context, listen: false).setFilterType(_filterType); }, selectedColor: AppColors.primary.withOpacity(0.2), checkmarkColor: AppColors.primary, labelStyle: TextStyle( fontSize: 10, fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal, color: isSelected ? AppColors.primary : AppColors.textSecondary, ), ); } Widget _buildDraggableElement(DocumentElement element, List selectedElements) { final elementProvider = Provider.of(context, listen: false); final isSelected = selectedElements.contains(element); final color = _getElementColor(element.type); return LongPressDraggable( data: element, onDragStarted: () { if (!isSelected) { elementProvider.selectElement(element); } }, feedback: Material( color: Colors.transparent, child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 260), child: _buildElementTile( context, element, color, true, isFeedback: true, ), ), ), childWhenDragging: Opacity( opacity: 0.4, child: _buildElementTile(context, element, color, isSelected), ), child: GestureDetector( onTap: () { if (isSelected) { elementProvider.deselectElement(element); } else { elementProvider.selectElement(element); } }, child: _buildElementTile(context, element, color, isSelected), ), ); } Widget _buildElementTile( BuildContext context, DocumentElement element, Color color, bool isSelected, { bool isFeedback = false, }) { final theme = Theme.of(context); return Container( margin: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: isSelected ? color.withOpacity(0.12) : AppColors.backgroundLight, borderRadius: BorderRadius.circular(12), border: Border.all( color: isSelected ? color : AppColors.borderLight, width: isSelected ? 1.5 : 1, ), boxShadow: [ if (isSelected) BoxShadow( color: color.withOpacity(0.2), blurRadius: 12, offset: const Offset(0, 4), ), if (isFeedback) BoxShadow( color: Colors.black.withOpacity(0.15), blurRadius: 16, offset: const Offset(0, 6), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: color.withOpacity(0.16), borderRadius: BorderRadius.circular(8), ), child: Text( element.type.label, style: theme.textTheme.bodySmall?.copyWith( color: color, fontWeight: FontWeight.w600, ), ), ), const SizedBox(width: 8), Expanded( child: Text( element.label, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), ), const SizedBox(width: 6), Icon( LucideIcons.grip_vertical, size: 16, color: AppColors.textSecondary, ), ], ), const SizedBox(height: 10), Text( element.value, maxLines: 3, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith( color: AppColors.textPrimary, height: 1.5, ), ), const SizedBox(height: 12), Row( children: [ Icon( _getElementIcon(element.type), size: 16, color: color, ), const SizedBox(width: 6), Text( element.documentId ?? '未关联文档', style: theme.textTheme.bodySmall?.copyWith( color: AppColors.textSecondary, ), ), const Spacer(), if (isSelected) Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4, ), decoration: BoxDecoration( color: color.withOpacity(0.18), borderRadius: BorderRadius.circular(8), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( LucideIcons.check, size: 14, color: color, ), const SizedBox(width: 4), Text( '已选中', style: theme.textTheme.bodySmall?.copyWith( color: color, fontWeight: FontWeight.w600, ), ), ], ), ), ], ), ], ), ); } Widget _buildElementGroupSection( ElementType type, List items, List selectedElements, ) { final color = _getElementColor(type); return Padding( padding: const EdgeInsets.only(bottom: 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(10), ), child: Row( children: [ Icon(_getElementIcon(type), size: 16, color: color), const SizedBox(width: 8), Text( '${type.label} · ${items.length}', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: color, ), ), ], ), ), const SizedBox(height: 12), for (final item in items) _buildDraggableElement(item, selectedElements), ], ), ); } Widget _buildStatBadge({ required IconData icon, required String label, required String value, required Color color, }) { return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: color.withOpacity(0.12), borderRadius: BorderRadius.circular(12), border: Border.all(color: color.withOpacity(0.2)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 16, color: color), const SizedBox(width: 8), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( label, style: const TextStyle( fontSize: 11, color: AppColors.textSecondary, ), ), Text( value, style: TextStyle( fontSize: 15, fontWeight: FontWeight.w700, color: color, ), ), ], ), ], ), ); } Widget _buildEmptyElementState() { return Container( alignment: Alignment.center, decoration: BoxDecoration( color: AppColors.backgroundLight, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.borderLight), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Icon( LucideIcons.inbox, size: 48, color: AppColors.textSecondary.withOpacity(0.4), ), const SizedBox(height: 12), const Text( '暂无匹配的要素', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), const SizedBox(height: 6), Text( _showSelectedOnly ? '切换回全部或调整筛选条件试试' : '尝试更换搜索关键字或筛选条件', style: const TextStyle( fontSize: 12, color: AppColors.textSecondary, ), ), ], ), ); } IconData _getElementIcon(ElementType type) { switch (type) { case ElementType.amount: return LucideIcons.dollar_sign; case ElementType.company: return LucideIcons.building; case ElementType.person: return LucideIcons.user; case ElementType.location: return LucideIcons.map_pin; default: return LucideIcons.tag; } } Widget _buildDocumentView( BuildContext context, String text, List elements, ) { return AppCard( title: Row( children: [ const Icon(LucideIcons.file_text, size: 18), const SizedBox(width: 8), const Text('主文档'), const Spacer(), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: AppColors.primary.withOpacity(0.1), borderRadius: BorderRadius.circular(6), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( LucideIcons.mouse_pointer_click, size: 12, color: AppColors.primary, ), const SizedBox(width: 4), Text( '点击高亮区域选择', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.primary, ), ), ], ), ), ], ), child: Container( constraints: const BoxConstraints(minHeight: 600), padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.border), ), child: SelectableText.rich( TextSpan( children: _buildTextSpans(text, elements), style: const TextStyle( fontSize: 15, height: 1.8, color: AppColors.textPrimary, ), ), ), ), ); } List _buildTextSpans( String text, List elements, ) { final spans = []; int lastIndex = 0; for (final element in elements) { final index = text.indexOf(element.value, lastIndex); if (index != -1) { if (index > lastIndex) { spans.add(TextSpan(text: text.substring(lastIndex, index))); } spans.add( TextSpan( text: element.value, style: TextStyle( backgroundColor: _getElementColor(element.type).withOpacity(0.25), color: _getElementColor(element.type), fontWeight: FontWeight.w600, decoration: TextDecoration.underline, decorationColor: _getElementColor(element.type), ), mouseCursor: SystemMouseCursors.click, ), ); lastIndex = index + element.value.length; } } if (lastIndex < text.length) { spans.add(TextSpan(text: text.substring(lastIndex))); } return spans.isEmpty ? [TextSpan(text: text)] : spans; } Color _getElementColor(ElementType type) { switch (type) { case ElementType.amount: return AppColors.amount; case ElementType.company: return AppColors.company; case ElementType.person: return AppColors.person; case ElementType.location: return AppColors.location; default: return AppColors.textSecondary; } } Widget _buildRelationshipBuilder(BuildContext context) { return Column( children: [ AppCard( title: Row( children: [ const Icon(LucideIcons.git_branch, size: 18), const SizedBox(width: 8), const Text('关系构建'), const Spacer(), Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: AppColors.warning.withOpacity(0.1), borderRadius: BorderRadius.circular(6), ), child: Text( '${_graphNodes.length} 节点', style: TextStyle( fontSize: 11, fontWeight: FontWeight.bold, color: AppColors.warning, ), ), ), ], ), child: Container( padding: const EdgeInsets.all(15), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '拖拽要素到此处构建关系网络', style: TextStyle( fontSize: 12, color: AppColors.textSecondary, ), ), const SizedBox(height: 16), // 关系网络图 DragTarget( onAccept: (element) { setState(() { _graphNodes.add( GraphNode( id: 'n${_graphNodes.length + 1}', label: element.displayText, type: NodeType.element, position: Offset( 100 + (_graphNodes.length * 80).toDouble(), 120 + (_graphNodes.length * 60).toDouble(), ), ), ); }); }, builder: (context, candidateData, rejectedData) { final isHovering = candidateData.isNotEmpty; return AnimatedContainer( duration: const Duration(milliseconds: 180), height: 400, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ AppColors.background, Colors.white, ], ), borderRadius: BorderRadius.circular(12), border: Border.all( color: isHovering ? AppColors.primary : AppColors.border, width: isHovering ? 2 : 1, ), ), clipBehavior: Clip.hardEdge, child: LayoutBuilder( builder: (context, constraints) { final canvasWidth = math.max(constraints.maxWidth, 600.0); final canvasHeight = math.max(constraints.maxHeight, 420.0); return InteractiveViewer( constrained: false, minScale: 0.5, maxScale: 2.5, boundaryMargin: const EdgeInsets.all(200), child: SizedBox( width: canvasWidth, height: canvasHeight, child: _buildGraphCanvas( Size(canvasWidth, canvasHeight), ), ), ); }, ), ); }, ), const SizedBox(height: 16), // 关系类型选择 Wrap( spacing: 8, runSpacing: 8, children: [ _buildRelationChip('计算', EdgeType.calculate), _buildRelationChip('引用', EdgeType.reference), _buildRelationChip('包含', EdgeType.contain), ], ), ], ), )), const SizedBox(height: 16), AppCard( child: Column( children: [ AppButton( text: '生成Prompt', type: ButtonType.primary, icon: LucideIcons.sparkles, fullWidth: true, onPressed: () { context.push('${AppRoutes.result}/${widget.documentId}'); }, ), const SizedBox(height: 12), AppButton( text: '清空关系', type: ButtonType.secondary, icon: LucideIcons.trash_2, fullWidth: true, onPressed: () { setState(() { _graphNodes.clear(); _graphEdges.clear(); }); }, ), ], ), ), ], ); } Widget _buildGraphCanvas(Size canvasSize) { if (_graphNodes.isEmpty) { return SizedBox.expand( child: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( LucideIcons.git_branch, size: 64, color: AppColors.textSecondary.withOpacity(0.5), ), const SizedBox(height: 16), Text( '拖拽要素到此处', style: TextStyle( fontSize: 14, color: AppColors.textSecondary, ), ), const SizedBox(height: 8), Text( '开始构建关系网络', style: TextStyle( fontSize: 12, color: AppColors.textSecondary.withOpacity(0.7), ), ), ], ), ), ); } final positions = {}; for (final node in _graphNodes) { positions[node.id] = _clampOffset(node.position, canvasSize); } return CustomPaint( size: canvasSize, painter: GraphPainter(_graphNodes, _graphEdges, positions), child: Stack( clipBehavior: Clip.none, children: _graphNodes.map((node) { final position = positions[node.id] ?? node.position; return Positioned( left: position.dx, top: position.dy, child: _buildGraphNode(node), ); }).toList(), ), ); } Offset _clampOffset(Offset position, Size canvasSize) { const double padding = 24; const double nodeWidth = 160; const double nodeHeight = 64; final double maxX = math.max(padding, canvasSize.width - nodeWidth - padding); final double maxY = math.max(padding, canvasSize.height - nodeHeight - padding); return Offset( position.dx.clamp(padding, maxX), position.dy.clamp(padding, maxY), ); } Widget _buildGraphNode(GraphNode node) { Color getNodeColor() { switch (node.type) { case NodeType.element: return AppColors.primary; case NodeType.operator: return AppColors.warning; case NodeType.result: return AppColors.success; } } return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( color: getNodeColor(), borderRadius: BorderRadius.circular(8), boxShadow: [ BoxShadow( color: getNodeColor().withOpacity(0.3), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: Text( node.label, style: const TextStyle( color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600, ), ), ); } Widget _buildRelationChip(String label, EdgeType type) { return ChoiceChip( label: Text(label), selected: false, onSelected: (selected) {}, labelStyle: const TextStyle(fontSize: 12), ); } String _getMockText() { return '''一、项目概述 本项目由腾讯科技有限公司(以下简称"腾讯")投资建设,旨在打造一套面向大型企业财务共享中心的"智能票据处理系统"。系统通过OCR识别、版面理解、要素抽取与人机协同核验,提升发票、合同、报销单据等票据类文档的处理效率与准确率。 二、投资与资金安排 项目总投资金额为人民币3,700万元,其中已支付1,000万元用于样机研制、数据治理与前期试点。剩余资金将依据阶段性里程碑进行分批拨付,涵盖核心算法优化、系统平台化改造、以及与客户生态的对接集成。 三、技术与产品路线 系统采用多模态文档理解技术,将版面结构、语义上下文与业务词典结合,实现对复杂票据的鲁棒解析。产品形态包含:上传解析端、人工牵引标注端、规则与关系构建端,以及结果交付端。 四、收益预期 预计上线后,票据处理效率提升60%以上,人工核验工作量下降40%,并显著降低AI"幻觉"导致的错判风险,从而提升财务数据的可靠性与可审计性。'''; } } /// 关系网络绘制器 class GraphPainter extends CustomPainter { final List nodes; final List edges; final Map positions; GraphPainter(this.nodes, this.edges, this.positions); @override void paint(Canvas canvas, Size size) { if (nodes.isEmpty) return; const double nodeWidth = 160; const double nodeHeight = 64; final connectionPaint = Paint() ..color = AppColors.border ..strokeWidth = 1.5 ..style = PaintingStyle.stroke; final arrowPaint = Paint() ..color = AppColors.primary ..style = PaintingStyle.fill; for (final edge in edges) { final source = positions[edge.source] ?? nodes.firstWhere((n) => n.id == edge.source).position; final target = positions[edge.target] ?? nodes.firstWhere((n) => n.id == edge.target).position; final sourceCenter = Offset( source.dx + nodeWidth / 2, source.dy + nodeHeight / 2, ); final targetCenter = Offset( target.dx + nodeWidth / 2, target.dy + nodeHeight / 2, ); canvas.drawLine(sourceCenter, targetCenter, connectionPaint); final dx = targetCenter.dx - sourceCenter.dx; final dy = targetCenter.dy - sourceCenter.dy; final distance = math.sqrt(dx * dx + dy * dy); if (distance < 0.001) continue; final directionX = dx / distance; final directionY = dy / distance; const arrowLength = 12.0; const arrowWidth = 6.0; final arrowPoint = Offset( targetCenter.dx - directionX * (nodeWidth / 2 - 4), targetCenter.dy - directionY * (nodeHeight / 2 - 4), ); final orthogonalX = -directionY; final orthogonalY = directionX; final path = Path() ..moveTo(arrowPoint.dx, arrowPoint.dy) ..lineTo( arrowPoint.dx - directionX * arrowLength + orthogonalX * arrowWidth, arrowPoint.dy - directionY * arrowLength + orthogonalY * arrowWidth, ) ..lineTo( arrowPoint.dx - directionX * arrowLength - orthogonalX * arrowWidth, arrowPoint.dy - directionY * arrowLength - orthogonalY * arrowWidth, ) ..close(); canvas.drawPath(path, arrowPaint); } } @override bool shouldRepaint(covariant CustomPainter oldDelegate) => true; }