parse_compare_page.dart 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  1. import 'package:flutter/material.dart';
  2. import 'package:go_router/go_router.dart';
  3. import 'package:provider/provider.dart';
  4. import 'package:flutter_lucide/flutter_lucide.dart';
  5. import '../widgets/layout/app_layout.dart';
  6. import '../widgets/common/app_button.dart';
  7. import '../widgets/common/app_card.dart';
  8. import '../providers/document_provider.dart';
  9. import '../utils/constants.dart';
  10. import '../theme/app_colors.dart';
  11. /// 解析对比页
  12. class ParseComparePage extends StatefulWidget {
  13. final String documentId;
  14. const ParseComparePage({Key? key, required this.documentId})
  15. : super(key: key);
  16. @override
  17. State<ParseComparePage> createState() => _ParseComparePageState();
  18. }
  19. class _ParseComparePageState extends State<ParseComparePage> {
  20. final ScrollController _leftScrollController = ScrollController();
  21. final ScrollController _rightScrollController = ScrollController();
  22. bool _syncScroll = true;
  23. double _leftZoom = 1.0;
  24. double _rightZoom = 1.0;
  25. @override
  26. void initState() {
  27. super.initState();
  28. _leftScrollController.addListener(_onLeftScroll);
  29. _rightScrollController.addListener(_onRightScroll);
  30. }
  31. @override
  32. void dispose() {
  33. _leftScrollController.dispose();
  34. _rightScrollController.dispose();
  35. super.dispose();
  36. }
  37. void _onLeftScroll() {
  38. if (_syncScroll && _leftScrollController.hasClients) {
  39. final ratio = _rightScrollController.position.maxScrollExtent /
  40. _leftScrollController.position.maxScrollExtent;
  41. if (_rightScrollController.hasClients) {
  42. _rightScrollController.jumpTo(
  43. _leftScrollController.offset * ratio,
  44. );
  45. }
  46. }
  47. }
  48. void _onRightScroll() {
  49. if (_syncScroll && _rightScrollController.hasClients) {
  50. final ratio = _leftScrollController.position.maxScrollExtent /
  51. _rightScrollController.position.maxScrollExtent;
  52. if (_leftScrollController.hasClients) {
  53. _leftScrollController.jumpTo(
  54. _rightScrollController.offset * ratio,
  55. );
  56. }
  57. }
  58. }
  59. @override
  60. Widget build(BuildContext context) {
  61. return Consumer<DocumentProvider>(
  62. builder: (context, provider, child) {
  63. final document = provider.getDocumentById(widget.documentId);
  64. final parsedText = document?.parsedText ?? _getMockParsedText();
  65. return AppLayout(
  66. maxContentWidth: 1600,
  67. child: SingleChildScrollView(
  68. child: Column(
  69. mainAxisSize: MainAxisSize.min,
  70. crossAxisAlignment: CrossAxisAlignment.start,
  71. children: [
  72. const SizedBox(height: 24),
  73. // 标题栏
  74. _buildHeader(context, document),
  75. const SizedBox(height: 32),
  76. // 工具栏
  77. _buildToolbar(context),
  78. const SizedBox(height: 24),
  79. // 对比视图
  80. _buildComparisonView(context, parsedText),
  81. const SizedBox(height: 32),
  82. // 操作按钮
  83. _buildActionButtons(context),
  84. ],
  85. ),
  86. ),
  87. );
  88. },
  89. );
  90. }
  91. Widget _buildHeader(BuildContext context, document) {
  92. return Container(
  93. padding: const EdgeInsets.all(20),
  94. decoration: BoxDecoration(
  95. color: Colors.white,
  96. borderRadius: BorderRadius.circular(16),
  97. border: Border.all(color: AppColors.border),
  98. boxShadow: [
  99. BoxShadow(
  100. color: Colors.black.withOpacity(0.04),
  101. blurRadius: 12,
  102. offset: const Offset(0, 4),
  103. ),
  104. ],
  105. ),
  106. child: Row(
  107. children: [
  108. IconButton(
  109. icon: const Icon(Icons.arrow_back),
  110. onPressed: () => context.pop(),
  111. style: IconButton.styleFrom(
  112. backgroundColor: AppColors.backgroundLight,
  113. padding: const EdgeInsets.all(12),
  114. ),
  115. ),
  116. const SizedBox(width: 16),
  117. Expanded(
  118. child: Column(
  119. crossAxisAlignment: CrossAxisAlignment.start,
  120. children: [
  121. Text(
  122. document?.name ?? '文档解析结果',
  123. style: const TextStyle(
  124. fontSize: 22,
  125. fontWeight: FontWeight.bold,
  126. color: AppColors.textPrimary,
  127. ),
  128. ),
  129. const SizedBox(height: 4),
  130. Row(
  131. children: [
  132. Container(
  133. padding: const EdgeInsets.symmetric(
  134. horizontal: 8,
  135. vertical: 4,
  136. ),
  137. decoration: BoxDecoration(
  138. color: AppColors.success.withOpacity(0.1),
  139. borderRadius: BorderRadius.circular(6),
  140. ),
  141. child: Row(
  142. mainAxisSize: MainAxisSize.min,
  143. children: [
  144. Icon(
  145. LucideIcons.circle_check,
  146. size: 12,
  147. color: AppColors.success,
  148. ),
  149. const SizedBox(width: 4),
  150. Text(
  151. '解析完成',
  152. style: TextStyle(
  153. fontSize: 12,
  154. fontWeight: FontWeight.w500,
  155. color: AppColors.success,
  156. ),
  157. ),
  158. ],
  159. ),
  160. ),
  161. const SizedBox(width: 12),
  162. Text(
  163. '${document?.formattedFileSize ?? "2.0 MB"} · ${_formatDate(document?.createdAt ?? DateTime.now())}',
  164. style: TextStyle(
  165. fontSize: 12,
  166. color: AppColors.textSecondary,
  167. ),
  168. ),
  169. ],
  170. ),
  171. ],
  172. ),
  173. ),
  174. AppButton(
  175. text: '导出',
  176. type: ButtonType.secondary,
  177. size: ButtonSize.small,
  178. icon: LucideIcons.download,
  179. onPressed: () {},
  180. ),
  181. ],
  182. ),
  183. );
  184. }
  185. Widget _buildToolbar(BuildContext context) {
  186. return Container(
  187. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
  188. decoration: BoxDecoration(
  189. color: Colors.white,
  190. borderRadius: BorderRadius.circular(12),
  191. border: Border.all(color: AppColors.border),
  192. ),
  193. child: Row(
  194. children: [
  195. IconButton(
  196. icon: Icon(
  197. _syncScroll ? LucideIcons.link : LucideIcons.unlink,
  198. size: 18,
  199. ),
  200. tooltip: _syncScroll ? '取消同步滚动' : '同步滚动',
  201. onPressed: () {
  202. setState(() {
  203. _syncScroll = !_syncScroll;
  204. });
  205. },
  206. style: IconButton.styleFrom(
  207. backgroundColor: _syncScroll
  208. ? AppColors.primary.withOpacity(0.1)
  209. : Colors.transparent,
  210. ),
  211. ),
  212. const SizedBox(width: 8),
  213. const VerticalDivider(width: 1),
  214. const SizedBox(width: 8),
  215. IconButton(
  216. icon: const Icon(LucideIcons.zoom_in, size: 18),
  217. tooltip: '放大',
  218. onPressed: () {
  219. setState(() {
  220. _leftZoom = (_leftZoom + 0.1).clamp(0.5, 2.0);
  221. _rightZoom = (_rightZoom + 0.1).clamp(0.5, 2.0);
  222. });
  223. },
  224. ),
  225. IconButton(
  226. icon: const Icon(LucideIcons.zoom_out, size: 18),
  227. tooltip: '缩小',
  228. onPressed: () {
  229. setState(() {
  230. _leftZoom = (_leftZoom - 0.1).clamp(0.5, 2.0);
  231. _rightZoom = (_rightZoom - 0.1).clamp(0.5, 2.0);
  232. });
  233. },
  234. ),
  235. IconButton(
  236. icon: const Icon(LucideIcons.rotate_cw, size: 18),
  237. tooltip: '重置',
  238. onPressed: () {
  239. setState(() {
  240. _leftZoom = 1.0;
  241. _rightZoom = 1.0;
  242. });
  243. },
  244. ),
  245. const Spacer(),
  246. Text(
  247. '${(_leftZoom * 100).toInt()}%',
  248. style: TextStyle(
  249. fontSize: 12,
  250. color: AppColors.textSecondary,
  251. fontWeight: FontWeight.w500,
  252. ),
  253. ),
  254. ],
  255. ),
  256. );
  257. }
  258. Widget _buildComparisonView(BuildContext context, String parsedText) {
  259. final isWide =
  260. MediaQuery.of(context).size.width > AppConstants.tabletBreakpoint;
  261. if (isWide) {
  262. return Row(
  263. crossAxisAlignment: CrossAxisAlignment.start,
  264. children: [
  265. Expanded(
  266. child: _buildOriginalDocument(context),
  267. ),
  268. const SizedBox(width: 20),
  269. Expanded(
  270. child: _buildParsedText(context, parsedText),
  271. ),
  272. ],
  273. );
  274. } else {
  275. return Column(
  276. children: [
  277. _buildOriginalDocument(context),
  278. const SizedBox(height: 16),
  279. _buildParsedText(context, parsedText),
  280. ],
  281. );
  282. }
  283. }
  284. Widget _buildOriginalDocument(BuildContext context) {
  285. return AppCard(
  286. title: Row(
  287. children: [
  288. const Icon(LucideIcons.file_text,
  289. size: 18, color: AppColors.textPrimary),
  290. const SizedBox(width: 8),
  291. const Text('原始文档'),
  292. const Spacer(),
  293. Container(
  294. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
  295. decoration: BoxDecoration(
  296. color: AppColors.background,
  297. borderRadius: BorderRadius.circular(6),
  298. ),
  299. child: Text(
  300. 'PDF',
  301. style: TextStyle(
  302. fontSize: 11,
  303. fontWeight: FontWeight.w600,
  304. color: AppColors.textSecondary,
  305. ),
  306. ),
  307. ),
  308. ],
  309. ),
  310. child: Container(
  311. height: 700,
  312. decoration: BoxDecoration(
  313. gradient: LinearGradient(
  314. begin: Alignment.topCenter,
  315. end: Alignment.bottomCenter,
  316. colors: [
  317. Colors.grey.shade50,
  318. Colors.grey.shade100,
  319. ],
  320. ),
  321. borderRadius: BorderRadius.circular(12),
  322. border: Border.all(color: AppColors.border),
  323. ),
  324. child: ClipRRect(
  325. borderRadius: BorderRadius.circular(12),
  326. child: Transform.scale(
  327. scale: _leftZoom,
  328. child: SingleChildScrollView(
  329. controller: _leftScrollController,
  330. child: Container(
  331. padding: const EdgeInsets.all(40),
  332. child: Column(
  333. children: [
  334. // 模拟PDF页面
  335. _buildMockPDFPage(),
  336. const SizedBox(height: 20),
  337. _buildMockPDFPage(),
  338. ],
  339. ),
  340. ),
  341. ),
  342. ),
  343. ),
  344. ),
  345. );
  346. }
  347. Widget _buildMockPDFPage() {
  348. return Container(
  349. width: double.infinity,
  350. padding: const EdgeInsets.all(40),
  351. decoration: BoxDecoration(
  352. color: Colors.white,
  353. borderRadius: BorderRadius.circular(8),
  354. boxShadow: [
  355. BoxShadow(
  356. color: Colors.black.withOpacity(0.1),
  357. blurRadius: 10,
  358. offset: const Offset(0, 4),
  359. ),
  360. ],
  361. ),
  362. child: Column(
  363. crossAxisAlignment: CrossAxisAlignment.start,
  364. children: [
  365. // 模拟PDF文本行
  366. ...List.generate(20, (index) {
  367. return Container(
  368. margin: const EdgeInsets.only(bottom: 8),
  369. height: 16,
  370. width: double.infinity,
  371. decoration: BoxDecoration(
  372. color: Colors.grey.shade300,
  373. borderRadius: BorderRadius.circular(4),
  374. ),
  375. );
  376. }),
  377. ],
  378. ),
  379. );
  380. }
  381. Widget _buildParsedText(BuildContext context, String parsedText) {
  382. return AppCard(
  383. title: Row(
  384. children: [
  385. Container(
  386. padding: const EdgeInsets.all(6),
  387. decoration: BoxDecoration(
  388. gradient: LinearGradient(
  389. colors: [AppColors.primary, AppColors.primaryLight],
  390. ),
  391. borderRadius: BorderRadius.circular(6),
  392. ),
  393. child: const Icon(
  394. LucideIcons.sparkles,
  395. size: 14,
  396. color: Colors.white,
  397. ),
  398. ),
  399. const SizedBox(width: 8),
  400. const Text('智能解析结果'),
  401. const Spacer(),
  402. Container(
  403. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
  404. decoration: BoxDecoration(
  405. color: AppColors.success.withOpacity(0.1),
  406. borderRadius: BorderRadius.circular(6),
  407. ),
  408. child: Row(
  409. mainAxisSize: MainAxisSize.min,
  410. children: [
  411. Icon(
  412. LucideIcons.circle_check,
  413. size: 12,
  414. color: AppColors.success,
  415. ),
  416. const SizedBox(width: 4),
  417. Text(
  418. '可编辑',
  419. style: TextStyle(
  420. fontSize: 11,
  421. fontWeight: FontWeight.w600,
  422. color: AppColors.success,
  423. ),
  424. ),
  425. ],
  426. ),
  427. ),
  428. ],
  429. ),
  430. child: Container(
  431. height: 700,
  432. decoration: BoxDecoration(
  433. color: Colors.white,
  434. borderRadius: BorderRadius.circular(12),
  435. border: Border.all(color: AppColors.border),
  436. ),
  437. child: Transform.scale(
  438. scale: _rightZoom,
  439. child: SingleChildScrollView(
  440. controller: _rightScrollController,
  441. padding: const EdgeInsets.all(24),
  442. child: SelectableText.rich(
  443. TextSpan(
  444. children: _buildFormattedText(parsedText),
  445. style: const TextStyle(
  446. fontSize: 14,
  447. height: 1.8,
  448. color: AppColors.textPrimary,
  449. ),
  450. ),
  451. ),
  452. ),
  453. ),
  454. ),
  455. );
  456. }
  457. List<TextSpan> _buildFormattedText(String text) {
  458. final lines = text.split('\n');
  459. final spans = <TextSpan>[];
  460. for (int i = 0; i < lines.length; i++) {
  461. final line = lines[i];
  462. if (line.trim().isEmpty) {
  463. spans.add(const TextSpan(text: '\n'));
  464. continue;
  465. }
  466. // 检测标题(以数字开头或包含"一、二、三"等)
  467. if (RegExp(r'^[一二三四五六七八九十]+、').hasMatch(line) ||
  468. RegExp(r'^\d+[\.、]').hasMatch(line)) {
  469. spans.add(
  470. TextSpan(
  471. text: line,
  472. style: const TextStyle(
  473. fontSize: 16,
  474. fontWeight: FontWeight.bold,
  475. color: AppColors.primary,
  476. ),
  477. ),
  478. );
  479. } else {
  480. spans.add(TextSpan(text: line));
  481. }
  482. if (i < lines.length - 1) {
  483. spans.add(const TextSpan(text: '\n'));
  484. }
  485. }
  486. return spans;
  487. }
  488. Widget _buildActionButtons(BuildContext context) {
  489. return Row(
  490. children: [
  491. AppButton(
  492. text: '继续AI处理',
  493. type: ButtonType.primary,
  494. icon: LucideIcons.sparkles,
  495. onPressed: () {
  496. context.push('${AppRoutes.process}/${widget.documentId}');
  497. },
  498. ),
  499. const SizedBox(width: 12),
  500. AppButton(
  501. text: '重新解析',
  502. type: ButtonType.secondary,
  503. icon: LucideIcons.refresh_cw,
  504. onPressed: () {},
  505. ),
  506. const SizedBox(width: 12),
  507. AppButton(
  508. text: '导出结果',
  509. type: ButtonType.secondary,
  510. icon: LucideIcons.download,
  511. onPressed: () {},
  512. ),
  513. ],
  514. );
  515. }
  516. String _formatDate(DateTime date) {
  517. final now = DateTime.now();
  518. final diff = now.difference(date);
  519. if (diff.inDays > 7) {
  520. return '${date.month}/${date.day}';
  521. } else if (diff.inDays > 0) {
  522. return '${diff.inDays}天前';
  523. } else if (diff.inHours > 0) {
  524. return '${diff.inHours}小时前';
  525. } else {
  526. return '刚刚';
  527. }
  528. }
  529. String _getMockParsedText() {
  530. return '''一、项目概述
  531. 本项目由腾讯科技有限公司(以下简称"腾讯")投资建设,旨在打造一套面向大型企业财务共享中心的"智能票据处理系统"。系统通过OCR识别、版面理解、要素抽取与人机协同核验,提升发票、合同、报销单据等票据类文档的处理效率与准确率。
  532. 二、投资与资金安排
  533. 项目总投资金额为人民币3,700万元,其中已支付1,000万元用于样机研制、数据治理与前期试点。剩余资金将依据阶段性里程碑进行分批拨付,涵盖核心算法优化、系统平台化改造、以及与客户生态的对接集成。
  534. 三、技术与产品路线
  535. 系统采用多模态文档理解技术,将版面结构、语义上下文与业务词典结合,实现对复杂票据的鲁棒解析。产品形态包含:上传解析端、人工牵引标注端、规则与关系构建端,以及结果交付端。
  536. 四、收益预期
  537. 预计上线后,票据处理效率提升60%以上,人工核验工作量下降40%,并显著降低AI"幻觉"导致的错判风险,从而提升财务数据的可靠性与可审计性。''';
  538. }
  539. }