| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298 |
- import 'dart:math' as math;
- import 'package:flutter/material.dart';
- import 'package:go_router/go_router.dart';
- import 'package:provider/provider.dart';
- import 'package:flutter_lucide/flutter_lucide.dart';
- import '../widgets/layout/app_layout.dart';
- import '../widgets/common/app_button.dart';
- import '../widgets/common/app_card.dart';
- import '../providers/document_provider.dart';
- import '../providers/element_provider.dart';
- import '../models/element.dart';
- import '../models/graph.dart';
- import '../services/mock_data_service.dart';
- import '../utils/constants.dart';
- import '../theme/app_colors.dart';
- /// 人工牵引页
- class TractionPage extends StatefulWidget {
- final String documentId;
- const TractionPage({Key? key, required this.documentId}) : super(key: key);
- @override
- State<TractionPage> createState() => _TractionPageState();
- }
- class _TractionPageState extends State<TractionPage> {
- ElementType? _filterType;
- List<GraphNode> _graphNodes = [];
- List<GraphEdge> _graphEdges = [];
- bool _showSelectedOnly = false;
- @override
- void initState() {
- super.initState();
- _graphNodes = MockDataService.getMockGraphNodes();
- _graphEdges = MockDataService.getMockGraphEdges();
- }
- @override
- Widget build(BuildContext context) {
- return Consumer2<DocumentProvider, ElementProvider>(
- builder: (context, docProvider, elementProvider, child) {
- final document = docProvider.getDocumentById(widget.documentId);
- final parsedText = document?.parsedText ?? _getMockText();
- final elements = elementProvider.filteredElements;
- return AppLayout(
- maxContentWidth: 1920,
- contentPadding: const EdgeInsets.symmetric(horizontal: 32, vertical: 32),
- child: Column(
- mainAxisSize: MainAxisSize.min,
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- const SizedBox(height: 24),
- // 标题栏
- _buildHeader(context, document),
- const SizedBox(height: 32),
- // 三栏布局
- _buildThreeColumnLayout(context, parsedText, elements),
- ],
- ),
- );
- },
- );
- }
- Widget _buildHeader(BuildContext context, document) {
- return Container(
- padding: const EdgeInsets.all(20),
- decoration: BoxDecoration(
- color: Colors.white,
- borderRadius: BorderRadius.circular(16),
- border: Border.all(color: AppColors.border),
- boxShadow: [
- BoxShadow(
- color: Colors.black.withOpacity(0.04),
- blurRadius: 12,
- offset: const Offset(0, 4),
- ),
- ],
- ),
- child: Row(
- children: [
- IconButton(
- icon: const Icon(Icons.arrow_back),
- onPressed: () => context.pop(),
- style: IconButton.styleFrom(
- backgroundColor: AppColors.backgroundLight,
- padding: const EdgeInsets.all(12),
- ),
- ),
- const SizedBox(width: 16),
- Expanded(
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- children: [
- Container(
- padding: const EdgeInsets.all(8),
- decoration: BoxDecoration(
- gradient: LinearGradient(
- colors: [AppColors.primary, AppColors.primaryLight],
- ),
- borderRadius: BorderRadius.circular(8),
- ),
- child: const Icon(
- LucideIcons.git_branch,
- color: Colors.white,
- size: 18,
- ),
- ),
- const SizedBox(width: 12),
- const Text(
- '人工牵引',
- style: TextStyle(
- fontSize: 22,
- fontWeight: FontWeight.bold,
- color: AppColors.textPrimary,
- ),
- ),
- ],
- ),
- const SizedBox(height: 8),
- Text(
- '从文档标记 → 标签池 → 拖拽构建逻辑',
- style: TextStyle(
- fontSize: 14,
- color: AppColors.textSecondary,
- ),
- ),
- ],
- ),
- ),
- AppButton(
- text: '保存',
- type: ButtonType.secondary,
- size: ButtonSize.small,
- icon: LucideIcons.save,
- onPressed: () {},
- ),
- ],
- ),
- );
- }
- Widget _buildThreeColumnLayout(
- BuildContext context,
- String text,
- List<DocumentElement> elements,
- ) {
- final screenWidth = MediaQuery.of(context).size.width;
- final isWide = screenWidth > AppConstants.desktopBreakpoint;
- final veryWide = screenWidth > 1920;
- final elementPanelWidth = veryWide ? 540.0 : 310.0;
- final relationPanelWidth = veryWide ? 560.0 : 400.0;
- final content = isWide
- ? Row(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // 左侧:要素池
- SizedBox(
- width: elementPanelWidth,
- child: _buildElementPool(context, elements),
- ),
- SizedBox(width: veryWide ? 28 : 24),
- // 中间:主文档区
- Expanded(
- flex: 3,
- child: _buildDocumentView(context, text, elements),
- ),
- SizedBox(width: veryWide ? 28 : 24),
- // 右侧:关系构建区
- SizedBox(
- width: relationPanelWidth,
- child: _buildRelationshipBuilder(context),
- ),
- ],
- )
- : Column(
- children: [
- _buildDocumentView(context, text, elements),
- const SizedBox(height: 20),
- _buildRelationshipBuilder(context),
- ],
- );
- return Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- // _buildActionBar(context),
- // const SizedBox(height: 20),
- content,
- ],
- );
- }
- Widget _buildActionBar(BuildContext context) {
- return Container(
- padding: const EdgeInsets.all(18),
- decoration: BoxDecoration(
- color: Colors.white,
- borderRadius: BorderRadius.circular(12),
- border: Border.all(color: AppColors.border),
- boxShadow: [
- BoxShadow(
- color: Colors.black.withOpacity(0.02),
- blurRadius: 12,
- offset: const Offset(0, 4),
- ),
- ],
- ),
- child: Wrap(
- spacing: 16,
- runSpacing: 12,
- crossAxisAlignment: WrapCrossAlignment.center,
- children: [
- Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Container(
- padding: const EdgeInsets.all(10),
- decoration: BoxDecoration(
- color: AppColors.primary.withOpacity(0.1),
- borderRadius: BorderRadius.circular(10),
- ),
- child: const Icon(
- LucideIcons.sparkles,
- color: AppColors.primary,
- size: 18,
- ),
- ),
- const SizedBox(width: 12),
- Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- '人工牵引操作台',
- style: Theme.of(context).textTheme.titleMedium?.copyWith(
- fontWeight: FontWeight.w600,
- color: AppColors.textPrimary,
- ),
- ),
- const SizedBox(height: 4),
- Text(
- '拖拽左侧要素到右侧画布,构建规则逻辑网络',
- style: Theme.of(context).textTheme.bodySmall?.copyWith(
- color: AppColors.textSecondary,
- ),
- ),
- ],
- ),
- ],
- ),
- const SizedBox(width: 12),
- AppButton(
- text: '自动布局',
- type: ButtonType.secondary,
- size: ButtonSize.small,
- icon: LucideIcons.layout_panel_left,
- onPressed: _autoLayoutGraph,
- ),
- AppButton(
- text: '对齐网格',
- type: ButtonType.secondary,
- size: ButtonSize.small,
- icon: LucideIcons.grid_3x3,
- onPressed: _alignNodesToGrid,
- ),
- AppButton(
- text: '导出节点',
- type: ButtonType.secondary,
- size: ButtonSize.small,
- icon: LucideIcons.download,
- onPressed: () {},
- ),
- ],
- ),
- );
- }
- void _autoLayoutGraph() {
- if (_graphNodes.isEmpty) return;
- const double horizontalGap = 240;
- const double verticalGap = 160;
- const double startX = 80;
- const double startY = 80;
- const int columns = 2;
- setState(() {
- for (int index = 0; index < _graphNodes.length; index++) {
- final node = _graphNodes[index];
- final col = index % columns;
- final row = index ~/ columns;
- final newPosition = Offset(
- startX + col * horizontalGap,
- startY + row * verticalGap,
- );
- _graphNodes[index] = node.copyWith(position: newPosition);
- }
- });
- }
- void _alignNodesToGrid() {
- if (_graphNodes.isEmpty) return;
- const double gridSize = 40;
- setState(() {
- for (int index = 0; index < _graphNodes.length; index++) {
- final node = _graphNodes[index];
- final alignedX = (node.position.dx / gridSize).roundToDouble() * gridSize;
- final alignedY = (node.position.dy / gridSize).roundToDouble() * gridSize;
- _graphNodes[index] = node.copyWith(position: Offset(alignedX, alignedY));
- }
- });
- }
- Widget _buildElementPool(BuildContext context, List<DocumentElement> elements) {
- final theme = Theme.of(context);
- final elementProvider = Provider.of<ElementProvider>(context);
- final allElements = elementProvider.elements;
- final selectedElements = elementProvider.selectedElements;
- final displayElements = _showSelectedOnly ? elements.where((e) => selectedElements.contains(e)).toList() : elements;
- final typeCounts = <ElementType, int>{
- for (final type in ElementType.values) type: allElements.where((e) => e.type == type).length,
- };
- final groupedElements = <ElementType, List<DocumentElement>>{};
- for (final type in ElementType.values) {
- final group = displayElements.where((element) => element.type == type).toList();
- if (group.isNotEmpty) {
- groupedElements[type] = group;
- }
- }
- return AppCard(
- title: Row(
- children: [
- const Icon(LucideIcons.tags, size: 18),
- const SizedBox(width: 8),
- const Text('要素池'),
- const Spacer(),
- Container(
- padding: const EdgeInsets.all(4),
- decoration: BoxDecoration(
- color: AppColors.primary.withOpacity(0.1),
- borderRadius: BorderRadius.circular(6),
- ),
- child: Text(
- '${elements.length}',
- style: TextStyle(
- fontSize: 11,
- fontWeight: FontWeight.bold,
- color: AppColors.primary,
- ),
- ),
- ),
- ],
- ),
- child: Container(
- padding: const EdgeInsets.all(15),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- mainAxisSize: MainAxisSize.min,
- children: [
- // 搜索与筛选
- Row(
- children: [
- Expanded(
- child: TextField(
- decoration: InputDecoration(
- hintText: '搜索要素或关键词...',
- prefixIcon: const Icon(LucideIcons.search, size: 18),
- isDense: true,
- contentPadding: const EdgeInsets.symmetric(
- horizontal: 14,
- vertical: 12,
- ),
- border: OutlineInputBorder(
- borderRadius: BorderRadius.circular(10),
- borderSide: const BorderSide(color: AppColors.border),
- ),
- enabledBorder: OutlineInputBorder(
- borderRadius: BorderRadius.circular(10),
- borderSide: const BorderSide(color: AppColors.border),
- ),
- focusedBorder: OutlineInputBorder(
- borderRadius: BorderRadius.circular(10),
- borderSide: const BorderSide(
- color: AppColors.primary,
- width: 2,
- ),
- ),
- ),
- onChanged: (value) {
- Provider.of<ElementProvider>(context, listen: false).setSearchQuery(value);
- },
- ),
- ),
- const SizedBox(width: 12),
- Tooltip(
- message: '只查看已选择的要素',
- child: InkWell(
- onTap: () {
- setState(() {
- _showSelectedOnly = !_showSelectedOnly;
- });
- },
- borderRadius: BorderRadius.circular(12),
- child: Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 12,
- vertical: 10,
- ),
- decoration: BoxDecoration(
- color: _showSelectedOnly ? AppColors.primary.withOpacity(0.15) : AppColors.borderLight,
- borderRadius: BorderRadius.circular(12),
- border: Border.all(
- color: _showSelectedOnly ? AppColors.primary : AppColors.borderLight,
- ),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(
- LucideIcons.circle_check,
- size: 16,
- color: _showSelectedOnly ? AppColors.primary : AppColors.textSecondary,
- ),
- const SizedBox(width: 6),
- Text(
- '仅已选',
- style: theme.textTheme.bodySmall?.copyWith(
- color: _showSelectedOnly ? AppColors.primary : AppColors.textSecondary,
- fontWeight: FontWeight.w600,
- ),
- ),
- ],
- ),
- ),
- ),
- ),
- ],
- ),
- const SizedBox(height: 16),
- // 类型筛选
- Wrap(
- spacing: 8,
- runSpacing: 8,
- children: [
- _buildFilterChip('全部', null, count: allElements.length),
- _buildFilterChip('金额', ElementType.amount, count: typeCounts[ElementType.amount] ?? 0),
- _buildFilterChip('公司', ElementType.company, count: typeCounts[ElementType.company] ?? 0),
- _buildFilterChip('人名', ElementType.person, count: typeCounts[ElementType.person] ?? 0),
- _buildFilterChip('地名', ElementType.location, count: typeCounts[ElementType.location] ?? 0),
- _buildFilterChip('日期', ElementType.date, count: typeCounts[ElementType.date] ?? 0),
- _buildFilterChip('其他', ElementType.other, count: typeCounts[ElementType.other] ?? 0),
- ],
- ),
- const SizedBox(height: 20),
- // 要素列表
- SizedBox(
- height: 340,
- child: displayElements.isEmpty
- ? _buildEmptyElementState()
- : ListView(
- padding: EdgeInsets.zero,
- children: groupedElements.entries
- .map(
- (entry) => _buildElementGroupSection(
- entry.key,
- entry.value,
- selectedElements,
- ),
- )
- .toList(),
- ),
- ),
- const SizedBox(height: 16),
- AppButton(
- text: '添加要素',
- type: ButtonType.primary,
- size: ButtonSize.small,
- icon: LucideIcons.plus,
- fullWidth: true,
- onPressed: () {},
- ),
- ],
- ),
- ),
- );
- }
- Widget _buildFilterChip(String label, ElementType? type, {int? count}) {
- final isSelected = _filterType == type;
- final displayLabel = count != null ? '$label · $count' : label;
- return FilterChip(
- label: Text(displayLabel),
- selected: isSelected,
- onSelected: (selected) {
- setState(() {
- _filterType = selected ? type : null;
- });
- Provider.of<ElementProvider>(context, listen: false).setFilterType(_filterType);
- },
- selectedColor: AppColors.primary.withOpacity(0.2),
- checkmarkColor: AppColors.primary,
- labelStyle: TextStyle(
- fontSize: 10,
- fontWeight: isSelected ? FontWeight.w600 : FontWeight.normal,
- color: isSelected ? AppColors.primary : AppColors.textSecondary,
- ),
- );
- }
- Widget _buildDraggableElement(DocumentElement element, List<DocumentElement> selectedElements) {
- final elementProvider = Provider.of<ElementProvider>(context, listen: false);
- final isSelected = selectedElements.contains(element);
- final color = _getElementColor(element.type);
- return LongPressDraggable<DocumentElement>(
- data: element,
- onDragStarted: () {
- if (!isSelected) {
- elementProvider.selectElement(element);
- }
- },
- feedback: Material(
- color: Colors.transparent,
- child: ConstrainedBox(
- constraints: const BoxConstraints(maxWidth: 260),
- child: _buildElementTile(
- context,
- element,
- color,
- true,
- isFeedback: true,
- ),
- ),
- ),
- childWhenDragging: Opacity(
- opacity: 0.4,
- child: _buildElementTile(context, element, color, isSelected),
- ),
- child: GestureDetector(
- onTap: () {
- if (isSelected) {
- elementProvider.deselectElement(element);
- } else {
- elementProvider.selectElement(element);
- }
- },
- child: _buildElementTile(context, element, color, isSelected),
- ),
- );
- }
- Widget _buildElementTile(
- BuildContext context,
- DocumentElement element,
- Color color,
- bool isSelected, {
- bool isFeedback = false,
- }) {
- final theme = Theme.of(context);
- return Container(
- margin: const EdgeInsets.only(bottom: 10),
- padding: const EdgeInsets.all(14),
- decoration: BoxDecoration(
- color: isSelected ? color.withOpacity(0.12) : AppColors.backgroundLight,
- borderRadius: BorderRadius.circular(12),
- border: Border.all(
- color: isSelected ? color : AppColors.borderLight,
- width: isSelected ? 1.5 : 1,
- ),
- boxShadow: [
- if (isSelected)
- BoxShadow(
- color: color.withOpacity(0.2),
- blurRadius: 12,
- offset: const Offset(0, 4),
- ),
- if (isFeedback)
- BoxShadow(
- color: Colors.black.withOpacity(0.15),
- blurRadius: 16,
- offset: const Offset(0, 6),
- ),
- ],
- ),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Row(
- crossAxisAlignment: CrossAxisAlignment.center,
- children: [
- Container(
- padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
- decoration: BoxDecoration(
- color: color.withOpacity(0.16),
- borderRadius: BorderRadius.circular(8),
- ),
- child: Text(
- element.type.label,
- style: theme.textTheme.bodySmall?.copyWith(
- color: color,
- fontWeight: FontWeight.w600,
- ),
- ),
- ),
- const SizedBox(width: 8),
- Expanded(
- child: Text(
- element.label,
- maxLines: 1,
- overflow: TextOverflow.ellipsis,
- style: theme.textTheme.titleSmall?.copyWith(
- fontWeight: FontWeight.w600,
- color: AppColors.textPrimary,
- ),
- ),
- ),
- const SizedBox(width: 6),
- Icon(
- LucideIcons.grip_vertical,
- size: 16,
- color: AppColors.textSecondary,
- ),
- ],
- ),
- const SizedBox(height: 10),
- Text(
- element.value,
- maxLines: 3,
- overflow: TextOverflow.ellipsis,
- style: theme.textTheme.bodyMedium?.copyWith(
- color: AppColors.textPrimary,
- height: 1.5,
- ),
- ),
- const SizedBox(height: 12),
- Row(
- children: [
- Icon(
- _getElementIcon(element.type),
- size: 16,
- color: color,
- ),
- const SizedBox(width: 6),
- Text(
- element.documentId ?? '未关联文档',
- style: theme.textTheme.bodySmall?.copyWith(
- color: AppColors.textSecondary,
- ),
- ),
- const Spacer(),
- if (isSelected)
- Container(
- padding: const EdgeInsets.symmetric(
- horizontal: 8,
- vertical: 4,
- ),
- decoration: BoxDecoration(
- color: color.withOpacity(0.18),
- borderRadius: BorderRadius.circular(8),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(
- LucideIcons.check,
- size: 14,
- color: color,
- ),
- const SizedBox(width: 4),
- Text(
- '已选中',
- style: theme.textTheme.bodySmall?.copyWith(
- color: color,
- fontWeight: FontWeight.w600,
- ),
- ),
- ],
- ),
- ),
- ],
- ),
- ],
- ),
- );
- }
- Widget _buildElementGroupSection(
- ElementType type,
- List<DocumentElement> items,
- List<DocumentElement> selectedElements,
- ) {
- final color = _getElementColor(type);
- return Padding(
- padding: const EdgeInsets.only(bottom: 20),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Container(
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
- decoration: BoxDecoration(
- color: color.withOpacity(0.1),
- borderRadius: BorderRadius.circular(10),
- ),
- child: Row(
- children: [
- Icon(_getElementIcon(type), size: 16, color: color),
- const SizedBox(width: 8),
- Text(
- '${type.label} · ${items.length}',
- style: TextStyle(
- fontSize: 13,
- fontWeight: FontWeight.w600,
- color: color,
- ),
- ),
- ],
- ),
- ),
- const SizedBox(height: 12),
- for (final item in items) _buildDraggableElement(item, selectedElements),
- ],
- ),
- );
- }
- Widget _buildStatBadge({
- required IconData icon,
- required String label,
- required String value,
- required Color color,
- }) {
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
- decoration: BoxDecoration(
- color: color.withOpacity(0.12),
- borderRadius: BorderRadius.circular(12),
- border: Border.all(color: color.withOpacity(0.2)),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(icon, size: 16, color: color),
- const SizedBox(width: 8),
- Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- label,
- style: const TextStyle(
- fontSize: 11,
- color: AppColors.textSecondary,
- ),
- ),
- Text(
- value,
- style: TextStyle(
- fontSize: 15,
- fontWeight: FontWeight.w700,
- color: color,
- ),
- ),
- ],
- ),
- ],
- ),
- );
- }
- Widget _buildEmptyElementState() {
- return Container(
- alignment: Alignment.center,
- decoration: BoxDecoration(
- color: AppColors.backgroundLight,
- borderRadius: BorderRadius.circular(12),
- border: Border.all(color: AppColors.borderLight),
- ),
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(
- LucideIcons.inbox,
- size: 48,
- color: AppColors.textSecondary.withOpacity(0.4),
- ),
- const SizedBox(height: 12),
- const Text(
- '暂无匹配的要素',
- style: TextStyle(
- fontSize: 14,
- fontWeight: FontWeight.w600,
- color: AppColors.textPrimary,
- ),
- ),
- const SizedBox(height: 6),
- Text(
- _showSelectedOnly ? '切换回全部或调整筛选条件试试' : '尝试更换搜索关键字或筛选条件',
- style: const TextStyle(
- fontSize: 12,
- color: AppColors.textSecondary,
- ),
- ),
- ],
- ),
- );
- }
- IconData _getElementIcon(ElementType type) {
- switch (type) {
- case ElementType.amount:
- return LucideIcons.dollar_sign;
- case ElementType.company:
- return LucideIcons.building;
- case ElementType.person:
- return LucideIcons.user;
- case ElementType.location:
- return LucideIcons.map_pin;
- default:
- return LucideIcons.tag;
- }
- }
- Widget _buildDocumentView(
- BuildContext context,
- String text,
- List<DocumentElement> elements,
- ) {
- return AppCard(
- title: Row(
- children: [
- const Icon(LucideIcons.file_text, size: 18),
- const SizedBox(width: 8),
- const Text('主文档'),
- const Spacer(),
- Container(
- padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
- decoration: BoxDecoration(
- color: AppColors.primary.withOpacity(0.1),
- borderRadius: BorderRadius.circular(6),
- ),
- child: Row(
- mainAxisSize: MainAxisSize.min,
- children: [
- Icon(
- LucideIcons.mouse_pointer_click,
- size: 12,
- color: AppColors.primary,
- ),
- const SizedBox(width: 4),
- Text(
- '点击高亮区域选择',
- style: TextStyle(
- fontSize: 11,
- fontWeight: FontWeight.w600,
- color: AppColors.primary,
- ),
- ),
- ],
- ),
- ),
- ],
- ),
- child: Container(
- constraints: const BoxConstraints(minHeight: 600),
- padding: const EdgeInsets.all(24),
- decoration: BoxDecoration(
- color: Colors.white,
- borderRadius: BorderRadius.circular(12),
- border: Border.all(color: AppColors.border),
- ),
- child: SelectableText.rich(
- TextSpan(
- children: _buildTextSpans(text, elements),
- style: const TextStyle(
- fontSize: 15,
- height: 1.8,
- color: AppColors.textPrimary,
- ),
- ),
- ),
- ),
- );
- }
- List<TextSpan> _buildTextSpans(
- String text,
- List<DocumentElement> elements,
- ) {
- final spans = <TextSpan>[];
- int lastIndex = 0;
- for (final element in elements) {
- final index = text.indexOf(element.value, lastIndex);
- if (index != -1) {
- if (index > lastIndex) {
- spans.add(TextSpan(text: text.substring(lastIndex, index)));
- }
- spans.add(
- TextSpan(
- text: element.value,
- style: TextStyle(
- backgroundColor: _getElementColor(element.type).withOpacity(0.25),
- color: _getElementColor(element.type),
- fontWeight: FontWeight.w600,
- decoration: TextDecoration.underline,
- decorationColor: _getElementColor(element.type),
- ),
- mouseCursor: SystemMouseCursors.click,
- ),
- );
- lastIndex = index + element.value.length;
- }
- }
- if (lastIndex < text.length) {
- spans.add(TextSpan(text: text.substring(lastIndex)));
- }
- return spans.isEmpty ? [TextSpan(text: text)] : spans;
- }
- Color _getElementColor(ElementType type) {
- switch (type) {
- case ElementType.amount:
- return AppColors.amount;
- case ElementType.company:
- return AppColors.company;
- case ElementType.person:
- return AppColors.person;
- case ElementType.location:
- return AppColors.location;
- default:
- return AppColors.textSecondary;
- }
- }
- Widget _buildRelationshipBuilder(BuildContext context) {
- return Column(
- children: [
- AppCard(
- title: Row(
- children: [
- const Icon(LucideIcons.git_branch, size: 18),
- const SizedBox(width: 8),
- const Text('关系构建'),
- const Spacer(),
- Container(
- padding: const EdgeInsets.all(4),
- decoration: BoxDecoration(
- color: AppColors.warning.withOpacity(0.1),
- borderRadius: BorderRadius.circular(6),
- ),
- child: Text(
- '${_graphNodes.length} 节点',
- style: TextStyle(
- fontSize: 11,
- fontWeight: FontWeight.bold,
- color: AppColors.warning,
- ),
- ),
- ),
- ],
- ),
- child: Container(
- padding: const EdgeInsets.all(15),
- child: Column(
- crossAxisAlignment: CrossAxisAlignment.start,
- children: [
- Text(
- '拖拽要素到此处构建关系网络',
- style: TextStyle(
- fontSize: 12,
- color: AppColors.textSecondary,
- ),
- ),
- const SizedBox(height: 16),
- // 关系网络图
- DragTarget<DocumentElement>(
- onAccept: (element) {
- setState(() {
- _graphNodes.add(
- GraphNode(
- id: 'n${_graphNodes.length + 1}',
- label: element.displayText,
- type: NodeType.element,
- position: Offset(
- 100 + (_graphNodes.length * 80).toDouble(),
- 120 + (_graphNodes.length * 60).toDouble(),
- ),
- ),
- );
- });
- },
- builder: (context, candidateData, rejectedData) {
- final isHovering = candidateData.isNotEmpty;
- return AnimatedContainer(
- duration: const Duration(milliseconds: 180),
- height: 400,
- decoration: BoxDecoration(
- gradient: LinearGradient(
- begin: Alignment.topLeft,
- end: Alignment.bottomRight,
- colors: [
- AppColors.background,
- Colors.white,
- ],
- ),
- borderRadius: BorderRadius.circular(12),
- border: Border.all(
- color: isHovering ? AppColors.primary : AppColors.border,
- width: isHovering ? 2 : 1,
- ),
- ),
- clipBehavior: Clip.hardEdge,
- child: LayoutBuilder(
- builder: (context, constraints) {
- final canvasWidth = math.max(constraints.maxWidth, 600.0);
- final canvasHeight = math.max(constraints.maxHeight, 420.0);
- return InteractiveViewer(
- constrained: false,
- minScale: 0.5,
- maxScale: 2.5,
- boundaryMargin: const EdgeInsets.all(200),
- child: SizedBox(
- width: canvasWidth,
- height: canvasHeight,
- child: _buildGraphCanvas(
- Size(canvasWidth, canvasHeight),
- ),
- ),
- );
- },
- ),
- );
- },
- ),
- const SizedBox(height: 16),
- // 关系类型选择
- Wrap(
- spacing: 8,
- runSpacing: 8,
- children: [
- _buildRelationChip('计算', EdgeType.calculate),
- _buildRelationChip('引用', EdgeType.reference),
- _buildRelationChip('包含', EdgeType.contain),
- ],
- ),
- ],
- ),
- )),
- const SizedBox(height: 16),
- AppCard(
- child: Column(
- children: [
- AppButton(
- text: '生成Prompt',
- type: ButtonType.primary,
- icon: LucideIcons.sparkles,
- fullWidth: true,
- onPressed: () {
- context.push('${AppRoutes.result}/${widget.documentId}');
- },
- ),
- const SizedBox(height: 12),
- AppButton(
- text: '清空关系',
- type: ButtonType.secondary,
- icon: LucideIcons.trash_2,
- fullWidth: true,
- onPressed: () {
- setState(() {
- _graphNodes.clear();
- _graphEdges.clear();
- });
- },
- ),
- ],
- ),
- ),
- ],
- );
- }
- Widget _buildGraphCanvas(Size canvasSize) {
- if (_graphNodes.isEmpty) {
- return SizedBox.expand(
- child: Center(
- child: Column(
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- Icon(
- LucideIcons.git_branch,
- size: 64,
- color: AppColors.textSecondary.withOpacity(0.5),
- ),
- const SizedBox(height: 16),
- Text(
- '拖拽要素到此处',
- style: TextStyle(
- fontSize: 14,
- color: AppColors.textSecondary,
- ),
- ),
- const SizedBox(height: 8),
- Text(
- '开始构建关系网络',
- style: TextStyle(
- fontSize: 12,
- color: AppColors.textSecondary.withOpacity(0.7),
- ),
- ),
- ],
- ),
- ),
- );
- }
- final positions = <String, Offset>{};
- for (final node in _graphNodes) {
- positions[node.id] = _clampOffset(node.position, canvasSize);
- }
- return CustomPaint(
- size: canvasSize,
- painter: GraphPainter(_graphNodes, _graphEdges, positions),
- child: Stack(
- clipBehavior: Clip.none,
- children: _graphNodes.map((node) {
- final position = positions[node.id] ?? node.position;
- return Positioned(
- left: position.dx,
- top: position.dy,
- child: _buildGraphNode(node),
- );
- }).toList(),
- ),
- );
- }
- Offset _clampOffset(Offset position, Size canvasSize) {
- const double padding = 24;
- const double nodeWidth = 160;
- const double nodeHeight = 64;
- final double maxX = math.max(padding, canvasSize.width - nodeWidth - padding);
- final double maxY = math.max(padding, canvasSize.height - nodeHeight - padding);
- return Offset(
- position.dx.clamp(padding, maxX),
- position.dy.clamp(padding, maxY),
- );
- }
- Widget _buildGraphNode(GraphNode node) {
- Color getNodeColor() {
- switch (node.type) {
- case NodeType.element:
- return AppColors.primary;
- case NodeType.operator:
- return AppColors.warning;
- case NodeType.result:
- return AppColors.success;
- }
- }
- return Container(
- padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
- decoration: BoxDecoration(
- color: getNodeColor(),
- borderRadius: BorderRadius.circular(8),
- boxShadow: [
- BoxShadow(
- color: getNodeColor().withOpacity(0.3),
- blurRadius: 8,
- offset: const Offset(0, 2),
- ),
- ],
- ),
- child: Text(
- node.label,
- style: const TextStyle(
- color: Colors.white,
- fontSize: 12,
- fontWeight: FontWeight.w600,
- ),
- ),
- );
- }
- Widget _buildRelationChip(String label, EdgeType type) {
- return ChoiceChip(
- label: Text(label),
- selected: false,
- onSelected: (selected) {},
- labelStyle: const TextStyle(fontSize: 12),
- );
- }
- String _getMockText() {
- return '''一、项目概述
- 本项目由腾讯科技有限公司(以下简称"腾讯")投资建设,旨在打造一套面向大型企业财务共享中心的"智能票据处理系统"。系统通过OCR识别、版面理解、要素抽取与人机协同核验,提升发票、合同、报销单据等票据类文档的处理效率与准确率。
- 二、投资与资金安排
- 项目总投资金额为人民币3,700万元,其中已支付1,000万元用于样机研制、数据治理与前期试点。剩余资金将依据阶段性里程碑进行分批拨付,涵盖核心算法优化、系统平台化改造、以及与客户生态的对接集成。
- 三、技术与产品路线
- 系统采用多模态文档理解技术,将版面结构、语义上下文与业务词典结合,实现对复杂票据的鲁棒解析。产品形态包含:上传解析端、人工牵引标注端、规则与关系构建端,以及结果交付端。
- 四、收益预期
- 预计上线后,票据处理效率提升60%以上,人工核验工作量下降40%,并显著降低AI"幻觉"导致的错判风险,从而提升财务数据的可靠性与可审计性。''';
- }
- }
- /// 关系网络绘制器
- class GraphPainter extends CustomPainter {
- final List<GraphNode> nodes;
- final List<GraphEdge> edges;
- final Map<String, Offset> positions;
- GraphPainter(this.nodes, this.edges, this.positions);
- @override
- void paint(Canvas canvas, Size size) {
- if (nodes.isEmpty) return;
- const double nodeWidth = 160;
- const double nodeHeight = 64;
- final connectionPaint = Paint()
- ..color = AppColors.border
- ..strokeWidth = 1.5
- ..style = PaintingStyle.stroke;
- final arrowPaint = Paint()
- ..color = AppColors.primary
- ..style = PaintingStyle.fill;
- for (final edge in edges) {
- final source = positions[edge.source] ?? nodes.firstWhere((n) => n.id == edge.source).position;
- final target = positions[edge.target] ?? nodes.firstWhere((n) => n.id == edge.target).position;
- final sourceCenter = Offset(
- source.dx + nodeWidth / 2,
- source.dy + nodeHeight / 2,
- );
- final targetCenter = Offset(
- target.dx + nodeWidth / 2,
- target.dy + nodeHeight / 2,
- );
- canvas.drawLine(sourceCenter, targetCenter, connectionPaint);
- final dx = targetCenter.dx - sourceCenter.dx;
- final dy = targetCenter.dy - sourceCenter.dy;
- final distance = math.sqrt(dx * dx + dy * dy);
- if (distance < 0.001) continue;
- final directionX = dx / distance;
- final directionY = dy / distance;
- const arrowLength = 12.0;
- const arrowWidth = 6.0;
- final arrowPoint = Offset(
- targetCenter.dx - directionX * (nodeWidth / 2 - 4),
- targetCenter.dy - directionY * (nodeHeight / 2 - 4),
- );
- final orthogonalX = -directionY;
- final orthogonalY = directionX;
- final path = Path()
- ..moveTo(arrowPoint.dx, arrowPoint.dy)
- ..lineTo(
- arrowPoint.dx - directionX * arrowLength + orthogonalX * arrowWidth,
- arrowPoint.dy - directionY * arrowLength + orthogonalY * arrowWidth,
- )
- ..lineTo(
- arrowPoint.dx - directionX * arrowLength - orthogonalX * arrowWidth,
- arrowPoint.dy - directionY * arrowLength - orthogonalY * arrowWidth,
- )
- ..close();
- canvas.drawPath(path, arrowPaint);
- }
- }
- @override
- bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
- }
|