traction_page.dart 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298
  1. import 'dart:math' as math;
  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/common/app_button.dart';
  8. import '../widgets/common/app_card.dart';
  9. import '../providers/document_provider.dart';
  10. import '../providers/element_provider.dart';
  11. import '../models/element.dart';
  12. import '../models/graph.dart';
  13. import '../services/mock_data_service.dart';
  14. import '../utils/constants.dart';
  15. import '../theme/app_colors.dart';
  16. /// 人工牵引页
  17. class TractionPage extends StatefulWidget {
  18. final String documentId;
  19. const TractionPage({Key? key, required this.documentId}) : super(key: key);
  20. @override
  21. State<TractionPage> createState() => _TractionPageState();
  22. }
  23. class _TractionPageState extends State<TractionPage> {
  24. ElementType? _filterType;
  25. List<GraphNode> _graphNodes = [];
  26. List<GraphEdge> _graphEdges = [];
  27. bool _showSelectedOnly = false;
  28. @override
  29. void initState() {
  30. super.initState();
  31. _graphNodes = MockDataService.getMockGraphNodes();
  32. _graphEdges = MockDataService.getMockGraphEdges();
  33. }
  34. @override
  35. Widget build(BuildContext context) {
  36. return Consumer2<DocumentProvider, ElementProvider>(
  37. builder: (context, docProvider, elementProvider, child) {
  38. final document = docProvider.getDocumentById(widget.documentId);
  39. final parsedText = document?.parsedText ?? _getMockText();
  40. final elements = elementProvider.filteredElements;
  41. return AppLayout(
  42. maxContentWidth: 1920,
  43. contentPadding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32),
  44. child: Column(
  45. mainAxisSize: MainAxisSize.min,
  46. crossAxisAlignment: CrossAxisAlignment.start,
  47. children: [
  48. const SizedBox(height: 24),
  49. // 标题栏
  50. _buildHeader(context, document),
  51. const SizedBox(height: 32),
  52. // 三栏布局
  53. _buildThreeColumnLayout(context, parsedText, elements),
  54. ],
  55. ),
  56. );
  57. },
  58. );
  59. }
  60. Widget _buildHeader(BuildContext context, document) {
  61. return Container(
  62. padding: const EdgeInsets.all(20),
  63. decoration: BoxDecoration(
  64. color: Colors.white,
  65. borderRadius: BorderRadius.circular(16),
  66. border: Border.all(color: AppColors.border),
  67. boxShadow: [
  68. BoxShadow(
  69. color: Colors.black.withOpacity(0.04),
  70. blurRadius: 12,
  71. offset: const Offset(0, 4),
  72. ),
  73. ],
  74. ),
  75. child: Row(
  76. children: [
  77. IconButton(
  78. icon: const Icon(Icons.arrow_back),
  79. onPressed: () => context.pop(),
  80. style: IconButton.styleFrom(
  81. backgroundColor: AppColors.backgroundLight,
  82. padding: const EdgeInsets.all(12),
  83. ),
  84. ),
  85. const SizedBox(width: 16),
  86. Expanded(
  87. child: Column(
  88. crossAxisAlignment: CrossAxisAlignment.start,
  89. children: [
  90. Row(
  91. children: [
  92. Container(
  93. padding: const EdgeInsets.all(8),
  94. decoration: BoxDecoration(
  95. gradient: LinearGradient(
  96. colors: [AppColors.primary, AppColors.primaryLight],
  97. ),
  98. borderRadius: BorderRadius.circular(8),
  99. ),
  100. child: const Icon(
  101. LucideIcons.git_branch,
  102. color: Colors.white,
  103. size: 18,
  104. ),
  105. ),
  106. const SizedBox(width: 12),
  107. const Text(
  108. '人工牵引',
  109. style: TextStyle(
  110. fontSize: 22,
  111. fontWeight: FontWeight.bold,
  112. color: AppColors.textPrimary,
  113. ),
  114. ),
  115. ],
  116. ),
  117. const SizedBox(height: 8),
  118. Text(
  119. '从文档标记 → 标签池 → 拖拽构建逻辑',
  120. style: TextStyle(
  121. fontSize: 14,
  122. color: AppColors.textSecondary,
  123. ),
  124. ),
  125. ],
  126. ),
  127. ),
  128. AppButton(
  129. text: '保存',
  130. type: ButtonType.secondary,
  131. size: ButtonSize.small,
  132. icon: LucideIcons.save,
  133. onPressed: () {},
  134. ),
  135. ],
  136. ),
  137. );
  138. }
  139. Widget _buildThreeColumnLayout(
  140. BuildContext context,
  141. String text,
  142. List<DocumentElement> elements,
  143. ) {
  144. final screenWidth = MediaQuery.of(context).size.width;
  145. final isWide = screenWidth > AppConstants.desktopBreakpoint;
  146. final veryWide = screenWidth > 1920;
  147. final elementPanelWidth = veryWide ? 540.0 : 310.0;
  148. final relationPanelWidth = veryWide ? 560.0 : 400.0;
  149. final content = isWide
  150. ? Row(
  151. crossAxisAlignment: CrossAxisAlignment.start,
  152. children: [
  153. // 左侧:要素池
  154. SizedBox(
  155. width: elementPanelWidth,
  156. child: _buildElementPool(context, elements),
  157. ),
  158. SizedBox(width: veryWide ? 28 : 24),
  159. // 中间:主文档区
  160. Expanded(
  161. flex: 3,
  162. child: _buildDocumentView(context, text, elements),
  163. ),
  164. SizedBox(width: veryWide ? 28 : 24),
  165. // 右侧:关系构建区
  166. SizedBox(
  167. width: relationPanelWidth,
  168. child: _buildRelationshipBuilder(context),
  169. ),
  170. ],
  171. )
  172. : Column(
  173. children: [
  174. _buildDocumentView(context, text, elements),
  175. const SizedBox(height: 20),
  176. _buildRelationshipBuilder(context),
  177. ],
  178. );
  179. return Column(
  180. crossAxisAlignment: CrossAxisAlignment.start,
  181. children: [
  182. // _buildActionBar(context),
  183. // const SizedBox(height: 20),
  184. content,
  185. ],
  186. );
  187. }
  188. Widget _buildActionBar(BuildContext context) {
  189. return Container(
  190. padding: const EdgeInsets.all(18),
  191. decoration: BoxDecoration(
  192. color: Colors.white,
  193. borderRadius: BorderRadius.circular(12),
  194. border: Border.all(color: AppColors.border),
  195. boxShadow: [
  196. BoxShadow(
  197. color: Colors.black.withOpacity(0.02),
  198. blurRadius: 12,
  199. offset: const Offset(0, 4),
  200. ),
  201. ],
  202. ),
  203. child: Wrap(
  204. spacing: 16,
  205. runSpacing: 12,
  206. crossAxisAlignment: WrapCrossAlignment.center,
  207. children: [
  208. Row(
  209. mainAxisSize: MainAxisSize.min,
  210. children: [
  211. Container(
  212. padding: const EdgeInsets.all(10),
  213. decoration: BoxDecoration(
  214. color: AppColors.primary.withOpacity(0.1),
  215. borderRadius: BorderRadius.circular(10),
  216. ),
  217. child: const Icon(
  218. LucideIcons.sparkles,
  219. color: AppColors.primary,
  220. size: 18,
  221. ),
  222. ),
  223. const SizedBox(width: 12),
  224. Column(
  225. crossAxisAlignment: CrossAxisAlignment.start,
  226. children: [
  227. Text(
  228. '人工牵引操作台',
  229. style: Theme.of(context).textTheme.titleMedium?.copyWith(
  230. fontWeight: FontWeight.w600,
  231. color: AppColors.textPrimary,
  232. ),
  233. ),
  234. const SizedBox(height: 4),
  235. Text(
  236. '拖拽左侧要素到右侧画布,构建规则逻辑网络',
  237. style: Theme.of(context).textTheme.bodySmall?.copyWith(
  238. color: AppColors.textSecondary,
  239. ),
  240. ),
  241. ],
  242. ),
  243. ],
  244. ),
  245. const SizedBox(width: 12),
  246. AppButton(
  247. text: '自动布局',
  248. type: ButtonType.secondary,
  249. size: ButtonSize.small,
  250. icon: LucideIcons.layout_panel_left,
  251. onPressed: _autoLayoutGraph,
  252. ),
  253. AppButton(
  254. text: '对齐网格',
  255. type: ButtonType.secondary,
  256. size: ButtonSize.small,
  257. icon: LucideIcons.grid_3x3,
  258. onPressed: _alignNodesToGrid,
  259. ),
  260. AppButton(
  261. text: '导出节点',
  262. type: ButtonType.secondary,
  263. size: ButtonSize.small,
  264. icon: LucideIcons.download,
  265. onPressed: () {},
  266. ),
  267. ],
  268. ),
  269. );
  270. }
  271. void _autoLayoutGraph() {
  272. if (_graphNodes.isEmpty) return;
  273. const double horizontalGap = 240;
  274. const double verticalGap = 160;
  275. const double startX = 80;
  276. const double startY = 80;
  277. const int columns = 2;
  278. setState(() {
  279. for (int index = 0; index < _graphNodes.length; index++) {
  280. final node = _graphNodes[index];
  281. final col = index % columns;
  282. final row = index ~/ columns;
  283. final newPosition = Offset(
  284. startX + col * horizontalGap,
  285. startY + row * verticalGap,
  286. );
  287. _graphNodes[index] = node.copyWith(position: newPosition);
  288. }
  289. });
  290. }
  291. void _alignNodesToGrid() {
  292. if (_graphNodes.isEmpty) return;
  293. const double gridSize = 40;
  294. setState(() {
  295. for (int index = 0; index < _graphNodes.length; index++) {
  296. final node = _graphNodes[index];
  297. final alignedX = (node.position.dx / gridSize).roundToDouble() * gridSize;
  298. final alignedY = (node.position.dy / gridSize).roundToDouble() * gridSize;
  299. _graphNodes[index] = node.copyWith(position: Offset(alignedX, alignedY));
  300. }
  301. });
  302. }
  303. Widget _buildElementPool(BuildContext context, List<DocumentElement> elements) {
  304. final theme = Theme.of(context);
  305. final elementProvider = Provider.of<ElementProvider>(context);
  306. final allElements = elementProvider.elements;
  307. final selectedElements = elementProvider.selectedElements;
  308. final displayElements = _showSelectedOnly ? elements.where((e) => selectedElements.contains(e)).toList() : elements;
  309. final typeCounts = <ElementType, int>{
  310. for (final type in ElementType.values) type: allElements.where((e) => e.type == type).length,
  311. };
  312. final groupedElements = <ElementType, List<DocumentElement>>{};
  313. for (final type in ElementType.values) {
  314. final group = displayElements.where((element) => element.type == type).toList();
  315. if (group.isNotEmpty) {
  316. groupedElements[type] = group;
  317. }
  318. }
  319. return AppCard(
  320. title: Row(
  321. children: [
  322. const Icon(LucideIcons.tags, size: 18),
  323. const SizedBox(width: 8),
  324. const Text('要素池'),
  325. const Spacer(),
  326. Container(
  327. padding: const EdgeInsets.all(4),
  328. decoration: BoxDecoration(
  329. color: AppColors.primary.withOpacity(0.1),
  330. borderRadius: BorderRadius.circular(6),
  331. ),
  332. child: Text(
  333. '${elements.length}',
  334. style: TextStyle(
  335. fontSize: 11,
  336. fontWeight: FontWeight.bold,
  337. color: AppColors.primary,
  338. ),
  339. ),
  340. ),
  341. ],
  342. ),
  343. child: Container(
  344. padding: const EdgeInsets.all(15),
  345. child: Column(
  346. crossAxisAlignment: CrossAxisAlignment.start,
  347. mainAxisSize: MainAxisSize.min,
  348. children: [
  349. // 搜索与筛选
  350. Row(
  351. children: [
  352. Expanded(
  353. child: TextField(
  354. decoration: InputDecoration(
  355. hintText: '搜索要素或关键词...',
  356. prefixIcon: const Icon(LucideIcons.search, size: 18),
  357. isDense: true,
  358. contentPadding: const EdgeInsets.symmetric(
  359. horizontal: 14,
  360. vertical: 12,
  361. ),
  362. border: OutlineInputBorder(
  363. borderRadius: BorderRadius.circular(10),
  364. borderSide: const BorderSide(color: AppColors.border),
  365. ),
  366. enabledBorder: OutlineInputBorder(
  367. borderRadius: BorderRadius.circular(10),
  368. borderSide: const BorderSide(color: AppColors.border),
  369. ),
  370. focusedBorder: OutlineInputBorder(
  371. borderRadius: BorderRadius.circular(10),
  372. borderSide: const BorderSide(
  373. color: AppColors.primary,
  374. width: 2,
  375. ),
  376. ),
  377. ),
  378. onChanged: (value) {
  379. Provider.of<ElementProvider>(context, listen: false).setSearchQuery(value);
  380. },
  381. ),
  382. ),
  383. const SizedBox(width: 12),
  384. Tooltip(
  385. message: '只查看已选择的要素',
  386. child: InkWell(
  387. onTap: () {
  388. setState(() {
  389. _showSelectedOnly = !_showSelectedOnly;
  390. });
  391. },
  392. borderRadius: BorderRadius.circular(12),
  393. child: Container(
  394. padding: const EdgeInsets.symmetric(
  395. horizontal: 12,
  396. vertical: 10,
  397. ),
  398. decoration: BoxDecoration(
  399. color: _showSelectedOnly ? AppColors.primary.withOpacity(0.15) : AppColors.borderLight,
  400. borderRadius: BorderRadius.circular(12),
  401. border: Border.all(
  402. color: _showSelectedOnly ? AppColors.primary : AppColors.borderLight,
  403. ),
  404. ),
  405. child: Row(
  406. mainAxisSize: MainAxisSize.min,
  407. children: [
  408. Icon(
  409. LucideIcons.circle_check,
  410. size: 16,
  411. color: _showSelectedOnly ? AppColors.primary : AppColors.textSecondary,
  412. ),
  413. const SizedBox(width: 6),
  414. Text(
  415. '仅已选',
  416. style: theme.textTheme.bodySmall?.copyWith(
  417. color: _showSelectedOnly ? AppColors.primary : AppColors.textSecondary,
  418. fontWeight: FontWeight.w600,
  419. ),
  420. ),
  421. ],
  422. ),
  423. ),
  424. ),
  425. ),
  426. ],
  427. ),
  428. const SizedBox(height: 16),
  429. // 类型筛选
  430. Wrap(
  431. spacing: 8,
  432. runSpacing: 8,
  433. children: [
  434. _buildFilterChip('全部', null, count: allElements.length),
  435. _buildFilterChip('金额', ElementType.amount, count: typeCounts[ElementType.amount] ?? 0),
  436. _buildFilterChip('公司', ElementType.company, count: typeCounts[ElementType.company] ?? 0),
  437. _buildFilterChip('人名', ElementType.person, count: typeCounts[ElementType.person] ?? 0),
  438. _buildFilterChip('地名', ElementType.location, count: typeCounts[ElementType.location] ?? 0),
  439. _buildFilterChip('日期', ElementType.date, count: typeCounts[ElementType.date] ?? 0),
  440. _buildFilterChip('其他', ElementType.other, count: typeCounts[ElementType.other] ?? 0),
  441. ],
  442. ),
  443. const SizedBox(height: 20),
  444. // 要素列表
  445. SizedBox(
  446. height: 340,
  447. child: displayElements.isEmpty
  448. ? _buildEmptyElementState()
  449. : ListView(
  450. padding: EdgeInsets.zero,
  451. children: groupedElements.entries
  452. .map(
  453. (entry) => _buildElementGroupSection(
  454. entry.key,
  455. entry.value,
  456. selectedElements,
  457. ),
  458. )
  459. .toList(),
  460. ),
  461. ),
  462. const SizedBox(height: 16),
  463. AppButton(
  464. text: '添加要素',
  465. type: ButtonType.primary,
  466. size: ButtonSize.small,
  467. icon: LucideIcons.plus,
  468. fullWidth: true,
  469. onPressed: () {},
  470. ),
  471. ],
  472. ),
  473. ),
  474. );
  475. }
  476. Widget _buildFilterChip(String label, ElementType? type, {int? count}) {
  477. final isSelected = _filterType == type;
  478. final displayLabel = count != null ? '$label · $count' : label;
  479. return FilterChip(
  480. label: Text(displayLabel),
  481. selected: isSelected,
  482. onSelected: (selected) {
  483. setState(() {
  484. _filterType = selected ? type : null;
  485. });
  486. Provider.of<ElementProvider>(context, listen: false).setFilterType(_filterType);
  487. },
  488. selectedColor: AppColors.primary.withOpacity(0.2),
  489. checkmarkColor: AppColors.primary,
  490. labelStyle: TextStyle(
  491. fontSize: 10,
  492. fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
  493. color: isSelected ? AppColors.primary : AppColors.textSecondary,
  494. ),
  495. );
  496. }
  497. Widget _buildDraggableElement(DocumentElement element, List<DocumentElement> selectedElements) {
  498. final elementProvider = Provider.of<ElementProvider>(context, listen: false);
  499. final isSelected = selectedElements.contains(element);
  500. final color = _getElementColor(element.type);
  501. return LongPressDraggable<DocumentElement>(
  502. data: element,
  503. onDragStarted: () {
  504. if (!isSelected) {
  505. elementProvider.selectElement(element);
  506. }
  507. },
  508. feedback: Material(
  509. color: Colors.transparent,
  510. child: ConstrainedBox(
  511. constraints: const BoxConstraints(maxWidth: 260),
  512. child: _buildElementTile(
  513. context,
  514. element,
  515. color,
  516. true,
  517. isFeedback: true,
  518. ),
  519. ),
  520. ),
  521. childWhenDragging: Opacity(
  522. opacity: 0.4,
  523. child: _buildElementTile(context, element, color, isSelected),
  524. ),
  525. child: GestureDetector(
  526. onTap: () {
  527. if (isSelected) {
  528. elementProvider.deselectElement(element);
  529. } else {
  530. elementProvider.selectElement(element);
  531. }
  532. },
  533. child: _buildElementTile(context, element, color, isSelected),
  534. ),
  535. );
  536. }
  537. Widget _buildElementTile(
  538. BuildContext context,
  539. DocumentElement element,
  540. Color color,
  541. bool isSelected, {
  542. bool isFeedback = false,
  543. }) {
  544. final theme = Theme.of(context);
  545. return Container(
  546. margin: const EdgeInsets.only(bottom: 10),
  547. padding: const EdgeInsets.all(14),
  548. decoration: BoxDecoration(
  549. color: isSelected ? color.withOpacity(0.12) : AppColors.backgroundLight,
  550. borderRadius: BorderRadius.circular(12),
  551. border: Border.all(
  552. color: isSelected ? color : AppColors.borderLight,
  553. width: isSelected ? 1.5 : 1,
  554. ),
  555. boxShadow: [
  556. if (isSelected)
  557. BoxShadow(
  558. color: color.withOpacity(0.2),
  559. blurRadius: 12,
  560. offset: const Offset(0, 4),
  561. ),
  562. if (isFeedback)
  563. BoxShadow(
  564. color: Colors.black.withOpacity(0.15),
  565. blurRadius: 16,
  566. offset: const Offset(0, 6),
  567. ),
  568. ],
  569. ),
  570. child: Column(
  571. crossAxisAlignment: CrossAxisAlignment.start,
  572. children: [
  573. Row(
  574. crossAxisAlignment: CrossAxisAlignment.center,
  575. children: [
  576. Container(
  577. padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
  578. decoration: BoxDecoration(
  579. color: color.withOpacity(0.16),
  580. borderRadius: BorderRadius.circular(8),
  581. ),
  582. child: Text(
  583. element.type.label,
  584. style: theme.textTheme.bodySmall?.copyWith(
  585. color: color,
  586. fontWeight: FontWeight.w600,
  587. ),
  588. ),
  589. ),
  590. const SizedBox(width: 8),
  591. Expanded(
  592. child: Text(
  593. element.label,
  594. maxLines: 1,
  595. overflow: TextOverflow.ellipsis,
  596. style: theme.textTheme.titleSmall?.copyWith(
  597. fontWeight: FontWeight.w600,
  598. color: AppColors.textPrimary,
  599. ),
  600. ),
  601. ),
  602. const SizedBox(width: 6),
  603. Icon(
  604. LucideIcons.grip_vertical,
  605. size: 16,
  606. color: AppColors.textSecondary,
  607. ),
  608. ],
  609. ),
  610. const SizedBox(height: 10),
  611. Text(
  612. element.value,
  613. maxLines: 3,
  614. overflow: TextOverflow.ellipsis,
  615. style: theme.textTheme.bodyMedium?.copyWith(
  616. color: AppColors.textPrimary,
  617. height: 1.5,
  618. ),
  619. ),
  620. const SizedBox(height: 12),
  621. Row(
  622. children: [
  623. Icon(
  624. _getElementIcon(element.type),
  625. size: 16,
  626. color: color,
  627. ),
  628. const SizedBox(width: 6),
  629. Text(
  630. element.documentId ?? '未关联文档',
  631. style: theme.textTheme.bodySmall?.copyWith(
  632. color: AppColors.textSecondary,
  633. ),
  634. ),
  635. const Spacer(),
  636. if (isSelected)
  637. Container(
  638. padding: const EdgeInsets.symmetric(
  639. horizontal: 8,
  640. vertical: 4,
  641. ),
  642. decoration: BoxDecoration(
  643. color: color.withOpacity(0.18),
  644. borderRadius: BorderRadius.circular(8),
  645. ),
  646. child: Row(
  647. mainAxisSize: MainAxisSize.min,
  648. children: [
  649. Icon(
  650. LucideIcons.check,
  651. size: 14,
  652. color: color,
  653. ),
  654. const SizedBox(width: 4),
  655. Text(
  656. '已选中',
  657. style: theme.textTheme.bodySmall?.copyWith(
  658. color: color,
  659. fontWeight: FontWeight.w600,
  660. ),
  661. ),
  662. ],
  663. ),
  664. ),
  665. ],
  666. ),
  667. ],
  668. ),
  669. );
  670. }
  671. Widget _buildElementGroupSection(
  672. ElementType type,
  673. List<DocumentElement> items,
  674. List<DocumentElement> selectedElements,
  675. ) {
  676. final color = _getElementColor(type);
  677. return Padding(
  678. padding: const EdgeInsets.only(bottom: 20),
  679. child: Column(
  680. crossAxisAlignment: CrossAxisAlignment.start,
  681. children: [
  682. Container(
  683. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  684. decoration: BoxDecoration(
  685. color: color.withOpacity(0.1),
  686. borderRadius: BorderRadius.circular(10),
  687. ),
  688. child: Row(
  689. children: [
  690. Icon(_getElementIcon(type), size: 16, color: color),
  691. const SizedBox(width: 8),
  692. Text(
  693. '${type.label} · ${items.length}',
  694. style: TextStyle(
  695. fontSize: 13,
  696. fontWeight: FontWeight.w600,
  697. color: color,
  698. ),
  699. ),
  700. ],
  701. ),
  702. ),
  703. const SizedBox(height: 12),
  704. for (final item in items) _buildDraggableElement(item, selectedElements),
  705. ],
  706. ),
  707. );
  708. }
  709. Widget _buildStatBadge({
  710. required IconData icon,
  711. required String label,
  712. required String value,
  713. required Color color,
  714. }) {
  715. return Container(
  716. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
  717. decoration: BoxDecoration(
  718. color: color.withOpacity(0.12),
  719. borderRadius: BorderRadius.circular(12),
  720. border: Border.all(color: color.withOpacity(0.2)),
  721. ),
  722. child: Row(
  723. mainAxisSize: MainAxisSize.min,
  724. children: [
  725. Icon(icon, size: 16, color: color),
  726. const SizedBox(width: 8),
  727. Column(
  728. crossAxisAlignment: CrossAxisAlignment.start,
  729. children: [
  730. Text(
  731. label,
  732. style: const TextStyle(
  733. fontSize: 11,
  734. color: AppColors.textSecondary,
  735. ),
  736. ),
  737. Text(
  738. value,
  739. style: TextStyle(
  740. fontSize: 15,
  741. fontWeight: FontWeight.w700,
  742. color: color,
  743. ),
  744. ),
  745. ],
  746. ),
  747. ],
  748. ),
  749. );
  750. }
  751. Widget _buildEmptyElementState() {
  752. return Container(
  753. alignment: Alignment.center,
  754. decoration: BoxDecoration(
  755. color: AppColors.backgroundLight,
  756. borderRadius: BorderRadius.circular(12),
  757. border: Border.all(color: AppColors.borderLight),
  758. ),
  759. child: Column(
  760. mainAxisAlignment: MainAxisAlignment.center,
  761. mainAxisSize: MainAxisSize.min,
  762. children: [
  763. Icon(
  764. LucideIcons.inbox,
  765. size: 48,
  766. color: AppColors.textSecondary.withOpacity(0.4),
  767. ),
  768. const SizedBox(height: 12),
  769. const Text(
  770. '暂无匹配的要素',
  771. style: TextStyle(
  772. fontSize: 14,
  773. fontWeight: FontWeight.w600,
  774. color: AppColors.textPrimary,
  775. ),
  776. ),
  777. const SizedBox(height: 6),
  778. Text(
  779. _showSelectedOnly ? '切换回全部或调整筛选条件试试' : '尝试更换搜索关键字或筛选条件',
  780. style: const TextStyle(
  781. fontSize: 12,
  782. color: AppColors.textSecondary,
  783. ),
  784. ),
  785. ],
  786. ),
  787. );
  788. }
  789. IconData _getElementIcon(ElementType type) {
  790. switch (type) {
  791. case ElementType.amount:
  792. return LucideIcons.dollar_sign;
  793. case ElementType.company:
  794. return LucideIcons.building;
  795. case ElementType.person:
  796. return LucideIcons.user;
  797. case ElementType.location:
  798. return LucideIcons.map_pin;
  799. default:
  800. return LucideIcons.tag;
  801. }
  802. }
  803. Widget _buildDocumentView(
  804. BuildContext context,
  805. String text,
  806. List<DocumentElement> elements,
  807. ) {
  808. return AppCard(
  809. title: Row(
  810. children: [
  811. const Icon(LucideIcons.file_text, size: 18),
  812. const SizedBox(width: 8),
  813. const Text('主文档'),
  814. const Spacer(),
  815. Container(
  816. padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
  817. decoration: BoxDecoration(
  818. color: AppColors.primary.withOpacity(0.1),
  819. borderRadius: BorderRadius.circular(6),
  820. ),
  821. child: Row(
  822. mainAxisSize: MainAxisSize.min,
  823. children: [
  824. Icon(
  825. LucideIcons.mouse_pointer_click,
  826. size: 12,
  827. color: AppColors.primary,
  828. ),
  829. const SizedBox(width: 4),
  830. Text(
  831. '点击高亮区域选择',
  832. style: TextStyle(
  833. fontSize: 11,
  834. fontWeight: FontWeight.w600,
  835. color: AppColors.primary,
  836. ),
  837. ),
  838. ],
  839. ),
  840. ),
  841. ],
  842. ),
  843. child: Container(
  844. constraints: const BoxConstraints(minHeight: 600),
  845. padding: const EdgeInsets.all(24),
  846. decoration: BoxDecoration(
  847. color: Colors.white,
  848. borderRadius: BorderRadius.circular(12),
  849. border: Border.all(color: AppColors.border),
  850. ),
  851. child: SelectableText.rich(
  852. TextSpan(
  853. children: _buildTextSpans(text, elements),
  854. style: const TextStyle(
  855. fontSize: 15,
  856. height: 1.8,
  857. color: AppColors.textPrimary,
  858. ),
  859. ),
  860. ),
  861. ),
  862. );
  863. }
  864. List<TextSpan> _buildTextSpans(
  865. String text,
  866. List<DocumentElement> elements,
  867. ) {
  868. final spans = <TextSpan>[];
  869. int lastIndex = 0;
  870. for (final element in elements) {
  871. final index = text.indexOf(element.value, lastIndex);
  872. if (index != -1) {
  873. if (index > lastIndex) {
  874. spans.add(TextSpan(text: text.substring(lastIndex, index)));
  875. }
  876. spans.add(
  877. TextSpan(
  878. text: element.value,
  879. style: TextStyle(
  880. backgroundColor: _getElementColor(element.type).withOpacity(0.25),
  881. color: _getElementColor(element.type),
  882. fontWeight: FontWeight.w600,
  883. decoration: TextDecoration.underline,
  884. decorationColor: _getElementColor(element.type),
  885. ),
  886. mouseCursor: SystemMouseCursors.click,
  887. ),
  888. );
  889. lastIndex = index + element.value.length;
  890. }
  891. }
  892. if (lastIndex < text.length) {
  893. spans.add(TextSpan(text: text.substring(lastIndex)));
  894. }
  895. return spans.isEmpty ? [TextSpan(text: text)] : spans;
  896. }
  897. Color _getElementColor(ElementType type) {
  898. switch (type) {
  899. case ElementType.amount:
  900. return AppColors.amount;
  901. case ElementType.company:
  902. return AppColors.company;
  903. case ElementType.person:
  904. return AppColors.person;
  905. case ElementType.location:
  906. return AppColors.location;
  907. default:
  908. return AppColors.textSecondary;
  909. }
  910. }
  911. Widget _buildRelationshipBuilder(BuildContext context) {
  912. return Column(
  913. children: [
  914. AppCard(
  915. title: Row(
  916. children: [
  917. const Icon(LucideIcons.git_branch, size: 18),
  918. const SizedBox(width: 8),
  919. const Text('关系构建'),
  920. const Spacer(),
  921. Container(
  922. padding: const EdgeInsets.all(4),
  923. decoration: BoxDecoration(
  924. color: AppColors.warning.withOpacity(0.1),
  925. borderRadius: BorderRadius.circular(6),
  926. ),
  927. child: Text(
  928. '${_graphNodes.length} 节点',
  929. style: TextStyle(
  930. fontSize: 11,
  931. fontWeight: FontWeight.bold,
  932. color: AppColors.warning,
  933. ),
  934. ),
  935. ),
  936. ],
  937. ),
  938. child: Container(
  939. padding: const EdgeInsets.all(15),
  940. child: Column(
  941. crossAxisAlignment: CrossAxisAlignment.start,
  942. children: [
  943. Text(
  944. '拖拽要素到此处构建关系网络',
  945. style: TextStyle(
  946. fontSize: 12,
  947. color: AppColors.textSecondary,
  948. ),
  949. ),
  950. const SizedBox(height: 16),
  951. // 关系网络图
  952. DragTarget<DocumentElement>(
  953. onAccept: (element) {
  954. setState(() {
  955. _graphNodes.add(
  956. GraphNode(
  957. id: 'n${_graphNodes.length + 1}',
  958. label: element.displayText,
  959. type: NodeType.element,
  960. position: Offset(
  961. 100 + (_graphNodes.length * 80).toDouble(),
  962. 120 + (_graphNodes.length * 60).toDouble(),
  963. ),
  964. ),
  965. );
  966. });
  967. },
  968. builder: (context, candidateData, rejectedData) {
  969. final isHovering = candidateData.isNotEmpty;
  970. return AnimatedContainer(
  971. duration: const Duration(milliseconds: 180),
  972. height: 400,
  973. decoration: BoxDecoration(
  974. gradient: LinearGradient(
  975. begin: Alignment.topLeft,
  976. end: Alignment.bottomRight,
  977. colors: [
  978. AppColors.background,
  979. Colors.white,
  980. ],
  981. ),
  982. borderRadius: BorderRadius.circular(12),
  983. border: Border.all(
  984. color: isHovering ? AppColors.primary : AppColors.border,
  985. width: isHovering ? 2 : 1,
  986. ),
  987. ),
  988. clipBehavior: Clip.hardEdge,
  989. child: LayoutBuilder(
  990. builder: (context, constraints) {
  991. final canvasWidth = math.max(constraints.maxWidth, 600.0);
  992. final canvasHeight = math.max(constraints.maxHeight, 420.0);
  993. return InteractiveViewer(
  994. constrained: false,
  995. minScale: 0.5,
  996. maxScale: 2.5,
  997. boundaryMargin: const EdgeInsets.all(200),
  998. child: SizedBox(
  999. width: canvasWidth,
  1000. height: canvasHeight,
  1001. child: _buildGraphCanvas(
  1002. Size(canvasWidth, canvasHeight),
  1003. ),
  1004. ),
  1005. );
  1006. },
  1007. ),
  1008. );
  1009. },
  1010. ),
  1011. const SizedBox(height: 16),
  1012. // 关系类型选择
  1013. Wrap(
  1014. spacing: 8,
  1015. runSpacing: 8,
  1016. children: [
  1017. _buildRelationChip('计算', EdgeType.calculate),
  1018. _buildRelationChip('引用', EdgeType.reference),
  1019. _buildRelationChip('包含', EdgeType.contain),
  1020. ],
  1021. ),
  1022. ],
  1023. ),
  1024. )),
  1025. const SizedBox(height: 16),
  1026. AppCard(
  1027. child: Column(
  1028. children: [
  1029. AppButton(
  1030. text: '生成Prompt',
  1031. type: ButtonType.primary,
  1032. icon: LucideIcons.sparkles,
  1033. fullWidth: true,
  1034. onPressed: () {
  1035. context.push('${AppRoutes.result}/${widget.documentId}');
  1036. },
  1037. ),
  1038. const SizedBox(height: 12),
  1039. AppButton(
  1040. text: '清空关系',
  1041. type: ButtonType.secondary,
  1042. icon: LucideIcons.trash_2,
  1043. fullWidth: true,
  1044. onPressed: () {
  1045. setState(() {
  1046. _graphNodes.clear();
  1047. _graphEdges.clear();
  1048. });
  1049. },
  1050. ),
  1051. ],
  1052. ),
  1053. ),
  1054. ],
  1055. );
  1056. }
  1057. Widget _buildGraphCanvas(Size canvasSize) {
  1058. if (_graphNodes.isEmpty) {
  1059. return SizedBox.expand(
  1060. child: Center(
  1061. child: Column(
  1062. mainAxisAlignment: MainAxisAlignment.center,
  1063. children: [
  1064. Icon(
  1065. LucideIcons.git_branch,
  1066. size: 64,
  1067. color: AppColors.textSecondary.withOpacity(0.5),
  1068. ),
  1069. const SizedBox(height: 16),
  1070. Text(
  1071. '拖拽要素到此处',
  1072. style: TextStyle(
  1073. fontSize: 14,
  1074. color: AppColors.textSecondary,
  1075. ),
  1076. ),
  1077. const SizedBox(height: 8),
  1078. Text(
  1079. '开始构建关系网络',
  1080. style: TextStyle(
  1081. fontSize: 12,
  1082. color: AppColors.textSecondary.withOpacity(0.7),
  1083. ),
  1084. ),
  1085. ],
  1086. ),
  1087. ),
  1088. );
  1089. }
  1090. final positions = <String, Offset>{};
  1091. for (final node in _graphNodes) {
  1092. positions[node.id] = _clampOffset(node.position, canvasSize);
  1093. }
  1094. return CustomPaint(
  1095. size: canvasSize,
  1096. painter: GraphPainter(_graphNodes, _graphEdges, positions),
  1097. child: Stack(
  1098. clipBehavior: Clip.none,
  1099. children: _graphNodes.map((node) {
  1100. final position = positions[node.id] ?? node.position;
  1101. return Positioned(
  1102. left: position.dx,
  1103. top: position.dy,
  1104. child: _buildGraphNode(node),
  1105. );
  1106. }).toList(),
  1107. ),
  1108. );
  1109. }
  1110. Offset _clampOffset(Offset position, Size canvasSize) {
  1111. const double padding = 24;
  1112. const double nodeWidth = 160;
  1113. const double nodeHeight = 64;
  1114. final double maxX = math.max(padding, canvasSize.width - nodeWidth - padding);
  1115. final double maxY = math.max(padding, canvasSize.height - nodeHeight - padding);
  1116. return Offset(
  1117. position.dx.clamp(padding, maxX),
  1118. position.dy.clamp(padding, maxY),
  1119. );
  1120. }
  1121. Widget _buildGraphNode(GraphNode node) {
  1122. Color getNodeColor() {
  1123. switch (node.type) {
  1124. case NodeType.element:
  1125. return AppColors.primary;
  1126. case NodeType.operator:
  1127. return AppColors.warning;
  1128. case NodeType.result:
  1129. return AppColors.success;
  1130. }
  1131. }
  1132. return Container(
  1133. padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
  1134. decoration: BoxDecoration(
  1135. color: getNodeColor(),
  1136. borderRadius: BorderRadius.circular(8),
  1137. boxShadow: [
  1138. BoxShadow(
  1139. color: getNodeColor().withOpacity(0.3),
  1140. blurRadius: 8,
  1141. offset: const Offset(0, 2),
  1142. ),
  1143. ],
  1144. ),
  1145. child: Text(
  1146. node.label,
  1147. style: const TextStyle(
  1148. color: Colors.white,
  1149. fontSize: 12,
  1150. fontWeight: FontWeight.w600,
  1151. ),
  1152. ),
  1153. );
  1154. }
  1155. Widget _buildRelationChip(String label, EdgeType type) {
  1156. return ChoiceChip(
  1157. label: Text(label),
  1158. selected: false,
  1159. onSelected: (selected) {},
  1160. labelStyle: const TextStyle(fontSize: 12),
  1161. );
  1162. }
  1163. String _getMockText() {
  1164. return '''一、项目概述
  1165. 本项目由腾讯科技有限公司(以下简称"腾讯")投资建设,旨在打造一套面向大型企业财务共享中心的"智能票据处理系统"。系统通过OCR识别、版面理解、要素抽取与人机协同核验,提升发票、合同、报销单据等票据类文档的处理效率与准确率。
  1166. 二、投资与资金安排
  1167. 项目总投资金额为人民币3,700万元,其中已支付1,000万元用于样机研制、数据治理与前期试点。剩余资金将依据阶段性里程碑进行分批拨付,涵盖核心算法优化、系统平台化改造、以及与客户生态的对接集成。
  1168. 三、技术与产品路线
  1169. 系统采用多模态文档理解技术,将版面结构、语义上下文与业务词典结合,实现对复杂票据的鲁棒解析。产品形态包含:上传解析端、人工牵引标注端、规则与关系构建端,以及结果交付端。
  1170. 四、收益预期
  1171. 预计上线后,票据处理效率提升60%以上,人工核验工作量下降40%,并显著降低AI"幻觉"导致的错判风险,从而提升财务数据的可靠性与可审计性。''';
  1172. }
  1173. }
  1174. /// 关系网络绘制器
  1175. class GraphPainter extends CustomPainter {
  1176. final List<GraphNode> nodes;
  1177. final List<GraphEdge> edges;
  1178. final Map<String, Offset> positions;
  1179. GraphPainter(this.nodes, this.edges, this.positions);
  1180. @override
  1181. void paint(Canvas canvas, Size size) {
  1182. if (nodes.isEmpty) return;
  1183. const double nodeWidth = 160;
  1184. const double nodeHeight = 64;
  1185. final connectionPaint = Paint()
  1186. ..color = AppColors.border
  1187. ..strokeWidth = 1.5
  1188. ..style = PaintingStyle.stroke;
  1189. final arrowPaint = Paint()
  1190. ..color = AppColors.primary
  1191. ..style = PaintingStyle.fill;
  1192. for (final edge in edges) {
  1193. final source = positions[edge.source] ?? nodes.firstWhere((n) => n.id == edge.source).position;
  1194. final target = positions[edge.target] ?? nodes.firstWhere((n) => n.id == edge.target).position;
  1195. final sourceCenter = Offset(
  1196. source.dx + nodeWidth / 2,
  1197. source.dy + nodeHeight / 2,
  1198. );
  1199. final targetCenter = Offset(
  1200. target.dx + nodeWidth / 2,
  1201. target.dy + nodeHeight / 2,
  1202. );
  1203. canvas.drawLine(sourceCenter, targetCenter, connectionPaint);
  1204. final dx = targetCenter.dx - sourceCenter.dx;
  1205. final dy = targetCenter.dy - sourceCenter.dy;
  1206. final distance = math.sqrt(dx * dx + dy * dy);
  1207. if (distance < 0.001) continue;
  1208. final directionX = dx / distance;
  1209. final directionY = dy / distance;
  1210. const arrowLength = 12.0;
  1211. const arrowWidth = 6.0;
  1212. final arrowPoint = Offset(
  1213. targetCenter.dx - directionX * (nodeWidth / 2 - 4),
  1214. targetCenter.dy - directionY * (nodeHeight / 2 - 4),
  1215. );
  1216. final orthogonalX = -directionY;
  1217. final orthogonalY = directionX;
  1218. final path = Path()
  1219. ..moveTo(arrowPoint.dx, arrowPoint.dy)
  1220. ..lineTo(
  1221. arrowPoint.dx - directionX * arrowLength + orthogonalX * arrowWidth,
  1222. arrowPoint.dy - directionY * arrowLength + orthogonalY * arrowWidth,
  1223. )
  1224. ..lineTo(
  1225. arrowPoint.dx - directionX * arrowLength - orthogonalX * arrowWidth,
  1226. arrowPoint.dy - directionY * arrowLength - orthogonalY * arrowWidth,
  1227. )
  1228. ..close();
  1229. canvas.drawPath(path, arrowPaint);
  1230. }
  1231. }
  1232. @override
  1233. bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
  1234. }