import 'dart:io'; 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/business/upload_zone.dart'; import '../widgets/business/enhanced_text_editor.dart'; import '../widgets/common/app_button.dart'; import '../widgets/common/app_card.dart'; import '../widgets/common/app_progress.dart'; import '../providers/document_provider.dart'; import '../providers/element_provider.dart'; import '../providers/annotation_provider.dart'; import '../models/document.dart'; import '../models/element.dart'; import '../models/annotation.dart'; import '../utils/constants.dart'; import '../theme/app_colors.dart'; /// 文档上传页 class UploadPage extends StatefulWidget { const UploadPage({Key? key}) : super(key: key); @override State createState() => _UploadPageState(); } class _UploadPageState extends State with SingleTickerProviderStateMixin { bool _hasFile = false; String? _fileName; double _uploadProgress = 0.0; bool _isUploading = false; int _currentStep = 0; // 0: 上传, 1: 解析, 2: 对比, 3: AI处理 String? _documentId; // 解析后的文档ID String _selectedMode = 'highlight'; // AI处理模式 late AnimationController _animationController; @override void initState() { super.initState(); _animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: 600), ); _animationController.forward(); // Demo: 默认填充要素,便于展示 WidgetsBinding.instance.addPostFrameCallback((_) { final elementProvider = Provider.of(context, listen: false); if (elementProvider.elements.isEmpty) { final demoDocId = 'demo-default'; final demoElements = _extractElements(_getMockParsedText(), demoDocId); elementProvider.setElements(demoElements); } }); } @override void dispose() { _animationController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AppLayout( maxContentWidth: 1600, child: FadeTransition( opacity: _animationController, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const SizedBox(height: 24), // 标题栏 _buildHeader(context), const SizedBox(height: 40), // 步骤指示器 if (_currentStep < 3) _buildStepIndicator(), if (_currentStep < 3) const SizedBox(height: 40), // 主要内容区 _buildContent(context), ], ), ), ), ); } Widget _buildHeader(BuildContext context) { String title; String subtitle; switch (_currentStep) { case 0: title = '上传文档'; subtitle = '支持PDF、Word、图片等多种格式'; break; case 1: title = '解析文档'; subtitle = '系统正在智能解析您的文档'; break; case 2: title = '文档解析结果'; subtitle = '原始文档与智能解析结果对比'; break; case 3: title = 'AI智能处理'; subtitle = 'AI润色、错别字修正、要素高亮'; break; default: title = '上传文档'; subtitle = '支持PDF、Word、图片等多种格式'; } return 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: [ if (_currentStep == 3) Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( gradient: LinearGradient( colors: [Colors.purple, Colors.purple.shade300], ), borderRadius: BorderRadius.circular(8), ), child: const Icon( LucideIcons.sparkles, color: Colors.white, size: 18, ), ), if (_currentStep == 3) const SizedBox(width: 12), Text( title, style: const TextStyle( fontSize: 28, fontWeight: FontWeight.bold, color: AppColors.textPrimary, ), ), ], ), const SizedBox(height: 4), Text( subtitle, style: TextStyle( fontSize: 14, color: AppColors.textSecondary, ), ), ], ), ), if (_currentStep == 2) AppButton( text: '导出', type: ButtonType.secondary, size: ButtonSize.small, icon: LucideIcons.download, onPressed: () {}, ), ], ); } Widget _buildStepIndicator() { return Container( padding: const EdgeInsets.all(24), 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: [ _buildStepItem( step: 1, title: '上传文件', icon: LucideIcons.upload, isActive: _currentStep == 0, isCompleted: _currentStep > 0, ), _buildStepConnector(isActive: _currentStep > 0), _buildStepItem( step: 2, title: '解析对比', icon: LucideIcons.settings, isActive: _currentStep == 1, isCompleted: _currentStep > 1, ), _buildStepConnector(isActive: _currentStep > 1), _buildStepItem( step: 3, title: 'AI处理', icon: LucideIcons.sparkles, isActive: _currentStep == 2, isCompleted: _currentStep > 2, ), ], ), ); } Widget _buildStepItem({ required int step, required String title, required IconData icon, required bool isActive, required bool isCompleted, }) { return Expanded( child: Row( children: [ Container( width: 40, height: 40, decoration: BoxDecoration( shape: BoxShape.circle, color: isCompleted ? AppColors.success : isActive ? AppColors.primary : AppColors.border, boxShadow: isActive ? [ BoxShadow( color: AppColors.primary.withOpacity(0.3), blurRadius: 8, offset: const Offset(0, 2), ), ] : null, ), child: isCompleted ? const Icon( LucideIcons.check, color: Colors.white, size: 20, ) : Icon( icon, color: isActive ? Colors.white : AppColors.textSecondary, size: 20, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '步骤 $step', style: TextStyle( fontSize: 12, color: AppColors.textSecondary, fontWeight: FontWeight.w500, ), ), const SizedBox(height: 2), Text( title, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: isActive || isCompleted ? AppColors.textPrimary : AppColors.textSecondary, ), ), ], ), ), ], ), ); } Widget _buildStepConnector({required bool isActive}) { return Container( height: 2, margin: const EdgeInsets.symmetric(horizontal: 12), decoration: BoxDecoration( color: isActive ? AppColors.primary : AppColors.border, borderRadius: BorderRadius.circular(1), ), ); } Widget _buildContent(BuildContext context) { if (_currentStep == 0) { return _buildUploadStep(context); } else if (_currentStep == 1) { return _buildParseStep(context); } else if (_currentStep == 2) { return _buildCompareStep(context); } else { return _buildAIProcessStep(context); } } Widget _buildUploadStep(BuildContext context) { return Column( children: [ // 上传区域 UploadZone( onUpload: _handleUpload, onProgress: (progress) { setState(() { _uploadProgress = progress; }); }, ), const SizedBox(height: 24), // 文件信息 if (_hasFile) _buildFileInfo(), const SizedBox(height: 32), // 操作按钮 Row( children: [ AppButton( text: '开始解析', type: ButtonType.primary, icon: LucideIcons.play, onPressed: _hasFile && !_isUploading ? _startParsing : null, loading: _isUploading, ), const SizedBox(width: 16), AppButton( text: '取消', type: ButtonType.secondary, onPressed: _hasFile ? _clearFile : null, ), ], ), ], ); } Widget _buildParseStep(BuildContext context) { return Container( padding: const EdgeInsets.all(32), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ AppColors.primary.withOpacity(0.05), Colors.white, ], ), borderRadius: BorderRadius.circular(16), border: Border.all(color: AppColors.border), ), child: Column( children: [ Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( color: AppColors.primary.withOpacity(0.1), shape: BoxShape.circle, ), child: const Icon( LucideIcons.settings, size: 48, color: AppColors.primary, ), ), const SizedBox(height: 24), const Text( '正在解析文档...', style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: AppColors.textPrimary, ), ), const SizedBox(height: 8), Text( '系统正在智能解析您的文档,请稍候', style: TextStyle( fontSize: 14, color: AppColors.textSecondary, ), ), const SizedBox(height: 32), AppProgress( value: _uploadProgress, status: ProgressStatus.active, showLabel: true, ), if (_uploadProgress >= 1.0) ...[ const SizedBox(height: 24), const Text( '解析完成!', style: TextStyle( fontSize: 14, color: AppColors.success, fontWeight: FontWeight.w500, ), ), ], ], ), ); } Widget _buildCompareStep(BuildContext context) { return Consumer( builder: (context, provider, child) { final document = _documentId != null ? provider.getDocumentById(_documentId!) : null; final parsedText = document?.parsedText ?? _getMockParsedText(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 工具栏 _buildCompareToolbar(context), const SizedBox(height: 24), // 对比视图 _buildComparisonView(context, parsedText), const SizedBox(height: 32), // 操作按钮 _buildCompareActionButtons(context), ], ); }, ); } Widget _buildCompareToolbar(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.border), ), child: Row( children: [ IconButton( icon: const Icon(LucideIcons.link, size: 18), tooltip: '同步滚动', onPressed: () {}, style: IconButton.styleFrom( backgroundColor: AppColors.primary.withOpacity(0.1), ), ), const SizedBox(width: 8), const VerticalDivider(width: 1), const SizedBox(width: 8), IconButton( icon: const Icon(LucideIcons.zoom_in, size: 18), tooltip: '放大', onPressed: () {}, ), IconButton( icon: const Icon(LucideIcons.zoom_out, size: 18), tooltip: '缩小', onPressed: () {}, ), IconButton( icon: const Icon(LucideIcons.rotate_cw, size: 18), tooltip: '重置', onPressed: () {}, ), ], ), ); } Widget _buildComparisonView(BuildContext context, String parsedText) { final isWide = MediaQuery.of(context).size.width > AppConstants.tabletBreakpoint; if (isWide) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: _buildOriginalDocument(context)), const SizedBox(width: 20), Expanded(child: _buildParsedText(context, parsedText)), ], ); } else { return Column( children: [ _buildOriginalDocument(context), const SizedBox(height: 16), _buildParsedText(context, parsedText), ], ); } } Widget _buildOriginalDocument(BuildContext context) { return AppCard( title: Row( children: [ const Icon(LucideIcons.file_text, size: 18, color: AppColors.textPrimary), const SizedBox(width: 8), const Text('原始文档'), const Spacer(), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: AppColors.background, borderRadius: BorderRadius.circular(6), ), child: Text( 'PDF', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.textSecondary, ), ), ), ], ), child: Container( height: 700, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.grey.shade50, Colors.grey.shade100, ], ), borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.border), ), child: ClipRRect( borderRadius: BorderRadius.circular(12), child: SingleChildScrollView( child: Container( padding: const EdgeInsets.all(40), child: Column( children: [ // 模拟PDF页面 _buildMockPDFPage(), const SizedBox(height: 20), _buildMockPDFPage(), ], ), ), ), ), ), ); } Widget _buildMockPDFPage() { return Container( width: double.infinity, padding: const EdgeInsets.all(40), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(8), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.1), blurRadius: 10, offset: const Offset(0, 4), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 模拟PDF文本行 ...List.generate(20, (index) { return Container( margin: const EdgeInsets.only(bottom: 8), height: 16, width: double.infinity, decoration: BoxDecoration( color: Colors.grey.shade300, borderRadius: BorderRadius.circular(4), ), ); }), ], ), ); } Widget _buildParsedText(BuildContext context, String parsedText) { return AppCard( title: Row( children: [ Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( gradient: LinearGradient( colors: [AppColors.primary, AppColors.primaryLight], ), borderRadius: BorderRadius.circular(6), ), child: const Icon( LucideIcons.sparkles, size: 14, color: Colors.white, ), ), const SizedBox(width: 8), const Text('智能解析结果'), const Spacer(), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: AppColors.success.withOpacity(0.1), borderRadius: BorderRadius.circular(6), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( LucideIcons.circle_check, size: 12, color: AppColors.success, ), const SizedBox(width: 4), Text( '可编辑', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.success, ), ), ], ), ), ], ), child: Container( height: 700, decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.border), ), child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: SelectableText.rich( TextSpan( children: _buildFormattedText(parsedText), style: const TextStyle( fontSize: 14, height: 1.8, color: AppColors.textPrimary, ), ), ), ), ), ); } List _buildFormattedText(String text) { final lines = text.split('\n'); final spans = []; for (int i = 0; i < lines.length; i++) { final line = lines[i]; if (line.trim().isEmpty) { spans.add(const TextSpan(text: '\n')); continue; } // 检测标题(以数字开头或包含"一、二、三"等) if (RegExp(r'^[一二三四五六七八九十]+、').hasMatch(line) || RegExp(r'^\d+[\.、]').hasMatch(line)) { spans.add( TextSpan( text: line, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.bold, color: AppColors.primary, ), ), ); } else { spans.add(TextSpan(text: line)); } if (i < lines.length - 1) { spans.add(const TextSpan(text: '\n')); } } return spans; } Widget _buildCompareActionButtons(BuildContext context) { return Row( children: [ AppButton( text: '继续AI处理', type: ButtonType.primary, icon: LucideIcons.sparkles, onPressed: () { setState(() { _currentStep = 3; }); // 不再重新加载覆盖已提取的要素,保留解析阶段自动提取的要素 }, ), const SizedBox(width: 12), AppButton( text: '重新解析', type: ButtonType.secondary, icon: LucideIcons.refresh_cw, onPressed: () { setState(() { _currentStep = 0; _documentId = null; _hasFile = false; _fileName = null; _uploadProgress = 0.0; }); }, ), const SizedBox(width: 12), AppButton( text: '导出结果', type: ButtonType.secondary, icon: LucideIcons.download, onPressed: () {}, ), ], ); } Widget _buildAIProcessStep(BuildContext context) { return Consumer3( builder: (context, docProvider, elementProvider, annotationProvider, child) { final document = _documentId != null ? docProvider.getDocumentById(_documentId!) : null; final parsedText = document?.parsedText ?? _getMockParsedText(); // 过滤当前文档的要素 final elements = _documentId != null ? elementProvider.elements .where((e) => e.documentId == _documentId) .toList() : elementProvider.elements; // 获取当前文档的批注 final annotations = annotationProvider.getAnnotationsByDocumentId(_documentId); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildAIToolbar(context), const SizedBox(height: 20), // 主内容区 LayoutBuilder( builder: (context, constraints) { final isWide = constraints.maxWidth > 900; return isWide ? Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: _buildAITextEditor( context, parsedText, elements, annotations), ), const SizedBox(width: 20), SizedBox( width: 320, child: _buildAIAnnotationPanel(context, elements, annotations, annotationProvider), ), ], ) : Column( children: [ _buildAITextEditor( context, parsedText, elements, annotations), const SizedBox(height: 20), _buildAIAnnotationPanel(context, elements, annotations, annotationProvider), ], ); }, ), ], ); }, ); } Widget _buildAIToolbar(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.border), boxShadow: [ BoxShadow( color: Colors.black.withOpacity(0.04), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: Row( children: [ _buildAIToolbarButton( icon: LucideIcons.highlighter, label: '要素高亮', isActive: _selectedMode == 'highlight', onTap: () => setState(() => _selectedMode = 'highlight'), ), const SizedBox(width: 8), _buildAIToolbarButton( icon: LucideIcons.sparkles, label: 'AI润色', isActive: _selectedMode == 'polish', onTap: () => setState(() => _selectedMode = 'polish'), ), const SizedBox(width: 8), _buildAIToolbarButton( icon: LucideIcons.circle_check, label: '错别字修正', isActive: _selectedMode == 'spellcheck', onTap: () => setState(() => _selectedMode = 'spellcheck'), ), const Spacer(), AppButton( text: '保存', type: ButtonType.primary, size: ButtonSize.small, icon: LucideIcons.save, onPressed: () {}, ), ], ), ); } Widget _buildAIToolbarButton({ required IconData icon, required String label, required bool isActive, required VoidCallback onTap, }) { return Material( color: Colors.transparent, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(8), child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration( color: isActive ? AppColors.primary.withOpacity(0.1) : Colors.transparent, borderRadius: BorderRadius.circular(8), border: isActive ? Border.all(color: AppColors.primary.withOpacity(0.3)) : null, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( icon, size: 18, color: isActive ? AppColors.primary : AppColors.textSecondary, ), const SizedBox(width: 6), Text( label, style: TextStyle( fontSize: 13, fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, color: isActive ? AppColors.primary : AppColors.textSecondary, ), ), ], ), ), ), ); } Widget _buildAITextEditor( BuildContext context, String text, List elements, List annotations, ) { 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.success.withOpacity(0.1), borderRadius: BorderRadius.circular(6), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( LucideIcons.circle_check, size: 12, color: AppColors.success, ), const SizedBox(width: 4), Text( '${elements.length} 个要素已提取', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.success, ), ), ], ), ), ], ), child: Container( height: 970, padding: const EdgeInsets.all(24), decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.grey.shade50, Colors.grey.shade100, ], ), borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.border), ), child: EnhancedTextEditor( text: text, elements: elements, annotations: annotations, onTextSelected: (selectedText) { // 直接添加要素(无需确认) final trimmed = selectedText.trim(); if (trimmed.isNotEmpty) { final currentDocId = _documentId ?? 'demo-default'; final newElement = DocumentElement( id: DateTime.now().microsecondsSinceEpoch.toString(), type: ElementType.other, label: '自定义', value: trimmed, documentId: currentDocId, ); Provider.of(context, listen: false) .addElement(newElement); } }, onAnnotationAction: (annotation) { // 处理批注操作 }, ), ), ); } // 直接添加要素逻辑已在 EnhancedTextEditor 的 onTextSelected 回调中处理 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; case ElementType.date: return AppColors.date; case ElementType.other: return AppColors.other; } } Widget _buildAIAnnotationPanel( BuildContext context, List elements, List annotations, AnnotationProvider annotationProvider, ) { return Column( children: [ AppCard( child: Stack( children: [ // 说明文字 - 左上角 Positioned( top: 0, left: 0, child: Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( color: AppColors.primary.withOpacity(0.1), borderRadius: const BorderRadius.only( topLeft: Radius.circular(8), bottomRight: Radius.circular(8), ), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( LucideIcons.info, size: 12, color: AppColors.primary, ), const SizedBox(width: 4), Text( '已提取 ${elements.length} 个要素', style: TextStyle( fontSize: 11, fontWeight: FontWeight.w500, color: AppColors.primary, ), ), ], ), ), ), // 要素列表 Padding( padding: const EdgeInsets.only(top: 32), child: elements.isEmpty ? Padding( padding: const EdgeInsets.all(32), child: Column( children: [ Icon( LucideIcons.tag, size: 48, 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), ), ), ], ), ) : Container( constraints: const BoxConstraints(maxHeight: 500), child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: elements.map((element) { return _buildElementBlock(context, element); }).toList(), ), ), ), ), ], ), ), // 批注列表 if (annotations.isNotEmpty) ...[ const SizedBox(height: 16), AppCard( title: Row( children: [ const Icon(LucideIcons.message_square, 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( '${annotations.length}', style: TextStyle( fontSize: 11, fontWeight: FontWeight.bold, color: AppColors.warning, ), ), ), ], ), child: Column( children: annotations.map((annotation) { return _buildAnnotationBlock( context, annotation, annotationProvider, ); }).toList(), ), ), ], const SizedBox(height: 16), AppCard( child: Column( children: [ AppButton( text: '继续人工牵引', type: ButtonType.primary, icon: LucideIcons.git_branch, fullWidth: true, onPressed: () { if (_documentId != null) { context.push('${AppRoutes.traction}/$_documentId'); } }, ), const SizedBox(height: 12), AppButton( text: '重新提取要素', type: ButtonType.secondary, icon: LucideIcons.refresh_cw, fullWidth: true, onPressed: () {}, ), ], ), ), ], ); } Widget _buildAnnotationBlock( BuildContext context, Annotation annotation, AnnotationProvider annotationProvider, ) { IconData icon; Color color; String typeLabel; switch (annotation.type) { case AnnotationType.highlight: icon = LucideIcons.highlighter; color = AppColors.primary; typeLabel = '高亮'; break; case AnnotationType.strikethrough: icon = LucideIcons.circle_x; color = AppColors.error; typeLabel = '错别字'; break; case AnnotationType.suggestion: icon = LucideIcons.lightbulb; color = AppColors.info; typeLabel = 'AI建议'; break; } return Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: color.withOpacity(0.05), borderRadius: BorderRadius.circular(8), border: Border.all(color: color.withOpacity(0.3)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Icon(icon, size: 16, color: color), const SizedBox(width: 6), Text( typeLabel, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: color, ), ), const Spacer(), // 操作按钮 Row( mainAxisSize: MainAxisSize.min, children: [ if (annotation.type == AnnotationType.strikethrough || annotation.type == AnnotationType.suggestion) IconButton( icon: const Icon(LucideIcons.check, size: 16), color: AppColors.success, onPressed: () { annotationProvider.acceptAnnotation(annotation.id); }, padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 28, minHeight: 28, ), ), IconButton( icon: const Icon(LucideIcons.x, size: 16), color: AppColors.error, onPressed: () { annotationProvider.rejectAnnotation(annotation.id); }, padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 28, minHeight: 28, ), ), ], ), ], ), const SizedBox(height: 8), Text( annotation.text, style: TextStyle( fontSize: 13, color: AppColors.textPrimary, decoration: annotation.type == AnnotationType.strikethrough ? TextDecoration.lineThrough : null, ), ), if (annotation.suggestion != null) ...[ const SizedBox(height: 6), Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(6), ), child: Row( children: [ Icon(LucideIcons.arrow_right, size: 14, color: color), const SizedBox(width: 6), Expanded( child: Text( annotation.suggestion!, style: TextStyle( fontSize: 12, color: color, fontWeight: FontWeight.w500, ), ), ), ], ), ), ], ], ), ); } Widget _buildAIActionButtons(BuildContext context) { return Row( children: [ AppButton( text: '继续人工牵引', type: ButtonType.primary, icon: LucideIcons.git_branch, onPressed: () { if (_documentId != null) { context.push('${AppRoutes.traction}/$_documentId'); } }, ), const SizedBox(width: 12), AppButton( text: '返回对比', type: ButtonType.secondary, icon: LucideIcons.arrow_left, onPressed: () { setState(() { _currentStep = 2; }); }, ), const SizedBox(width: 12), AppButton( text: '保存', type: ButtonType.secondary, icon: LucideIcons.save, onPressed: () {}, ), ], ); } Widget _buildFileInfo() { return Container( padding: const EdgeInsets.all(20), decoration: BoxDecoration( gradient: LinearGradient( colors: [ AppColors.primary.withOpacity(0.05), Colors.white, ], ), borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.primary.withOpacity(0.2)), ), child: Row( children: [ Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.primary.withOpacity(0.1), borderRadius: BorderRadius.circular(10), ), child: const Icon( LucideIcons.file_text, color: AppColors.primary, size: 24, ), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _fileName ?? '未知文件', style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), const SizedBox(height: 4), Row( children: [ Icon( LucideIcons.hard_drive, size: 14, color: AppColors.textSecondary, ), const SizedBox(width: 4), Text( '2.0 MB', style: TextStyle( fontSize: 12, color: AppColors.textSecondary, ), ), const SizedBox(width: 16), Icon( LucideIcons.clock, size: 14, color: AppColors.textSecondary, ), const SizedBox(width: 4), Text( '刚刚上传', style: TextStyle( fontSize: 12, color: AppColors.textSecondary, ), ), ], ), ], ), ), IconButton( icon: const Icon(LucideIcons.x), onPressed: _clearFile, color: AppColors.textSecondary, ), ], ), ); } void _handleUpload(List files) { setState(() { _hasFile = true; _fileName = files.isNotEmpty ? files.first.path.split('/').last : '示例文档.pdf'; }); } void _clearFile() { setState(() { _hasFile = false; _fileName = null; _uploadProgress = 0.0; _currentStep = 0; _documentId = null; }); } Future _startParsing() async { setState(() { _isUploading = true; _currentStep = 1; _uploadProgress = 0.0; }); // 模拟解析过程 for (int i = 0; i <= 100; i += 10) { await Future.delayed(const Duration(milliseconds: 50)); if (mounted) { setState(() { _uploadProgress = i / 100; }); } } // 解析完成后,创建文档并显示对比视图 if (mounted) { final docProvider = Provider.of(context, listen: false); final elementProvider = Provider.of(context, listen: false); // 创建新文档 final newDocument = Document( id: DateTime.now().millisecondsSinceEpoch.toString(), name: _fileName ?? '上传的文档.pdf', type: DocumentType.pdf, status: DocumentStatus.completed, createdAt: DateTime.now(), updatedAt: DateTime.now(), fileSize: 2048000, parsedText: _getMockParsedText(), ); // 添加到文档列表 docProvider.addDocument(newDocument); // 自动提取要素 final parsedText = _getMockParsedText(); final extractedElements = _extractElements(parsedText, newDocument.id); // 用新要素替换 elementProvider.setElements(extractedElements); // 加载批注 final annotationProvider = Provider.of(context, listen: false); annotationProvider.loadAnnotations(documentId: newDocument.id); // 切换到对比视图 setState(() { _isUploading = false; _currentStep = 2; _documentId = newDocument.id; }); } } String _getMockParsedText() { return '''一、项目概述 本项目由腾讯科技有限公司(以下简称"腾讯")投资建设,旨在打造一套面向大型企业财务共享中心的"智能票据处理系统"。项目负责人为张明,项目地点位于北京市海淀区中关村科技园。系统通过OCR识别、版面理解、要素抽取与人机协同核验,提升发票、合同、报销单据等票据类文档的处理效率与准确率。 二、投资与资金安排 项目总投资金额为人民币3,700万元,其中已支付1,000万元用于样机研制、数据治理与前期试点。项目启动日期为2024年1月15日,预计完成时间为2024年12月31日。剩余资金将依据阶段性里程碑进行分批拨付,涵盖核心算法优化、系统平台化改造、以及与客户生态的对接集成。 三、技术与产品路线 系统采用多模态文档理解技术,将版面结构、语义上下文与业务词典结合,实现对复杂票据的鲁棒解析。产品形态包含:上传解析端、人工牵引标注端、规则与关系构建端,以及结果交付端。技术团队由李华、王强等核心成员组成,办公地点位于上海市浦东新区。 四、收益预期 预计上线后,票据处理效率提升60%以上,人工核验工作量下降40%,并显著降低AI"幻觉"导致的错判风险,从而提升财务数据的可靠性与可审计性。项目涉及的其他关键信息包括:合同编号CT-2024-001、项目代码PRJ-2024-001等。'''; } /// 从文本中提取要素 List _extractElements(String text, String documentId) { final elements = []; int elementIndex = 1; // 1. 提取公司名称(company) final companies = [ ('腾讯科技有限公司', '公司'), ]; for (final (value, label) in companies) { if (text.contains(value)) { elements.add(DocumentElement( id: 'e${elementIndex++}', type: ElementType.company, label: label, value: value, documentId: documentId, )); } } // 2. 提取金额(amount) final amounts = [ ('3700万', '总投资'), ('1000万', '已支付'), ('3,700万元', '总投资金额'), ]; for (final (value, label) in amounts) { if (text.contains(value) && !elements.any((e) => e.value == value)) { elements.add(DocumentElement( id: 'e${elementIndex++}', type: ElementType.amount, label: label, value: value, documentId: documentId, )); } } // 3. 提取人名(person) final persons = [ ('张明', '项目负责人'), ('李华', '技术团队成员'), ('王强', '技术团队成员'), ]; for (final (value, label) in persons) { if (text.contains(value)) { elements.add(DocumentElement( id: 'e${elementIndex++}', type: ElementType.person, label: label, value: value, documentId: documentId, )); } } // 4. 提取地名(location) final locations = [ ('北京市海淀区中关村科技园', '项目地点'), ('上海市浦东新区', '办公地点'), ]; for (final (value, label) in locations) { if (text.contains(value)) { elements.add(DocumentElement( id: 'e${elementIndex++}', type: ElementType.location, label: label, value: value, documentId: documentId, )); } } // 5. 提取日期(date) final dates = [ ('2024年1月15日', '项目启动日期'), ('2024年12月31日', '预计完成时间'), ]; for (final (value, label) in dates) { if (text.contains(value)) { elements.add(DocumentElement( id: 'e${elementIndex++}', type: ElementType.date, label: label, value: value, documentId: documentId, )); } } // 6. 提取其他信息(other) final others = [ ('CT-2024-001', '合同编号'), ('PRJ-2024-001', '项目代码'), ('智能票据处理系统', '系统名称'), ]; for (final (value, label) in others) { if (text.contains(value)) { elements.add(DocumentElement( id: 'e${elementIndex++}', type: ElementType.other, label: label, value: value, documentId: documentId, )); } } return elements; } /// 构建要素文本块 Widget _buildElementBlock(BuildContext context, DocumentElement element) { final color = _getElementColor(element.type); return Container( margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: color.withOpacity(0.05), borderRadius: BorderRadius.circular(12), border: Border.all( color: color.withOpacity(0.3), width: 1.5, ), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 类型图标 Container( width: 36, height: 36, decoration: BoxDecoration( color: color.withOpacity(0.2), borderRadius: BorderRadius.circular(8), ), child: Icon( _getElementIcon(element.type), size: 20, color: color, ), ), const SizedBox(width: 12), // 要素内容 Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 标签 Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: color.withOpacity(0.15), borderRadius: BorderRadius.circular(4), ), child: Text( element.label, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: color, ), ), ), const SizedBox(height: 8), // 值 Text( element.value, style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), ], ), ), // 删除按钮 IconButton( icon: Icon( LucideIcons.x, size: 18, color: AppColors.textSecondary, ), onPressed: () { Provider.of(context, listen: false) .removeElement(element.id); }, padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 32, minHeight: 32, ), ), ], ), ); } /// 获取要素类型图标 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; case ElementType.date: return LucideIcons.calendar; default: return LucideIcons.tag; } } }