| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746 |
- 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<UploadPage> createState() => _UploadPageState();
- }
- class _UploadPageState extends State<UploadPage>
- 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<ElementProvider>(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<DocumentProvider>(
- 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<TextSpan> _buildFormattedText(String text) {
- final lines = text.split('\n');
- final spans = <TextSpan>[];
- 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<DocumentProvider, ElementProvider, AnnotationProvider>(
- 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<DocumentElement> elements,
- List<Annotation> 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<ElementProvider>(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<DocumentElement> elements,
- List<Annotation> 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<File> 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<void> _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<DocumentProvider>(context, listen: false);
- final elementProvider =
- Provider.of<ElementProvider>(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<AnnotationProvider>(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<DocumentElement> _extractElements(String text, String documentId) {
- final elements = <DocumentElement>[];
- 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<ElementProvider>(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;
- }
- }
- }
|