ai_process_page.dart 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  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 '../widgets/common/app_tag.dart';
  9. import '../providers/document_provider.dart';
  10. import '../providers/element_provider.dart';
  11. import '../models/element.dart';
  12. import '../utils/constants.dart';
  13. import '../theme/app_colors.dart';
  14. /// AI处理页
  15. class AIProcessPage extends StatefulWidget {
  16. final String documentId;
  17. const AIProcessPage({Key? key, required this.documentId}) : super(key: key);
  18. @override
  19. State<AIProcessPage> createState() => _AIProcessPageState();
  20. }
  21. class _AIProcessPageState extends State<AIProcessPage> {
  22. String _selectedMode = 'highlight'; // highlight, polish, spellcheck
  23. @override
  24. Widget build(BuildContext context) {
  25. return Consumer2<DocumentProvider, ElementProvider>(
  26. builder: (context, docProvider, elementProvider, child) {
  27. final document = docProvider.getDocumentById(widget.documentId);
  28. final parsedText = document?.parsedText ?? _getMockText();
  29. final elements = elementProvider.elements;
  30. return AppLayout(
  31. maxContentWidth: 1600,
  32. child: SingleChildScrollView(
  33. child: Column(
  34. mainAxisSize: MainAxisSize.min,
  35. crossAxisAlignment: CrossAxisAlignment.start,
  36. children: [
  37. const SizedBox(height: 24),
  38. // 标题栏
  39. _buildHeader(context, document),
  40. const SizedBox(height: 32),
  41. // 工具栏
  42. _buildToolbar(context),
  43. const SizedBox(height: 24),
  44. // 主内容区
  45. LayoutBuilder(
  46. builder: (context, constraints) {
  47. final isWide = constraints.maxWidth > 900;
  48. return isWide
  49. ? Row(
  50. crossAxisAlignment: CrossAxisAlignment.start,
  51. children: [
  52. Expanded(
  53. flex: 2,
  54. child: _buildTextEditor(
  55. context,
  56. parsedText,
  57. elements,
  58. ),
  59. ),
  60. const SizedBox(width: 20),
  61. SizedBox(
  62. width: 320,
  63. child: _buildAnnotationPanel(
  64. context,
  65. elements,
  66. ),
  67. ),
  68. ],
  69. )
  70. : Column(
  71. children: [
  72. _buildTextEditor(
  73. context,
  74. parsedText,
  75. elements,
  76. ),
  77. const SizedBox(height: 20),
  78. _buildAnnotationPanel(
  79. context,
  80. elements,
  81. ),
  82. ],
  83. );
  84. },
  85. ),
  86. ],
  87. ),
  88. ),
  89. );
  90. },
  91. );
  92. }
  93. Widget _buildHeader(BuildContext context, document) {
  94. return Container(
  95. padding: const EdgeInsets.all(20),
  96. decoration: BoxDecoration(
  97. color: Colors.white,
  98. borderRadius: BorderRadius.circular(16),
  99. border: Border.all(color: AppColors.border),
  100. boxShadow: [
  101. BoxShadow(
  102. color: Colors.black.withOpacity(0.04),
  103. blurRadius: 12,
  104. offset: const Offset(0, 4),
  105. ),
  106. ],
  107. ),
  108. child: Row(
  109. children: [
  110. IconButton(
  111. icon: const Icon(Icons.arrow_back),
  112. onPressed: () => context.pop(),
  113. style: IconButton.styleFrom(
  114. backgroundColor: AppColors.backgroundLight,
  115. padding: const EdgeInsets.all(12),
  116. ),
  117. ),
  118. const SizedBox(width: 16),
  119. Expanded(
  120. child: Column(
  121. crossAxisAlignment: CrossAxisAlignment.start,
  122. children: [
  123. Row(
  124. children: [
  125. Container(
  126. padding: const EdgeInsets.all(8),
  127. decoration: BoxDecoration(
  128. gradient: LinearGradient(
  129. colors: [Colors.purple, Colors.purple.shade300],
  130. ),
  131. borderRadius: BorderRadius.circular(8),
  132. ),
  133. child: const Icon(
  134. LucideIcons.sparkles,
  135. color: Colors.white,
  136. size: 18,
  137. ),
  138. ),
  139. const SizedBox(width: 12),
  140. Text(
  141. document?.name ?? 'AI智能处理',
  142. style: const TextStyle(
  143. fontSize: 22,
  144. fontWeight: FontWeight.bold,
  145. color: AppColors.textPrimary,
  146. ),
  147. ),
  148. ],
  149. ),
  150. const SizedBox(height: 8),
  151. Text(
  152. 'AI润色、错别字修正、要素高亮',
  153. style: TextStyle(
  154. fontSize: 14,
  155. color: AppColors.textSecondary,
  156. ),
  157. ),
  158. ],
  159. ),
  160. ),
  161. ],
  162. ),
  163. );
  164. }
  165. Widget _buildToolbar(BuildContext context) {
  166. return Container(
  167. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
  168. decoration: BoxDecoration(
  169. color: Colors.white,
  170. borderRadius: BorderRadius.circular(12),
  171. border: Border.all(color: AppColors.border),
  172. boxShadow: [
  173. BoxShadow(
  174. color: Colors.black.withOpacity(0.04),
  175. blurRadius: 8,
  176. offset: const Offset(0, 2),
  177. ),
  178. ],
  179. ),
  180. child: Row(
  181. children: [
  182. _buildToolbarButton(
  183. icon: LucideIcons.highlighter,
  184. label: '要素高亮',
  185. isActive: _selectedMode == 'highlight',
  186. onTap: () => setState(() => _selectedMode = 'highlight'),
  187. ),
  188. const SizedBox(width: 8),
  189. _buildToolbarButton(
  190. icon: LucideIcons.sparkles,
  191. label: 'AI润色',
  192. isActive: _selectedMode == 'polish',
  193. onTap: () => setState(() => _selectedMode = 'polish'),
  194. ),
  195. const SizedBox(width: 8),
  196. _buildToolbarButton(
  197. icon: LucideIcons.circle_check,
  198. label: '错别字修正',
  199. isActive: _selectedMode == 'spellcheck',
  200. onTap: () => setState(() => _selectedMode = 'spellcheck'),
  201. ),
  202. const Spacer(),
  203. AppButton(
  204. text: '保存',
  205. type: ButtonType.primary,
  206. size: ButtonSize.small,
  207. icon: LucideIcons.save,
  208. onPressed: () {},
  209. ),
  210. ],
  211. ),
  212. );
  213. }
  214. Widget _buildToolbarButton({
  215. required IconData icon,
  216. required String label,
  217. required bool isActive,
  218. required VoidCallback onTap,
  219. }) {
  220. return Material(
  221. color: Colors.transparent,
  222. child: InkWell(
  223. onTap: onTap,
  224. borderRadius: BorderRadius.circular(8),
  225. child: Container(
  226. padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
  227. decoration: BoxDecoration(
  228. color: isActive
  229. ? AppColors.primary.withOpacity(0.1)
  230. : Colors.transparent,
  231. borderRadius: BorderRadius.circular(8),
  232. border: isActive
  233. ? Border.all(color: AppColors.primary.withOpacity(0.3))
  234. : null,
  235. ),
  236. child: Row(
  237. mainAxisSize: MainAxisSize.min,
  238. children: [
  239. Icon(
  240. icon,
  241. size: 18,
  242. color: isActive ? AppColors.primary : AppColors.textSecondary,
  243. ),
  244. const SizedBox(width: 6),
  245. Text(
  246. label,
  247. style: TextStyle(
  248. fontSize: 13,
  249. fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
  250. color: isActive ? AppColors.primary : AppColors.textSecondary,
  251. ),
  252. ),
  253. ],
  254. ),
  255. ),
  256. ),
  257. );
  258. }
  259. Widget _buildTextEditor(
  260. BuildContext context,
  261. String text,
  262. List<DocumentElement> elements,
  263. ) {
  264. return AppCard(
  265. title: Row(
  266. children: [
  267. const Icon(LucideIcons.file_text, size: 18),
  268. const SizedBox(width: 8),
  269. const Text('文档内容'),
  270. const Spacer(),
  271. Container(
  272. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
  273. decoration: BoxDecoration(
  274. color: AppColors.success.withOpacity(0.1),
  275. borderRadius: BorderRadius.circular(6),
  276. ),
  277. child: Row(
  278. mainAxisSize: MainAxisSize.min,
  279. children: [
  280. Icon(
  281. LucideIcons.circle_check,
  282. size: 12,
  283. color: AppColors.success,
  284. ),
  285. const SizedBox(width: 4),
  286. Text(
  287. '${elements.length} 个要素已提取',
  288. style: TextStyle(
  289. fontSize: 11,
  290. fontWeight: FontWeight.w600,
  291. color: AppColors.success,
  292. ),
  293. ),
  294. ],
  295. ),
  296. ),
  297. ],
  298. ),
  299. child: Container(
  300. constraints: const BoxConstraints(minHeight: 600),
  301. padding: const EdgeInsets.all(24),
  302. decoration: BoxDecoration(
  303. color: Colors.white,
  304. borderRadius: BorderRadius.circular(12),
  305. border: Border.all(color: AppColors.border),
  306. ),
  307. child: SelectableText.rich(
  308. TextSpan(
  309. children: _buildTextSpans(text, elements),
  310. style: const TextStyle(
  311. fontSize: 15,
  312. height: 1.8,
  313. color: AppColors.textPrimary,
  314. ),
  315. ),
  316. ),
  317. ),
  318. );
  319. }
  320. List<TextSpan> _buildTextSpans(
  321. String text,
  322. List<DocumentElement> elements,
  323. ) {
  324. final spans = <TextSpan>[];
  325. int lastIndex = 0;
  326. // 为每个要素创建高亮
  327. for (final element in elements) {
  328. final index = text.indexOf(element.value, lastIndex);
  329. if (index != -1) {
  330. // 添加高亮前的文本
  331. if (index > lastIndex) {
  332. spans.add(TextSpan(text: text.substring(lastIndex, index)));
  333. }
  334. // 添加高亮的要素文本
  335. spans.add(
  336. TextSpan(
  337. text: element.value,
  338. style: TextStyle(
  339. backgroundColor: _getElementColor(element.type).withOpacity(0.2),
  340. color: _getElementColor(element.type),
  341. fontWeight: FontWeight.w600,
  342. decoration: TextDecoration.underline,
  343. decorationColor: _getElementColor(element.type),
  344. ),
  345. mouseCursor: SystemMouseCursors.click,
  346. ),
  347. );
  348. lastIndex = index + element.value.length;
  349. }
  350. }
  351. // 添加剩余文本
  352. if (lastIndex < text.length) {
  353. spans.add(TextSpan(text: text.substring(lastIndex)));
  354. }
  355. return spans.isEmpty ? [TextSpan(text: text)] : spans;
  356. }
  357. Color _getElementColor(ElementType type) {
  358. switch (type) {
  359. case ElementType.amount:
  360. return AppColors.amount;
  361. case ElementType.company:
  362. return AppColors.company;
  363. case ElementType.person:
  364. return AppColors.person;
  365. case ElementType.location:
  366. return AppColors.location;
  367. default:
  368. return AppColors.textSecondary;
  369. }
  370. }
  371. Widget _buildAnnotationPanel(
  372. BuildContext context,
  373. List<DocumentElement> elements,
  374. ) {
  375. return Column(
  376. children: [
  377. AppCard(
  378. title: Row(
  379. children: [
  380. const Icon(LucideIcons.tags, size: 18),
  381. const SizedBox(width: 8),
  382. const Text('要素标签'),
  383. const Spacer(),
  384. Container(
  385. padding: const EdgeInsets.all(4),
  386. decoration: BoxDecoration(
  387. color: AppColors.primary.withOpacity(0.1),
  388. borderRadius: BorderRadius.circular(6),
  389. ),
  390. child: Text(
  391. '${elements.length}',
  392. style: TextStyle(
  393. fontSize: 12,
  394. fontWeight: FontWeight.bold,
  395. color: AppColors.primary,
  396. ),
  397. ),
  398. ),
  399. ],
  400. ),
  401. child: elements.isEmpty
  402. ? Padding(
  403. padding: const EdgeInsets.all(32),
  404. child: Column(
  405. children: [
  406. Icon(
  407. LucideIcons.tag,
  408. size: 48,
  409. color: AppColors.textSecondary.withOpacity(0.5),
  410. ),
  411. const SizedBox(height: 16),
  412. Text(
  413. '暂无要素',
  414. style: TextStyle(
  415. fontSize: 14,
  416. color: AppColors.textSecondary,
  417. ),
  418. ),
  419. const SizedBox(height: 8),
  420. Text(
  421. '点击"要素提取"按钮开始',
  422. style: TextStyle(
  423. fontSize: 12,
  424. color: AppColors.textSecondary.withOpacity(0.7),
  425. ),
  426. ),
  427. ],
  428. ),
  429. )
  430. : Container(
  431. constraints: const BoxConstraints(maxHeight: 400),
  432. child: SingleChildScrollView(
  433. child: Wrap(
  434. spacing: 8,
  435. runSpacing: 8,
  436. children: elements.map((element) {
  437. return AppTag(
  438. label: element.displayText,
  439. type: element.type,
  440. closable: true,
  441. onDeleted: () {},
  442. );
  443. }).toList(),
  444. ),
  445. ),
  446. ),
  447. ),
  448. const SizedBox(height: 16),
  449. AppCard(
  450. child: Column(
  451. children: [
  452. AppButton(
  453. text: '继续人工牵引',
  454. type: ButtonType.primary,
  455. icon: LucideIcons.git_branch,
  456. fullWidth: true,
  457. onPressed: () {
  458. context.push('${AppRoutes.traction}/${widget.documentId}');
  459. },
  460. ),
  461. const SizedBox(height: 12),
  462. AppButton(
  463. text: '重新提取要素',
  464. type: ButtonType.secondary,
  465. icon: LucideIcons.refresh_cw,
  466. fullWidth: true,
  467. onPressed: () {},
  468. ),
  469. ],
  470. ),
  471. ),
  472. ],
  473. );
  474. }
  475. String _getMockText() {
  476. return '''一、项目概述
  477. 本项目由腾讯科技有限公司(以下简称"腾讯")投资建设,旨在打造一套面向大型企业财务共享中心的"智能票据处理系统"。系统通过OCR识别、版面理解、要素抽取与人机协同核验,提升发票、合同、报销单据等票据类文档的处理效率与准确率。
  478. 二、投资与资金安排
  479. 项目总投资金额为人民币3,700万元,其中已支付1,000万元用于样机研制、数据治理与前期试点。剩余资金将依据阶段性里程碑进行分批拨付,涵盖核心算法优化、系统平台化改造、以及与客户生态的对接集成。
  480. 三、技术与产品路线
  481. 系统采用多模态文档理解技术,将版面结构、语义上下文与业务词典结合,实现对复杂票据的鲁棒解析。产品形态包含:上传解析端、人工牵引标注端、规则与关系构建端,以及结果交付端。
  482. 四、收益预期
  483. 预计上线后,票据处理效率提升60%以上,人工核验工作量下降40%,并显著降低AI"幻觉"导致的错判风险,从而提升财务数据的可靠性与可审计性。''';
  484. }
  485. }