upload_page.dart 53 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746
  1. import 'dart:io';
  2. import 'package:flutter/material.dart';
  3. import 'package:go_router/go_router.dart';
  4. import 'package:provider/provider.dart';
  5. import 'package:flutter_lucide/flutter_lucide.dart';
  6. import '../widgets/layout/app_layout.dart';
  7. import '../widgets/business/upload_zone.dart';
  8. import '../widgets/business/enhanced_text_editor.dart';
  9. import '../widgets/common/app_button.dart';
  10. import '../widgets/common/app_card.dart';
  11. import '../widgets/common/app_progress.dart';
  12. import '../providers/document_provider.dart';
  13. import '../providers/element_provider.dart';
  14. import '../providers/annotation_provider.dart';
  15. import '../models/document.dart';
  16. import '../models/element.dart';
  17. import '../models/annotation.dart';
  18. import '../utils/constants.dart';
  19. import '../theme/app_colors.dart';
  20. /// 文档上传页
  21. class UploadPage extends StatefulWidget {
  22. const UploadPage({Key? key}) : super(key: key);
  23. @override
  24. State<UploadPage> createState() => _UploadPageState();
  25. }
  26. class _UploadPageState extends State<UploadPage>
  27. with SingleTickerProviderStateMixin {
  28. bool _hasFile = false;
  29. String? _fileName;
  30. double _uploadProgress = 0.0;
  31. bool _isUploading = false;
  32. int _currentStep = 0; // 0: 上传, 1: 解析, 2: 对比, 3: AI处理
  33. String? _documentId; // 解析后的文档ID
  34. String _selectedMode = 'highlight'; // AI处理模式
  35. late AnimationController _animationController;
  36. @override
  37. void initState() {
  38. super.initState();
  39. _animationController = AnimationController(
  40. vsync: this,
  41. duration: const Duration(milliseconds: 600),
  42. );
  43. _animationController.forward();
  44. // Demo: 默认填充要素,便于展示
  45. WidgetsBinding.instance.addPostFrameCallback((_) {
  46. final elementProvider =
  47. Provider.of<ElementProvider>(context, listen: false);
  48. if (elementProvider.elements.isEmpty) {
  49. final demoDocId = 'demo-default';
  50. final demoElements = _extractElements(_getMockParsedText(), demoDocId);
  51. elementProvider.setElements(demoElements);
  52. }
  53. });
  54. }
  55. @override
  56. void dispose() {
  57. _animationController.dispose();
  58. super.dispose();
  59. }
  60. @override
  61. Widget build(BuildContext context) {
  62. return AppLayout(
  63. maxContentWidth: 1600,
  64. child: FadeTransition(
  65. opacity: _animationController,
  66. child: SingleChildScrollView(
  67. child: Column(
  68. mainAxisSize: MainAxisSize.min,
  69. crossAxisAlignment: CrossAxisAlignment.start,
  70. children: [
  71. const SizedBox(height: 24),
  72. // 标题栏
  73. _buildHeader(context),
  74. const SizedBox(height: 40),
  75. // 步骤指示器
  76. if (_currentStep < 3) _buildStepIndicator(),
  77. if (_currentStep < 3) const SizedBox(height: 40),
  78. // 主要内容区
  79. _buildContent(context),
  80. ],
  81. ),
  82. ),
  83. ),
  84. );
  85. }
  86. Widget _buildHeader(BuildContext context) {
  87. String title;
  88. String subtitle;
  89. switch (_currentStep) {
  90. case 0:
  91. title = '上传文档';
  92. subtitle = '支持PDF、Word、图片等多种格式';
  93. break;
  94. case 1:
  95. title = '解析文档';
  96. subtitle = '系统正在智能解析您的文档';
  97. break;
  98. case 2:
  99. title = '文档解析结果';
  100. subtitle = '原始文档与智能解析结果对比';
  101. break;
  102. case 3:
  103. title = 'AI智能处理';
  104. subtitle = 'AI润色、错别字修正、要素高亮';
  105. break;
  106. default:
  107. title = '上传文档';
  108. subtitle = '支持PDF、Word、图片等多种格式';
  109. }
  110. return Row(
  111. children: [
  112. IconButton(
  113. icon: const Icon(Icons.arrow_back),
  114. onPressed: () => context.pop(),
  115. style: IconButton.styleFrom(
  116. backgroundColor: AppColors.backgroundLight,
  117. padding: const EdgeInsets.all(12),
  118. ),
  119. ),
  120. const SizedBox(width: 16),
  121. Expanded(
  122. child: Column(
  123. crossAxisAlignment: CrossAxisAlignment.start,
  124. children: [
  125. Row(
  126. children: [
  127. if (_currentStep == 3)
  128. Container(
  129. padding: const EdgeInsets.all(8),
  130. decoration: BoxDecoration(
  131. gradient: LinearGradient(
  132. colors: [Colors.purple, Colors.purple.shade300],
  133. ),
  134. borderRadius: BorderRadius.circular(8),
  135. ),
  136. child: const Icon(
  137. LucideIcons.sparkles,
  138. color: Colors.white,
  139. size: 18,
  140. ),
  141. ),
  142. if (_currentStep == 3) const SizedBox(width: 12),
  143. Text(
  144. title,
  145. style: const TextStyle(
  146. fontSize: 28,
  147. fontWeight: FontWeight.bold,
  148. color: AppColors.textPrimary,
  149. ),
  150. ),
  151. ],
  152. ),
  153. const SizedBox(height: 4),
  154. Text(
  155. subtitle,
  156. style: TextStyle(
  157. fontSize: 14,
  158. color: AppColors.textSecondary,
  159. ),
  160. ),
  161. ],
  162. ),
  163. ),
  164. if (_currentStep == 2)
  165. AppButton(
  166. text: '导出',
  167. type: ButtonType.secondary,
  168. size: ButtonSize.small,
  169. icon: LucideIcons.download,
  170. onPressed: () {},
  171. ),
  172. ],
  173. );
  174. }
  175. Widget _buildStepIndicator() {
  176. return Container(
  177. padding: const EdgeInsets.all(24),
  178. decoration: BoxDecoration(
  179. color: Colors.white,
  180. borderRadius: BorderRadius.circular(16),
  181. border: Border.all(color: AppColors.border),
  182. boxShadow: [
  183. BoxShadow(
  184. color: Colors.black.withOpacity(0.04),
  185. blurRadius: 12,
  186. offset: const Offset(0, 4),
  187. ),
  188. ],
  189. ),
  190. child: Row(
  191. children: [
  192. _buildStepItem(
  193. step: 1,
  194. title: '上传文件',
  195. icon: LucideIcons.upload,
  196. isActive: _currentStep == 0,
  197. isCompleted: _currentStep > 0,
  198. ),
  199. _buildStepConnector(isActive: _currentStep > 0),
  200. _buildStepItem(
  201. step: 2,
  202. title: '解析对比',
  203. icon: LucideIcons.settings,
  204. isActive: _currentStep == 1,
  205. isCompleted: _currentStep > 1,
  206. ),
  207. _buildStepConnector(isActive: _currentStep > 1),
  208. _buildStepItem(
  209. step: 3,
  210. title: 'AI处理',
  211. icon: LucideIcons.sparkles,
  212. isActive: _currentStep == 2,
  213. isCompleted: _currentStep > 2,
  214. ),
  215. ],
  216. ),
  217. );
  218. }
  219. Widget _buildStepItem({
  220. required int step,
  221. required String title,
  222. required IconData icon,
  223. required bool isActive,
  224. required bool isCompleted,
  225. }) {
  226. return Expanded(
  227. child: Row(
  228. children: [
  229. Container(
  230. width: 40,
  231. height: 40,
  232. decoration: BoxDecoration(
  233. shape: BoxShape.circle,
  234. color: isCompleted
  235. ? AppColors.success
  236. : isActive
  237. ? AppColors.primary
  238. : AppColors.border,
  239. boxShadow: isActive
  240. ? [
  241. BoxShadow(
  242. color: AppColors.primary.withOpacity(0.3),
  243. blurRadius: 8,
  244. offset: const Offset(0, 2),
  245. ),
  246. ]
  247. : null,
  248. ),
  249. child: isCompleted
  250. ? const Icon(
  251. LucideIcons.check,
  252. color: Colors.white,
  253. size: 20,
  254. )
  255. : Icon(
  256. icon,
  257. color: isActive ? Colors.white : AppColors.textSecondary,
  258. size: 20,
  259. ),
  260. ),
  261. const SizedBox(width: 12),
  262. Expanded(
  263. child: Column(
  264. crossAxisAlignment: CrossAxisAlignment.start,
  265. children: [
  266. Text(
  267. '步骤 $step',
  268. style: TextStyle(
  269. fontSize: 12,
  270. color: AppColors.textSecondary,
  271. fontWeight: FontWeight.w500,
  272. ),
  273. ),
  274. const SizedBox(height: 2),
  275. Text(
  276. title,
  277. style: TextStyle(
  278. fontSize: 14,
  279. fontWeight: FontWeight.w600,
  280. color: isActive || isCompleted
  281. ? AppColors.textPrimary
  282. : AppColors.textSecondary,
  283. ),
  284. ),
  285. ],
  286. ),
  287. ),
  288. ],
  289. ),
  290. );
  291. }
  292. Widget _buildStepConnector({required bool isActive}) {
  293. return Container(
  294. height: 2,
  295. margin: const EdgeInsets.symmetric(horizontal: 12),
  296. decoration: BoxDecoration(
  297. color: isActive ? AppColors.primary : AppColors.border,
  298. borderRadius: BorderRadius.circular(1),
  299. ),
  300. );
  301. }
  302. Widget _buildContent(BuildContext context) {
  303. if (_currentStep == 0) {
  304. return _buildUploadStep(context);
  305. } else if (_currentStep == 1) {
  306. return _buildParseStep(context);
  307. } else if (_currentStep == 2) {
  308. return _buildCompareStep(context);
  309. } else {
  310. return _buildAIProcessStep(context);
  311. }
  312. }
  313. Widget _buildUploadStep(BuildContext context) {
  314. return Column(
  315. children: [
  316. // 上传区域
  317. UploadZone(
  318. onUpload: _handleUpload,
  319. onProgress: (progress) {
  320. setState(() {
  321. _uploadProgress = progress;
  322. });
  323. },
  324. ),
  325. const SizedBox(height: 24),
  326. // 文件信息
  327. if (_hasFile) _buildFileInfo(),
  328. const SizedBox(height: 32),
  329. // 操作按钮
  330. Row(
  331. children: [
  332. AppButton(
  333. text: '开始解析',
  334. type: ButtonType.primary,
  335. icon: LucideIcons.play,
  336. onPressed: _hasFile && !_isUploading ? _startParsing : null,
  337. loading: _isUploading,
  338. ),
  339. const SizedBox(width: 16),
  340. AppButton(
  341. text: '取消',
  342. type: ButtonType.secondary,
  343. onPressed: _hasFile ? _clearFile : null,
  344. ),
  345. ],
  346. ),
  347. ],
  348. );
  349. }
  350. Widget _buildParseStep(BuildContext context) {
  351. return Container(
  352. padding: const EdgeInsets.all(32),
  353. decoration: BoxDecoration(
  354. gradient: LinearGradient(
  355. begin: Alignment.topLeft,
  356. end: Alignment.bottomRight,
  357. colors: [
  358. AppColors.primary.withOpacity(0.05),
  359. Colors.white,
  360. ],
  361. ),
  362. borderRadius: BorderRadius.circular(16),
  363. border: Border.all(color: AppColors.border),
  364. ),
  365. child: Column(
  366. children: [
  367. Container(
  368. padding: const EdgeInsets.all(20),
  369. decoration: BoxDecoration(
  370. color: AppColors.primary.withOpacity(0.1),
  371. shape: BoxShape.circle,
  372. ),
  373. child: const Icon(
  374. LucideIcons.settings,
  375. size: 48,
  376. color: AppColors.primary,
  377. ),
  378. ),
  379. const SizedBox(height: 24),
  380. const Text(
  381. '正在解析文档...',
  382. style: TextStyle(
  383. fontSize: 20,
  384. fontWeight: FontWeight.bold,
  385. color: AppColors.textPrimary,
  386. ),
  387. ),
  388. const SizedBox(height: 8),
  389. Text(
  390. '系统正在智能解析您的文档,请稍候',
  391. style: TextStyle(
  392. fontSize: 14,
  393. color: AppColors.textSecondary,
  394. ),
  395. ),
  396. const SizedBox(height: 32),
  397. AppProgress(
  398. value: _uploadProgress,
  399. status: ProgressStatus.active,
  400. showLabel: true,
  401. ),
  402. if (_uploadProgress >= 1.0) ...[
  403. const SizedBox(height: 24),
  404. const Text(
  405. '解析完成!',
  406. style: TextStyle(
  407. fontSize: 14,
  408. color: AppColors.success,
  409. fontWeight: FontWeight.w500,
  410. ),
  411. ),
  412. ],
  413. ],
  414. ),
  415. );
  416. }
  417. Widget _buildCompareStep(BuildContext context) {
  418. return Consumer<DocumentProvider>(
  419. builder: (context, provider, child) {
  420. final document =
  421. _documentId != null ? provider.getDocumentById(_documentId!) : null;
  422. final parsedText = document?.parsedText ?? _getMockParsedText();
  423. return Column(
  424. crossAxisAlignment: CrossAxisAlignment.start,
  425. children: [
  426. // 工具栏
  427. _buildCompareToolbar(context),
  428. const SizedBox(height: 24),
  429. // 对比视图
  430. _buildComparisonView(context, parsedText),
  431. const SizedBox(height: 32),
  432. // 操作按钮
  433. _buildCompareActionButtons(context),
  434. ],
  435. );
  436. },
  437. );
  438. }
  439. Widget _buildCompareToolbar(BuildContext context) {
  440. return Container(
  441. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
  442. decoration: BoxDecoration(
  443. color: Colors.white,
  444. borderRadius: BorderRadius.circular(12),
  445. border: Border.all(color: AppColors.border),
  446. ),
  447. child: Row(
  448. children: [
  449. IconButton(
  450. icon: const Icon(LucideIcons.link, size: 18),
  451. tooltip: '同步滚动',
  452. onPressed: () {},
  453. style: IconButton.styleFrom(
  454. backgroundColor: AppColors.primary.withOpacity(0.1),
  455. ),
  456. ),
  457. const SizedBox(width: 8),
  458. const VerticalDivider(width: 1),
  459. const SizedBox(width: 8),
  460. IconButton(
  461. icon: const Icon(LucideIcons.zoom_in, size: 18),
  462. tooltip: '放大',
  463. onPressed: () {},
  464. ),
  465. IconButton(
  466. icon: const Icon(LucideIcons.zoom_out, size: 18),
  467. tooltip: '缩小',
  468. onPressed: () {},
  469. ),
  470. IconButton(
  471. icon: const Icon(LucideIcons.rotate_cw, size: 18),
  472. tooltip: '重置',
  473. onPressed: () {},
  474. ),
  475. ],
  476. ),
  477. );
  478. }
  479. Widget _buildComparisonView(BuildContext context, String parsedText) {
  480. final isWide =
  481. MediaQuery.of(context).size.width > AppConstants.tabletBreakpoint;
  482. if (isWide) {
  483. return Row(
  484. crossAxisAlignment: CrossAxisAlignment.start,
  485. children: [
  486. Expanded(child: _buildOriginalDocument(context)),
  487. const SizedBox(width: 20),
  488. Expanded(child: _buildParsedText(context, parsedText)),
  489. ],
  490. );
  491. } else {
  492. return Column(
  493. children: [
  494. _buildOriginalDocument(context),
  495. const SizedBox(height: 16),
  496. _buildParsedText(context, parsedText),
  497. ],
  498. );
  499. }
  500. }
  501. Widget _buildOriginalDocument(BuildContext context) {
  502. return AppCard(
  503. title: Row(
  504. children: [
  505. const Icon(LucideIcons.file_text,
  506. size: 18, color: AppColors.textPrimary),
  507. const SizedBox(width: 8),
  508. const Text('原始文档'),
  509. const Spacer(),
  510. Container(
  511. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
  512. decoration: BoxDecoration(
  513. color: AppColors.background,
  514. borderRadius: BorderRadius.circular(6),
  515. ),
  516. child: Text(
  517. 'PDF',
  518. style: TextStyle(
  519. fontSize: 11,
  520. fontWeight: FontWeight.w600,
  521. color: AppColors.textSecondary,
  522. ),
  523. ),
  524. ),
  525. ],
  526. ),
  527. child: Container(
  528. height: 700,
  529. decoration: BoxDecoration(
  530. gradient: LinearGradient(
  531. begin: Alignment.topCenter,
  532. end: Alignment.bottomCenter,
  533. colors: [
  534. Colors.grey.shade50,
  535. Colors.grey.shade100,
  536. ],
  537. ),
  538. borderRadius: BorderRadius.circular(12),
  539. border: Border.all(color: AppColors.border),
  540. ),
  541. child: ClipRRect(
  542. borderRadius: BorderRadius.circular(12),
  543. child: SingleChildScrollView(
  544. child: Container(
  545. padding: const EdgeInsets.all(40),
  546. child: Column(
  547. children: [
  548. // 模拟PDF页面
  549. _buildMockPDFPage(),
  550. const SizedBox(height: 20),
  551. _buildMockPDFPage(),
  552. ],
  553. ),
  554. ),
  555. ),
  556. ),
  557. ),
  558. );
  559. }
  560. Widget _buildMockPDFPage() {
  561. return Container(
  562. width: double.infinity,
  563. padding: const EdgeInsets.all(40),
  564. decoration: BoxDecoration(
  565. color: Colors.white,
  566. borderRadius: BorderRadius.circular(8),
  567. boxShadow: [
  568. BoxShadow(
  569. color: Colors.black.withOpacity(0.1),
  570. blurRadius: 10,
  571. offset: const Offset(0, 4),
  572. ),
  573. ],
  574. ),
  575. child: Column(
  576. crossAxisAlignment: CrossAxisAlignment.start,
  577. children: [
  578. // 模拟PDF文本行
  579. ...List.generate(20, (index) {
  580. return Container(
  581. margin: const EdgeInsets.only(bottom: 8),
  582. height: 16,
  583. width: double.infinity,
  584. decoration: BoxDecoration(
  585. color: Colors.grey.shade300,
  586. borderRadius: BorderRadius.circular(4),
  587. ),
  588. );
  589. }),
  590. ],
  591. ),
  592. );
  593. }
  594. Widget _buildParsedText(BuildContext context, String parsedText) {
  595. return AppCard(
  596. title: Row(
  597. children: [
  598. Container(
  599. padding: const EdgeInsets.all(6),
  600. decoration: BoxDecoration(
  601. gradient: LinearGradient(
  602. colors: [AppColors.primary, AppColors.primaryLight],
  603. ),
  604. borderRadius: BorderRadius.circular(6),
  605. ),
  606. child: const Icon(
  607. LucideIcons.sparkles,
  608. size: 14,
  609. color: Colors.white,
  610. ),
  611. ),
  612. const SizedBox(width: 8),
  613. const Text('智能解析结果'),
  614. const Spacer(),
  615. Container(
  616. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
  617. decoration: BoxDecoration(
  618. color: AppColors.success.withOpacity(0.1),
  619. borderRadius: BorderRadius.circular(6),
  620. ),
  621. child: Row(
  622. mainAxisSize: MainAxisSize.min,
  623. children: [
  624. Icon(
  625. LucideIcons.circle_check,
  626. size: 12,
  627. color: AppColors.success,
  628. ),
  629. const SizedBox(width: 4),
  630. Text(
  631. '可编辑',
  632. style: TextStyle(
  633. fontSize: 11,
  634. fontWeight: FontWeight.w600,
  635. color: AppColors.success,
  636. ),
  637. ),
  638. ],
  639. ),
  640. ),
  641. ],
  642. ),
  643. child: Container(
  644. height: 700,
  645. decoration: BoxDecoration(
  646. color: Colors.white,
  647. borderRadius: BorderRadius.circular(12),
  648. border: Border.all(color: AppColors.border),
  649. ),
  650. child: SingleChildScrollView(
  651. padding: const EdgeInsets.all(24),
  652. child: SelectableText.rich(
  653. TextSpan(
  654. children: _buildFormattedText(parsedText),
  655. style: const TextStyle(
  656. fontSize: 14,
  657. height: 1.8,
  658. color: AppColors.textPrimary,
  659. ),
  660. ),
  661. ),
  662. ),
  663. ),
  664. );
  665. }
  666. List<TextSpan> _buildFormattedText(String text) {
  667. final lines = text.split('\n');
  668. final spans = <TextSpan>[];
  669. for (int i = 0; i < lines.length; i++) {
  670. final line = lines[i];
  671. if (line.trim().isEmpty) {
  672. spans.add(const TextSpan(text: '\n'));
  673. continue;
  674. }
  675. // 检测标题(以数字开头或包含"一、二、三"等)
  676. if (RegExp(r'^[一二三四五六七八九十]+、').hasMatch(line) ||
  677. RegExp(r'^\d+[\.、]').hasMatch(line)) {
  678. spans.add(
  679. TextSpan(
  680. text: line,
  681. style: const TextStyle(
  682. fontSize: 16,
  683. fontWeight: FontWeight.bold,
  684. color: AppColors.primary,
  685. ),
  686. ),
  687. );
  688. } else {
  689. spans.add(TextSpan(text: line));
  690. }
  691. if (i < lines.length - 1) {
  692. spans.add(const TextSpan(text: '\n'));
  693. }
  694. }
  695. return spans;
  696. }
  697. Widget _buildCompareActionButtons(BuildContext context) {
  698. return Row(
  699. children: [
  700. AppButton(
  701. text: '继续AI处理',
  702. type: ButtonType.primary,
  703. icon: LucideIcons.sparkles,
  704. onPressed: () {
  705. setState(() {
  706. _currentStep = 3;
  707. });
  708. // 不再重新加载覆盖已提取的要素,保留解析阶段自动提取的要素
  709. },
  710. ),
  711. const SizedBox(width: 12),
  712. AppButton(
  713. text: '重新解析',
  714. type: ButtonType.secondary,
  715. icon: LucideIcons.refresh_cw,
  716. onPressed: () {
  717. setState(() {
  718. _currentStep = 0;
  719. _documentId = null;
  720. _hasFile = false;
  721. _fileName = null;
  722. _uploadProgress = 0.0;
  723. });
  724. },
  725. ),
  726. const SizedBox(width: 12),
  727. AppButton(
  728. text: '导出结果',
  729. type: ButtonType.secondary,
  730. icon: LucideIcons.download,
  731. onPressed: () {},
  732. ),
  733. ],
  734. );
  735. }
  736. Widget _buildAIProcessStep(BuildContext context) {
  737. return Consumer3<DocumentProvider, ElementProvider, AnnotationProvider>(
  738. builder:
  739. (context, docProvider, elementProvider, annotationProvider, child) {
  740. final document = _documentId != null
  741. ? docProvider.getDocumentById(_documentId!)
  742. : null;
  743. final parsedText = document?.parsedText ?? _getMockParsedText();
  744. // 过滤当前文档的要素
  745. final elements = _documentId != null
  746. ? elementProvider.elements
  747. .where((e) => e.documentId == _documentId)
  748. .toList()
  749. : elementProvider.elements;
  750. // 获取当前文档的批注
  751. final annotations =
  752. annotationProvider.getAnnotationsByDocumentId(_documentId);
  753. return Column(
  754. crossAxisAlignment: CrossAxisAlignment.start,
  755. children: [
  756. _buildAIToolbar(context),
  757. const SizedBox(height: 20),
  758. // 主内容区
  759. LayoutBuilder(
  760. builder: (context, constraints) {
  761. final isWide = constraints.maxWidth > 900;
  762. return isWide
  763. ? Row(
  764. crossAxisAlignment: CrossAxisAlignment.start,
  765. children: [
  766. Expanded(
  767. child: _buildAITextEditor(
  768. context, parsedText, elements, annotations),
  769. ),
  770. const SizedBox(width: 20),
  771. SizedBox(
  772. width: 320,
  773. child: _buildAIAnnotationPanel(context, elements,
  774. annotations, annotationProvider),
  775. ),
  776. ],
  777. )
  778. : Column(
  779. children: [
  780. _buildAITextEditor(
  781. context, parsedText, elements, annotations),
  782. const SizedBox(height: 20),
  783. _buildAIAnnotationPanel(context, elements,
  784. annotations, annotationProvider),
  785. ],
  786. );
  787. },
  788. ),
  789. ],
  790. );
  791. },
  792. );
  793. }
  794. Widget _buildAIToolbar(BuildContext context) {
  795. return Container(
  796. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
  797. decoration: BoxDecoration(
  798. color: Colors.white,
  799. borderRadius: BorderRadius.circular(12),
  800. border: Border.all(color: AppColors.border),
  801. boxShadow: [
  802. BoxShadow(
  803. color: Colors.black.withOpacity(0.04),
  804. blurRadius: 8,
  805. offset: const Offset(0, 2),
  806. ),
  807. ],
  808. ),
  809. child: Row(
  810. children: [
  811. _buildAIToolbarButton(
  812. icon: LucideIcons.highlighter,
  813. label: '要素高亮',
  814. isActive: _selectedMode == 'highlight',
  815. onTap: () => setState(() => _selectedMode = 'highlight'),
  816. ),
  817. const SizedBox(width: 8),
  818. _buildAIToolbarButton(
  819. icon: LucideIcons.sparkles,
  820. label: 'AI润色',
  821. isActive: _selectedMode == 'polish',
  822. onTap: () => setState(() => _selectedMode = 'polish'),
  823. ),
  824. const SizedBox(width: 8),
  825. _buildAIToolbarButton(
  826. icon: LucideIcons.circle_check,
  827. label: '错别字修正',
  828. isActive: _selectedMode == 'spellcheck',
  829. onTap: () => setState(() => _selectedMode = 'spellcheck'),
  830. ),
  831. const Spacer(),
  832. AppButton(
  833. text: '保存',
  834. type: ButtonType.primary,
  835. size: ButtonSize.small,
  836. icon: LucideIcons.save,
  837. onPressed: () {},
  838. ),
  839. ],
  840. ),
  841. );
  842. }
  843. Widget _buildAIToolbarButton({
  844. required IconData icon,
  845. required String label,
  846. required bool isActive,
  847. required VoidCallback onTap,
  848. }) {
  849. return Material(
  850. color: Colors.transparent,
  851. child: InkWell(
  852. onTap: onTap,
  853. borderRadius: BorderRadius.circular(8),
  854. child: Container(
  855. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
  856. decoration: BoxDecoration(
  857. color: isActive
  858. ? AppColors.primary.withOpacity(0.1)
  859. : Colors.transparent,
  860. borderRadius: BorderRadius.circular(8),
  861. border: isActive
  862. ? Border.all(color: AppColors.primary.withOpacity(0.3))
  863. : null,
  864. ),
  865. child: Row(
  866. mainAxisSize: MainAxisSize.min,
  867. children: [
  868. Icon(
  869. icon,
  870. size: 18,
  871. color: isActive ? AppColors.primary : AppColors.textSecondary,
  872. ),
  873. const SizedBox(width: 6),
  874. Text(
  875. label,
  876. style: TextStyle(
  877. fontSize: 13,
  878. fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
  879. color: isActive ? AppColors.primary : AppColors.textSecondary,
  880. ),
  881. ),
  882. ],
  883. ),
  884. ),
  885. ),
  886. );
  887. }
  888. Widget _buildAITextEditor(
  889. BuildContext context,
  890. String text,
  891. List<DocumentElement> elements,
  892. List<Annotation> annotations,
  893. ) {
  894. return AppCard(
  895. title: Row(
  896. children: [
  897. const Icon(LucideIcons.file_text, size: 18),
  898. const SizedBox(width: 8),
  899. const Text('文档内容'),
  900. const Spacer(),
  901. Container(
  902. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
  903. decoration: BoxDecoration(
  904. color: AppColors.success.withOpacity(0.1),
  905. borderRadius: BorderRadius.circular(6),
  906. ),
  907. child: Row(
  908. mainAxisSize: MainAxisSize.min,
  909. children: [
  910. Icon(
  911. LucideIcons.circle_check,
  912. size: 12,
  913. color: AppColors.success,
  914. ),
  915. const SizedBox(width: 4),
  916. Text(
  917. '${elements.length} 个要素已提取',
  918. style: TextStyle(
  919. fontSize: 11,
  920. fontWeight: FontWeight.w600,
  921. color: AppColors.success,
  922. ),
  923. ),
  924. ],
  925. ),
  926. ),
  927. ],
  928. ),
  929. child: Container(
  930. height: 970,
  931. padding: const EdgeInsets.all(24),
  932. decoration: BoxDecoration(
  933. gradient: LinearGradient(
  934. begin: Alignment.topCenter,
  935. end: Alignment.bottomCenter,
  936. colors: [
  937. Colors.grey.shade50,
  938. Colors.grey.shade100,
  939. ],
  940. ),
  941. borderRadius: BorderRadius.circular(12),
  942. border: Border.all(color: AppColors.border),
  943. ),
  944. child: EnhancedTextEditor(
  945. text: text,
  946. elements: elements,
  947. annotations: annotations,
  948. onTextSelected: (selectedText) {
  949. // 直接添加要素(无需确认)
  950. final trimmed = selectedText.trim();
  951. if (trimmed.isNotEmpty) {
  952. final currentDocId = _documentId ?? 'demo-default';
  953. final newElement = DocumentElement(
  954. id: DateTime.now().microsecondsSinceEpoch.toString(),
  955. type: ElementType.other,
  956. label: '自定义',
  957. value: trimmed,
  958. documentId: currentDocId,
  959. );
  960. Provider.of<ElementProvider>(context, listen: false)
  961. .addElement(newElement);
  962. }
  963. },
  964. onAnnotationAction: (annotation) {
  965. // 处理批注操作
  966. },
  967. ),
  968. ),
  969. );
  970. }
  971. // 直接添加要素逻辑已在 EnhancedTextEditor 的 onTextSelected 回调中处理
  972. Color _getElementColor(ElementType type) {
  973. switch (type) {
  974. case ElementType.amount:
  975. return AppColors.amount;
  976. case ElementType.company:
  977. return AppColors.company;
  978. case ElementType.person:
  979. return AppColors.person;
  980. case ElementType.location:
  981. return AppColors.location;
  982. case ElementType.date:
  983. return AppColors.date;
  984. case ElementType.other:
  985. return AppColors.other;
  986. }
  987. }
  988. Widget _buildAIAnnotationPanel(
  989. BuildContext context,
  990. List<DocumentElement> elements,
  991. List<Annotation> annotations,
  992. AnnotationProvider annotationProvider,
  993. ) {
  994. return Column(
  995. children: [
  996. AppCard(
  997. child: Stack(
  998. children: [
  999. // 说明文字 - 左上角
  1000. Positioned(
  1001. top: 0,
  1002. left: 0,
  1003. child: Container(
  1004. padding:
  1005. const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
  1006. decoration: BoxDecoration(
  1007. color: AppColors.primary.withOpacity(0.1),
  1008. borderRadius: const BorderRadius.only(
  1009. topLeft: Radius.circular(8),
  1010. bottomRight: Radius.circular(8),
  1011. ),
  1012. ),
  1013. child: Row(
  1014. mainAxisSize: MainAxisSize.min,
  1015. children: [
  1016. Icon(
  1017. LucideIcons.info,
  1018. size: 12,
  1019. color: AppColors.primary,
  1020. ),
  1021. const SizedBox(width: 4),
  1022. Text(
  1023. '已提取 ${elements.length} 个要素',
  1024. style: TextStyle(
  1025. fontSize: 11,
  1026. fontWeight: FontWeight.w500,
  1027. color: AppColors.primary,
  1028. ),
  1029. ),
  1030. ],
  1031. ),
  1032. ),
  1033. ),
  1034. // 要素列表
  1035. Padding(
  1036. padding: const EdgeInsets.only(top: 32),
  1037. child: elements.isEmpty
  1038. ? Padding(
  1039. padding: const EdgeInsets.all(32),
  1040. child: Column(
  1041. children: [
  1042. Icon(
  1043. LucideIcons.tag,
  1044. size: 48,
  1045. color: AppColors.textSecondary.withOpacity(0.5),
  1046. ),
  1047. const SizedBox(height: 16),
  1048. Text(
  1049. '暂无要素',
  1050. style: TextStyle(
  1051. fontSize: 14,
  1052. color: AppColors.textSecondary,
  1053. ),
  1054. ),
  1055. const SizedBox(height: 8),
  1056. Text(
  1057. '解析完成后将自动提取要素',
  1058. style: TextStyle(
  1059. fontSize: 12,
  1060. color: AppColors.textSecondary.withOpacity(0.7),
  1061. ),
  1062. ),
  1063. ],
  1064. ),
  1065. )
  1066. : Container(
  1067. constraints: const BoxConstraints(maxHeight: 500),
  1068. child: SingleChildScrollView(
  1069. child: Column(
  1070. crossAxisAlignment: CrossAxisAlignment.stretch,
  1071. children: elements.map((element) {
  1072. return _buildElementBlock(context, element);
  1073. }).toList(),
  1074. ),
  1075. ),
  1076. ),
  1077. ),
  1078. ],
  1079. ),
  1080. ),
  1081. // 批注列表
  1082. if (annotations.isNotEmpty) ...[
  1083. const SizedBox(height: 16),
  1084. AppCard(
  1085. title: Row(
  1086. children: [
  1087. const Icon(LucideIcons.message_square, size: 18),
  1088. const SizedBox(width: 8),
  1089. const Text('批注建议'),
  1090. const Spacer(),
  1091. Container(
  1092. padding: const EdgeInsets.all(4),
  1093. decoration: BoxDecoration(
  1094. color: AppColors.warning.withOpacity(0.1),
  1095. borderRadius: BorderRadius.circular(6),
  1096. ),
  1097. child: Text(
  1098. '${annotations.length}',
  1099. style: TextStyle(
  1100. fontSize: 11,
  1101. fontWeight: FontWeight.bold,
  1102. color: AppColors.warning,
  1103. ),
  1104. ),
  1105. ),
  1106. ],
  1107. ),
  1108. child: Column(
  1109. children: annotations.map((annotation) {
  1110. return _buildAnnotationBlock(
  1111. context,
  1112. annotation,
  1113. annotationProvider,
  1114. );
  1115. }).toList(),
  1116. ),
  1117. ),
  1118. ],
  1119. const SizedBox(height: 16),
  1120. AppCard(
  1121. child: Column(
  1122. children: [
  1123. AppButton(
  1124. text: '继续人工牵引',
  1125. type: ButtonType.primary,
  1126. icon: LucideIcons.git_branch,
  1127. fullWidth: true,
  1128. onPressed: () {
  1129. if (_documentId != null) {
  1130. context.push('${AppRoutes.traction}/$_documentId');
  1131. }
  1132. },
  1133. ),
  1134. const SizedBox(height: 12),
  1135. AppButton(
  1136. text: '重新提取要素',
  1137. type: ButtonType.secondary,
  1138. icon: LucideIcons.refresh_cw,
  1139. fullWidth: true,
  1140. onPressed: () {},
  1141. ),
  1142. ],
  1143. ),
  1144. ),
  1145. ],
  1146. );
  1147. }
  1148. Widget _buildAnnotationBlock(
  1149. BuildContext context,
  1150. Annotation annotation,
  1151. AnnotationProvider annotationProvider,
  1152. ) {
  1153. IconData icon;
  1154. Color color;
  1155. String typeLabel;
  1156. switch (annotation.type) {
  1157. case AnnotationType.highlight:
  1158. icon = LucideIcons.highlighter;
  1159. color = AppColors.primary;
  1160. typeLabel = '高亮';
  1161. break;
  1162. case AnnotationType.strikethrough:
  1163. icon = LucideIcons.circle_x;
  1164. color = AppColors.error;
  1165. typeLabel = '错别字';
  1166. break;
  1167. case AnnotationType.suggestion:
  1168. icon = LucideIcons.lightbulb;
  1169. color = AppColors.info;
  1170. typeLabel = 'AI建议';
  1171. break;
  1172. }
  1173. return Container(
  1174. margin: const EdgeInsets.only(bottom: 12),
  1175. padding: const EdgeInsets.all(12),
  1176. decoration: BoxDecoration(
  1177. color: color.withOpacity(0.05),
  1178. borderRadius: BorderRadius.circular(8),
  1179. border: Border.all(color: color.withOpacity(0.3)),
  1180. ),
  1181. child: Column(
  1182. crossAxisAlignment: CrossAxisAlignment.start,
  1183. children: [
  1184. Row(
  1185. children: [
  1186. Icon(icon, size: 16, color: color),
  1187. const SizedBox(width: 6),
  1188. Text(
  1189. typeLabel,
  1190. style: TextStyle(
  1191. fontSize: 12,
  1192. fontWeight: FontWeight.w600,
  1193. color: color,
  1194. ),
  1195. ),
  1196. const Spacer(),
  1197. // 操作按钮
  1198. Row(
  1199. mainAxisSize: MainAxisSize.min,
  1200. children: [
  1201. if (annotation.type == AnnotationType.strikethrough ||
  1202. annotation.type == AnnotationType.suggestion)
  1203. IconButton(
  1204. icon: const Icon(LucideIcons.check, size: 16),
  1205. color: AppColors.success,
  1206. onPressed: () {
  1207. annotationProvider.acceptAnnotation(annotation.id);
  1208. },
  1209. padding: EdgeInsets.zero,
  1210. constraints: const BoxConstraints(
  1211. minWidth: 28,
  1212. minHeight: 28,
  1213. ),
  1214. ),
  1215. IconButton(
  1216. icon: const Icon(LucideIcons.x, size: 16),
  1217. color: AppColors.error,
  1218. onPressed: () {
  1219. annotationProvider.rejectAnnotation(annotation.id);
  1220. },
  1221. padding: EdgeInsets.zero,
  1222. constraints: const BoxConstraints(
  1223. minWidth: 28,
  1224. minHeight: 28,
  1225. ),
  1226. ),
  1227. ],
  1228. ),
  1229. ],
  1230. ),
  1231. const SizedBox(height: 8),
  1232. Text(
  1233. annotation.text,
  1234. style: TextStyle(
  1235. fontSize: 13,
  1236. color: AppColors.textPrimary,
  1237. decoration: annotation.type == AnnotationType.strikethrough
  1238. ? TextDecoration.lineThrough
  1239. : null,
  1240. ),
  1241. ),
  1242. if (annotation.suggestion != null) ...[
  1243. const SizedBox(height: 6),
  1244. Container(
  1245. padding: const EdgeInsets.all(8),
  1246. decoration: BoxDecoration(
  1247. color: color.withOpacity(0.1),
  1248. borderRadius: BorderRadius.circular(6),
  1249. ),
  1250. child: Row(
  1251. children: [
  1252. Icon(LucideIcons.arrow_right, size: 14, color: color),
  1253. const SizedBox(width: 6),
  1254. Expanded(
  1255. child: Text(
  1256. annotation.suggestion!,
  1257. style: TextStyle(
  1258. fontSize: 12,
  1259. color: color,
  1260. fontWeight: FontWeight.w500,
  1261. ),
  1262. ),
  1263. ),
  1264. ],
  1265. ),
  1266. ),
  1267. ],
  1268. ],
  1269. ),
  1270. );
  1271. }
  1272. Widget _buildAIActionButtons(BuildContext context) {
  1273. return Row(
  1274. children: [
  1275. AppButton(
  1276. text: '继续人工牵引',
  1277. type: ButtonType.primary,
  1278. icon: LucideIcons.git_branch,
  1279. onPressed: () {
  1280. if (_documentId != null) {
  1281. context.push('${AppRoutes.traction}/$_documentId');
  1282. }
  1283. },
  1284. ),
  1285. const SizedBox(width: 12),
  1286. AppButton(
  1287. text: '返回对比',
  1288. type: ButtonType.secondary,
  1289. icon: LucideIcons.arrow_left,
  1290. onPressed: () {
  1291. setState(() {
  1292. _currentStep = 2;
  1293. });
  1294. },
  1295. ),
  1296. const SizedBox(width: 12),
  1297. AppButton(
  1298. text: '保存',
  1299. type: ButtonType.secondary,
  1300. icon: LucideIcons.save,
  1301. onPressed: () {},
  1302. ),
  1303. ],
  1304. );
  1305. }
  1306. Widget _buildFileInfo() {
  1307. return Container(
  1308. padding: const EdgeInsets.all(20),
  1309. decoration: BoxDecoration(
  1310. gradient: LinearGradient(
  1311. colors: [
  1312. AppColors.primary.withOpacity(0.05),
  1313. Colors.white,
  1314. ],
  1315. ),
  1316. borderRadius: BorderRadius.circular(12),
  1317. border: Border.all(color: AppColors.primary.withOpacity(0.2)),
  1318. ),
  1319. child: Row(
  1320. children: [
  1321. Container(
  1322. padding: const EdgeInsets.all(12),
  1323. decoration: BoxDecoration(
  1324. color: AppColors.primary.withOpacity(0.1),
  1325. borderRadius: BorderRadius.circular(10),
  1326. ),
  1327. child: const Icon(
  1328. LucideIcons.file_text,
  1329. color: AppColors.primary,
  1330. size: 24,
  1331. ),
  1332. ),
  1333. const SizedBox(width: 16),
  1334. Expanded(
  1335. child: Column(
  1336. crossAxisAlignment: CrossAxisAlignment.start,
  1337. children: [
  1338. Text(
  1339. _fileName ?? '未知文件',
  1340. style: const TextStyle(
  1341. fontSize: 15,
  1342. fontWeight: FontWeight.w600,
  1343. color: AppColors.textPrimary,
  1344. ),
  1345. maxLines: 1,
  1346. overflow: TextOverflow.ellipsis,
  1347. ),
  1348. const SizedBox(height: 4),
  1349. Row(
  1350. children: [
  1351. Icon(
  1352. LucideIcons.hard_drive,
  1353. size: 14,
  1354. color: AppColors.textSecondary,
  1355. ),
  1356. const SizedBox(width: 4),
  1357. Text(
  1358. '2.0 MB',
  1359. style: TextStyle(
  1360. fontSize: 12,
  1361. color: AppColors.textSecondary,
  1362. ),
  1363. ),
  1364. const SizedBox(width: 16),
  1365. Icon(
  1366. LucideIcons.clock,
  1367. size: 14,
  1368. color: AppColors.textSecondary,
  1369. ),
  1370. const SizedBox(width: 4),
  1371. Text(
  1372. '刚刚上传',
  1373. style: TextStyle(
  1374. fontSize: 12,
  1375. color: AppColors.textSecondary,
  1376. ),
  1377. ),
  1378. ],
  1379. ),
  1380. ],
  1381. ),
  1382. ),
  1383. IconButton(
  1384. icon: const Icon(LucideIcons.x),
  1385. onPressed: _clearFile,
  1386. color: AppColors.textSecondary,
  1387. ),
  1388. ],
  1389. ),
  1390. );
  1391. }
  1392. void _handleUpload(List<File> files) {
  1393. setState(() {
  1394. _hasFile = true;
  1395. _fileName =
  1396. files.isNotEmpty ? files.first.path.split('/').last : '示例文档.pdf';
  1397. });
  1398. }
  1399. void _clearFile() {
  1400. setState(() {
  1401. _hasFile = false;
  1402. _fileName = null;
  1403. _uploadProgress = 0.0;
  1404. _currentStep = 0;
  1405. _documentId = null;
  1406. });
  1407. }
  1408. Future<void> _startParsing() async {
  1409. setState(() {
  1410. _isUploading = true;
  1411. _currentStep = 1;
  1412. _uploadProgress = 0.0;
  1413. });
  1414. // 模拟解析过程
  1415. for (int i = 0; i <= 100; i += 10) {
  1416. await Future.delayed(const Duration(milliseconds: 50));
  1417. if (mounted) {
  1418. setState(() {
  1419. _uploadProgress = i / 100;
  1420. });
  1421. }
  1422. }
  1423. // 解析完成后,创建文档并显示对比视图
  1424. if (mounted) {
  1425. final docProvider = Provider.of<DocumentProvider>(context, listen: false);
  1426. final elementProvider =
  1427. Provider.of<ElementProvider>(context, listen: false);
  1428. // 创建新文档
  1429. final newDocument = Document(
  1430. id: DateTime.now().millisecondsSinceEpoch.toString(),
  1431. name: _fileName ?? '上传的文档.pdf',
  1432. type: DocumentType.pdf,
  1433. status: DocumentStatus.completed,
  1434. createdAt: DateTime.now(),
  1435. updatedAt: DateTime.now(),
  1436. fileSize: 2048000,
  1437. parsedText: _getMockParsedText(),
  1438. );
  1439. // 添加到文档列表
  1440. docProvider.addDocument(newDocument);
  1441. // 自动提取要素
  1442. final parsedText = _getMockParsedText();
  1443. final extractedElements = _extractElements(parsedText, newDocument.id);
  1444. // 用新要素替换
  1445. elementProvider.setElements(extractedElements);
  1446. // 加载批注
  1447. final annotationProvider =
  1448. Provider.of<AnnotationProvider>(context, listen: false);
  1449. annotationProvider.loadAnnotations(documentId: newDocument.id);
  1450. // 切换到对比视图
  1451. setState(() {
  1452. _isUploading = false;
  1453. _currentStep = 2;
  1454. _documentId = newDocument.id;
  1455. });
  1456. }
  1457. }
  1458. String _getMockParsedText() {
  1459. return '''一、项目概述
  1460. 本项目由腾讯科技有限公司(以下简称"腾讯")投资建设,旨在打造一套面向大型企业财务共享中心的"智能票据处理系统"。项目负责人为张明,项目地点位于北京市海淀区中关村科技园。系统通过OCR识别、版面理解、要素抽取与人机协同核验,提升发票、合同、报销单据等票据类文档的处理效率与准确率。
  1461. 二、投资与资金安排
  1462. 项目总投资金额为人民币3,700万元,其中已支付1,000万元用于样机研制、数据治理与前期试点。项目启动日期为2024年1月15日,预计完成时间为2024年12月31日。剩余资金将依据阶段性里程碑进行分批拨付,涵盖核心算法优化、系统平台化改造、以及与客户生态的对接集成。
  1463. 三、技术与产品路线
  1464. 系统采用多模态文档理解技术,将版面结构、语义上下文与业务词典结合,实现对复杂票据的鲁棒解析。产品形态包含:上传解析端、人工牵引标注端、规则与关系构建端,以及结果交付端。技术团队由李华、王强等核心成员组成,办公地点位于上海市浦东新区。
  1465. 四、收益预期
  1466. 预计上线后,票据处理效率提升60%以上,人工核验工作量下降40%,并显著降低AI"幻觉"导致的错判风险,从而提升财务数据的可靠性与可审计性。项目涉及的其他关键信息包括:合同编号CT-2024-001、项目代码PRJ-2024-001等。''';
  1467. }
  1468. /// 从文本中提取要素
  1469. List<DocumentElement> _extractElements(String text, String documentId) {
  1470. final elements = <DocumentElement>[];
  1471. int elementIndex = 1;
  1472. // 1. 提取公司名称(company)
  1473. final companies = [
  1474. ('腾讯科技有限公司', '公司'),
  1475. ];
  1476. for (final (value, label) in companies) {
  1477. if (text.contains(value)) {
  1478. elements.add(DocumentElement(
  1479. id: 'e${elementIndex++}',
  1480. type: ElementType.company,
  1481. label: label,
  1482. value: value,
  1483. documentId: documentId,
  1484. ));
  1485. }
  1486. }
  1487. // 2. 提取金额(amount)
  1488. final amounts = [
  1489. ('3700万', '总投资'),
  1490. ('1000万', '已支付'),
  1491. ('3,700万元', '总投资金额'),
  1492. ];
  1493. for (final (value, label) in amounts) {
  1494. if (text.contains(value) && !elements.any((e) => e.value == value)) {
  1495. elements.add(DocumentElement(
  1496. id: 'e${elementIndex++}',
  1497. type: ElementType.amount,
  1498. label: label,
  1499. value: value,
  1500. documentId: documentId,
  1501. ));
  1502. }
  1503. }
  1504. // 3. 提取人名(person)
  1505. final persons = [
  1506. ('张明', '项目负责人'),
  1507. ('李华', '技术团队成员'),
  1508. ('王强', '技术团队成员'),
  1509. ];
  1510. for (final (value, label) in persons) {
  1511. if (text.contains(value)) {
  1512. elements.add(DocumentElement(
  1513. id: 'e${elementIndex++}',
  1514. type: ElementType.person,
  1515. label: label,
  1516. value: value,
  1517. documentId: documentId,
  1518. ));
  1519. }
  1520. }
  1521. // 4. 提取地名(location)
  1522. final locations = [
  1523. ('北京市海淀区中关村科技园', '项目地点'),
  1524. ('上海市浦东新区', '办公地点'),
  1525. ];
  1526. for (final (value, label) in locations) {
  1527. if (text.contains(value)) {
  1528. elements.add(DocumentElement(
  1529. id: 'e${elementIndex++}',
  1530. type: ElementType.location,
  1531. label: label,
  1532. value: value,
  1533. documentId: documentId,
  1534. ));
  1535. }
  1536. }
  1537. // 5. 提取日期(date)
  1538. final dates = [
  1539. ('2024年1月15日', '项目启动日期'),
  1540. ('2024年12月31日', '预计完成时间'),
  1541. ];
  1542. for (final (value, label) in dates) {
  1543. if (text.contains(value)) {
  1544. elements.add(DocumentElement(
  1545. id: 'e${elementIndex++}',
  1546. type: ElementType.date,
  1547. label: label,
  1548. value: value,
  1549. documentId: documentId,
  1550. ));
  1551. }
  1552. }
  1553. // 6. 提取其他信息(other)
  1554. final others = [
  1555. ('CT-2024-001', '合同编号'),
  1556. ('PRJ-2024-001', '项目代码'),
  1557. ('智能票据处理系统', '系统名称'),
  1558. ];
  1559. for (final (value, label) in others) {
  1560. if (text.contains(value)) {
  1561. elements.add(DocumentElement(
  1562. id: 'e${elementIndex++}',
  1563. type: ElementType.other,
  1564. label: label,
  1565. value: value,
  1566. documentId: documentId,
  1567. ));
  1568. }
  1569. }
  1570. return elements;
  1571. }
  1572. /// 构建要素文本块
  1573. Widget _buildElementBlock(BuildContext context, DocumentElement element) {
  1574. final color = _getElementColor(element.type);
  1575. return Container(
  1576. margin: const EdgeInsets.only(bottom: 12),
  1577. padding: const EdgeInsets.all(16),
  1578. decoration: BoxDecoration(
  1579. color: color.withOpacity(0.05),
  1580. borderRadius: BorderRadius.circular(12),
  1581. border: Border.all(
  1582. color: color.withOpacity(0.3),
  1583. width: 1.5,
  1584. ),
  1585. ),
  1586. child: Row(
  1587. crossAxisAlignment: CrossAxisAlignment.start,
  1588. children: [
  1589. // 类型图标
  1590. Container(
  1591. width: 36,
  1592. height: 36,
  1593. decoration: BoxDecoration(
  1594. color: color.withOpacity(0.2),
  1595. borderRadius: BorderRadius.circular(8),
  1596. ),
  1597. child: Icon(
  1598. _getElementIcon(element.type),
  1599. size: 20,
  1600. color: color,
  1601. ),
  1602. ),
  1603. const SizedBox(width: 12),
  1604. // 要素内容
  1605. Expanded(
  1606. child: Column(
  1607. crossAxisAlignment: CrossAxisAlignment.start,
  1608. children: [
  1609. // 标签
  1610. Container(
  1611. padding:
  1612. const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
  1613. decoration: BoxDecoration(
  1614. color: color.withOpacity(0.15),
  1615. borderRadius: BorderRadius.circular(4),
  1616. ),
  1617. child: Text(
  1618. element.label,
  1619. style: TextStyle(
  1620. fontSize: 11,
  1621. fontWeight: FontWeight.w600,
  1622. color: color,
  1623. ),
  1624. ),
  1625. ),
  1626. const SizedBox(height: 8),
  1627. // 值
  1628. Text(
  1629. element.value,
  1630. style: TextStyle(
  1631. fontSize: 15,
  1632. fontWeight: FontWeight.w600,
  1633. color: AppColors.textPrimary,
  1634. ),
  1635. ),
  1636. ],
  1637. ),
  1638. ),
  1639. // 删除按钮
  1640. IconButton(
  1641. icon: Icon(
  1642. LucideIcons.x,
  1643. size: 18,
  1644. color: AppColors.textSecondary,
  1645. ),
  1646. onPressed: () {
  1647. Provider.of<ElementProvider>(context, listen: false)
  1648. .removeElement(element.id);
  1649. },
  1650. padding: EdgeInsets.zero,
  1651. constraints: const BoxConstraints(
  1652. minWidth: 32,
  1653. minHeight: 32,
  1654. ),
  1655. ),
  1656. ],
  1657. ),
  1658. );
  1659. }
  1660. /// 获取要素类型图标
  1661. IconData _getElementIcon(ElementType type) {
  1662. switch (type) {
  1663. case ElementType.amount:
  1664. return LucideIcons.dollar_sign;
  1665. case ElementType.company:
  1666. return LucideIcons.building;
  1667. case ElementType.person:
  1668. return LucideIcons.user;
  1669. case ElementType.location:
  1670. return LucideIcons.map_pin;
  1671. case ElementType.date:
  1672. return LucideIcons.calendar;
  1673. default:
  1674. return LucideIcons.tag;
  1675. }
  1676. }
  1677. }