result_page.dart 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629
  1. import 'package:flutter/material.dart';
  2. import 'package:flutter/services.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 '../providers/document_provider.dart';
  9. import '../providers/element_provider.dart';
  10. import '../models/element.dart';
  11. import '../services/mock_data_service.dart';
  12. import '../theme/app_colors.dart';
  13. /// AI结果页
  14. class ResultPage extends StatefulWidget {
  15. final String documentId;
  16. const ResultPage({Key? key, required this.documentId}) : super(key: key);
  17. @override
  18. State<ResultPage> createState() => _ResultPageState();
  19. }
  20. class _ResultPageState extends State<ResultPage> {
  21. final prompt = MockDataService.getMockPrompt();
  22. final response = MockDataService.getMockAIResponse();
  23. bool _isRegenerating = false;
  24. bool _isSending = false;
  25. late final TextEditingController _inputController;
  26. @override
  27. void initState() {
  28. super.initState();
  29. _inputController = TextEditingController();
  30. }
  31. @override
  32. void dispose() {
  33. _inputController.dispose();
  34. super.dispose();
  35. }
  36. @override
  37. Widget build(BuildContext context) {
  38. return Consumer2<DocumentProvider, ElementProvider>(
  39. builder: (context, docProvider, elementProvider, child) {
  40. final document = docProvider.getDocumentById(widget.documentId);
  41. var documentElements = elementProvider.elements
  42. .where(
  43. (element) =>
  44. element.documentId == widget.documentId ||
  45. element.documentId == null,
  46. )
  47. .toList();
  48. if (documentElements.isEmpty) {
  49. documentElements = elementProvider.elements.take(10).toList();
  50. }
  51. return AppLayout(
  52. maxContentWidth: 1200,
  53. child: Column(
  54. crossAxisAlignment: CrossAxisAlignment.start,
  55. children: [
  56. _buildHeader(context, document),
  57. const SizedBox(height: 24),
  58. _buildChatInterface(context),
  59. const SizedBox(height: 32),
  60. _buildActionComposer(context, documentElements),
  61. ],
  62. ),
  63. );
  64. },
  65. );
  66. }
  67. Widget _buildHeader(BuildContext context, document) {
  68. return Container(
  69. padding: const EdgeInsets.all(20),
  70. decoration: BoxDecoration(
  71. color: Colors.white,
  72. borderRadius: BorderRadius.circular(16),
  73. border: Border.all(color: AppColors.border),
  74. boxShadow: [
  75. BoxShadow(
  76. color: Colors.black.withOpacity(0.04),
  77. blurRadius: 12,
  78. offset: const Offset(0, 4),
  79. ),
  80. ],
  81. ),
  82. child: Row(
  83. children: [
  84. IconButton(
  85. icon: const Icon(Icons.arrow_back),
  86. onPressed: () => context.pop(),
  87. style: IconButton.styleFrom(
  88. backgroundColor: AppColors.backgroundLight,
  89. padding: const EdgeInsets.all(12),
  90. ),
  91. ),
  92. const SizedBox(width: 16),
  93. Expanded(
  94. child: Column(
  95. crossAxisAlignment: CrossAxisAlignment.start,
  96. children: [
  97. Row(
  98. children: [
  99. Container(
  100. padding: const EdgeInsets.all(8),
  101. decoration: BoxDecoration(
  102. gradient: LinearGradient(
  103. colors: [AppColors.success, Colors.green.shade300],
  104. ),
  105. borderRadius: BorderRadius.circular(8),
  106. ),
  107. child: const Icon(
  108. LucideIcons.circle_check,
  109. color: Colors.white,
  110. size: 18,
  111. ),
  112. ),
  113. const SizedBox(width: 12),
  114. const Text(
  115. 'AI处理结果',
  116. style: TextStyle(
  117. fontSize: 22,
  118. fontWeight: FontWeight.bold,
  119. color: AppColors.textPrimary,
  120. ),
  121. ),
  122. ],
  123. ),
  124. const SizedBox(height: 8),
  125. Text(
  126. '基于人工牵引生成的精准Prompt与AI回复',
  127. style: TextStyle(
  128. fontSize: 14,
  129. color: AppColors.textSecondary,
  130. ),
  131. ),
  132. ],
  133. ),
  134. ),
  135. AppButton(
  136. text: '导出',
  137. type: ButtonType.secondary,
  138. size: ButtonSize.small,
  139. icon: LucideIcons.download,
  140. onPressed: () {},
  141. ),
  142. ],
  143. ),
  144. );
  145. }
  146. Widget _buildChatInterface(BuildContext context) {
  147. return Expanded(
  148. child: Container(
  149. padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 28),
  150. decoration: BoxDecoration(
  151. color: const Color(0xFFF7F8FA),
  152. borderRadius: BorderRadius.circular(18),
  153. border: Border.all(color: AppColors.borderLight),
  154. boxShadow: [
  155. BoxShadow(
  156. color: Colors.black.withOpacity(0.02),
  157. blurRadius: 18,
  158. offset: const Offset(0, 8),
  159. ),
  160. ],
  161. ),
  162. child: SingleChildScrollView( // 改为可滚动
  163. child: Column(
  164. crossAxisAlignment: CrossAxisAlignment.stretch,
  165. children: [
  166. _buildChatMessage(
  167. context,
  168. isAI: false,
  169. title: '系统生成的精准 Prompt',
  170. content: prompt,
  171. icon: LucideIcons.sparkles,
  172. ),
  173. const SizedBox(height: 28),
  174. _buildChatMessage(
  175. context,
  176. isAI: true,
  177. title: 'AI 回复',
  178. content: response,
  179. icon: LucideIcons.bot,
  180. ),
  181. // 可以继续添加更多消息
  182. ],
  183. ),
  184. ),
  185. ),
  186. );
  187. }
  188. Widget _buildChatMessage(
  189. BuildContext context, {
  190. required bool isAI,
  191. required String title,
  192. required String content,
  193. required IconData icon,
  194. }) {
  195. final theme = Theme.of(context);
  196. final bubbleColor =
  197. isAI ? Colors.white : AppColors.primary.withOpacity(0.08);
  198. final borderColor =
  199. isAI ? AppColors.borderLight : AppColors.primary.withOpacity(0.25);
  200. final bubble = Container(
  201. constraints: const BoxConstraints(maxWidth: 760),
  202. padding: const EdgeInsets.all(20),
  203. decoration: BoxDecoration(
  204. color: bubbleColor,
  205. borderRadius: BorderRadius.only(
  206. topLeft: const Radius.circular(20),
  207. topRight: const Radius.circular(20),
  208. bottomLeft: Radius.circular(isAI ? 20 : 6),
  209. bottomRight: Radius.circular(isAI ? 6 : 20),
  210. ),
  211. border: Border.all(color: borderColor),
  212. boxShadow: [
  213. BoxShadow(
  214. color: Colors.black.withOpacity(0.04),
  215. blurRadius: 12,
  216. offset: const Offset(0, 6),
  217. ),
  218. ],
  219. ),
  220. child: Column(
  221. crossAxisAlignment: CrossAxisAlignment.start,
  222. children: [
  223. Row(
  224. children: [
  225. Icon(
  226. icon,
  227. size: 18,
  228. color: isAI ? AppColors.success : AppColors.primary,
  229. ),
  230. const SizedBox(width: 8),
  231. Text(
  232. title,
  233. style: theme.textTheme.titleSmall?.copyWith(
  234. fontWeight: FontWeight.w600,
  235. color: AppColors.textPrimary,
  236. ),
  237. ),
  238. if (!isAI) ...[
  239. const SizedBox(width: 8),
  240. Container(
  241. padding:
  242. const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
  243. decoration: BoxDecoration(
  244. color: AppColors.primary.withOpacity(0.12),
  245. borderRadius: BorderRadius.circular(20),
  246. ),
  247. child: Text(
  248. '系统生成',
  249. style: theme.textTheme.bodySmall?.copyWith(
  250. fontSize: 11,
  251. color: AppColors.primary,
  252. fontWeight: FontWeight.w600,
  253. ),
  254. ),
  255. ),
  256. ],
  257. ],
  258. ),
  259. const SizedBox(height: 12),
  260. SelectableText.rich(
  261. TextSpan(
  262. children: _buildFormattedContent(content),
  263. style: const TextStyle(
  264. fontSize: 15,
  265. height: 1.8,
  266. color: AppColors.textPrimary,
  267. ),
  268. ),
  269. ),
  270. ],
  271. ),
  272. );
  273. final avatar = CircleAvatar(
  274. radius: 20,
  275. backgroundColor: isAI
  276. ? AppColors.success.withOpacity(0.15)
  277. : AppColors.primary.withOpacity(0.15),
  278. child: Icon(
  279. icon,
  280. color: isAI ? AppColors.success : AppColors.primary,
  281. size: 20,
  282. ),
  283. );
  284. return Row(
  285. crossAxisAlignment: CrossAxisAlignment.start,
  286. mainAxisAlignment: isAI ? MainAxisAlignment.start : MainAxisAlignment.end,
  287. children: [
  288. if (isAI) ...[
  289. avatar,
  290. const SizedBox(width: 12),
  291. ],
  292. Flexible(child: bubble),
  293. if (!isAI) ...[
  294. const SizedBox(width: 12),
  295. avatar,
  296. ],
  297. ],
  298. );
  299. }
  300. List<TextSpan> _buildFormattedContent(String content) {
  301. final lines = content.split('\n');
  302. final spans = <TextSpan>[];
  303. for (int i = 0; i < lines.length; i++) {
  304. final line = lines[i];
  305. if (line.trim().isEmpty) {
  306. spans.add(const TextSpan(text: '\n'));
  307. continue;
  308. }
  309. // 检测公式或计算结果
  310. if (line.contains('=') || line.contains('计算')) {
  311. spans.add(
  312. TextSpan(
  313. text: line,
  314. style: TextStyle(
  315. color: AppColors.primary,
  316. fontWeight: FontWeight.w600,
  317. backgroundColor: AppColors.primary.withOpacity(0.1),
  318. ),
  319. ),
  320. );
  321. } else if (line.startsWith('-') || line.startsWith('•')) {
  322. // 列表项
  323. spans.add(
  324. TextSpan(
  325. text: line,
  326. style: const TextStyle(
  327. fontWeight: FontWeight.w500,
  328. ),
  329. ),
  330. );
  331. } else {
  332. spans.add(TextSpan(text: line));
  333. }
  334. if (i < lines.length - 1) {
  335. spans.add(const TextSpan(text: '\n'));
  336. }
  337. }
  338. return spans;
  339. }
  340. Widget _buildActionComposer(
  341. BuildContext context,
  342. List<DocumentElement> elements,
  343. ) {
  344. final theme = Theme.of(context);
  345. final hasElements = elements.isNotEmpty;
  346. return Container(
  347. padding: const EdgeInsets.all(10),
  348. decoration: BoxDecoration(
  349. color: Colors.white,
  350. borderRadius: BorderRadius.circular(18),
  351. border: Border.all(color: AppColors.borderLight),
  352. boxShadow: [
  353. BoxShadow(
  354. color: Colors.black.withOpacity(0.03),
  355. blurRadius: 18,
  356. offset: const Offset(0, 10),
  357. ),
  358. ],
  359. ),
  360. child: Column(
  361. crossAxisAlignment: CrossAxisAlignment.start,
  362. children: [
  363. Row(
  364. children: [
  365. Container(
  366. padding:
  367. const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
  368. decoration: BoxDecoration(
  369. color: AppColors.primary.withOpacity(0.12),
  370. borderRadius: BorderRadius.circular(20),
  371. ),
  372. child: Row(
  373. children: [
  374. Icon(
  375. LucideIcons.sparkles,
  376. size: 16,
  377. color: AppColors.primary,
  378. ),
  379. const SizedBox(width: 6),
  380. Text(
  381. '生成新的 AI 回复',
  382. style: theme.textTheme.bodySmall?.copyWith(
  383. color: AppColors.primary,
  384. fontWeight: FontWeight.w600,
  385. ),
  386. ),
  387. ],
  388. ),
  389. ),
  390. const SizedBox(width: 12),
  391. Text(
  392. '可结合要素补充或修改 Prompt 内容',
  393. style: theme.textTheme.bodySmall?.copyWith(
  394. color: AppColors.textSecondary,
  395. ),
  396. ),
  397. ],
  398. ),
  399. if (hasElements) ...[
  400. const SizedBox(height: 16),
  401. SizedBox(
  402. height: 44,
  403. child: SingleChildScrollView(
  404. scrollDirection: Axis.horizontal,
  405. child: Row(
  406. children: [
  407. for (int i = 0; i < elements.length; i++) ...[
  408. if (i > 0) const SizedBox(width: 8),
  409. _buildElementChip(elements[i]),
  410. ],
  411. ],
  412. ),
  413. ),
  414. ),
  415. ],
  416. const SizedBox(height: 16),
  417. Row(
  418. crossAxisAlignment: CrossAxisAlignment.start,
  419. children: [
  420. Expanded(
  421. child: TextField(
  422. controller: _inputController,
  423. minLines: 4,
  424. maxLines: 10,
  425. onChanged: (_) => setState(() {}),
  426. decoration: InputDecoration(
  427. hintText: '描述需要 AI 调整或补充的内容,可点击要素快速引用…',
  428. filled: true,
  429. fillColor: AppColors.backgroundLight,
  430. border: OutlineInputBorder(
  431. borderRadius: BorderRadius.circular(14),
  432. borderSide: const BorderSide(color: AppColors.border),
  433. ),
  434. enabledBorder: OutlineInputBorder(
  435. borderRadius: BorderRadius.circular(14),
  436. borderSide: const BorderSide(color: AppColors.border),
  437. ),
  438. focusedBorder: OutlineInputBorder(
  439. borderRadius: BorderRadius.circular(14),
  440. borderSide: const BorderSide(
  441. color: AppColors.primary,
  442. width: 2,
  443. ),
  444. ),
  445. contentPadding: const EdgeInsets.symmetric(
  446. horizontal: 18,
  447. vertical: 16,
  448. ),
  449. suffixIcon: _inputController.text.isEmpty
  450. ? null
  451. : IconButton(
  452. icon: const Icon(LucideIcons.x),
  453. onPressed: () {
  454. _inputController.clear();
  455. setState(() {});
  456. },
  457. ),
  458. ),
  459. ),
  460. ),
  461. const SizedBox(width: 16),
  462. SizedBox(
  463. width: 152,
  464. child: Column(
  465. children: [
  466. AppButton(
  467. text: '发送',
  468. type: ButtonType.primary,
  469. icon: LucideIcons.send,
  470. loading: _isSending,
  471. onPressed:
  472. _isSending || _inputController.text.trim().isEmpty
  473. ? null
  474. : _handleSend,
  475. fullWidth: true,
  476. ),
  477. const SizedBox(height: 10),
  478. AppButton(
  479. text: '重新生成',
  480. type: ButtonType.secondary,
  481. icon: LucideIcons.refresh_cw,
  482. loading: _isRegenerating,
  483. onPressed: _isRegenerating ? null : _handleRegenerate,
  484. fullWidth: true,
  485. ),
  486. ],
  487. ),
  488. ),
  489. ],
  490. ),
  491. const SizedBox(height: 16),
  492. Row(
  493. children: [
  494. AppButton(
  495. text: '导出结果',
  496. type: ButtonType.secondary,
  497. icon: LucideIcons.download,
  498. onPressed: () {},
  499. ),
  500. const SizedBox(width: 12),
  501. AppButton(
  502. text: '复制回复',
  503. type: ButtonType.text,
  504. icon: LucideIcons.copy,
  505. onPressed: () {
  506. Clipboard.setData(ClipboardData(text: response));
  507. ScaffoldMessenger.of(context).showSnackBar(
  508. const SnackBar(content: Text('AI 回复已复制')),
  509. );
  510. },
  511. ),
  512. ],
  513. ),
  514. ],
  515. ),
  516. );
  517. }
  518. Widget _buildElementChip(DocumentElement element) {
  519. final color = _getElementColor(element.type);
  520. return ActionChip(
  521. label: Text(
  522. '${element.type.label}|${element.value}',
  523. style: TextStyle(
  524. fontSize: 12,
  525. color: color,
  526. fontWeight: FontWeight.w600,
  527. ),
  528. overflow: TextOverflow.ellipsis,
  529. ),
  530. avatar: Icon(
  531. LucideIcons.tag,
  532. size: 14,
  533. color: color,
  534. ),
  535. backgroundColor: color.withOpacity(0.12),
  536. shape: StadiumBorder(
  537. side: BorderSide(color: color.withOpacity(0.3)),
  538. ),
  539. onPressed: () => _insertElementIntoInput(element),
  540. );
  541. }
  542. void _insertElementIntoInput(DocumentElement element) {
  543. final insertion = '[${element.label}: ${element.value}]';
  544. final text = _inputController.text;
  545. TextSelection selection = _inputController.selection;
  546. if (!selection.isValid) {
  547. selection = TextSelection.collapsed(offset: text.length);
  548. }
  549. final newText = text.replaceRange(
  550. selection.start,
  551. selection.end,
  552. insertion,
  553. );
  554. final cursorPosition = selection.start + insertion.length;
  555. _inputController.value = TextEditingValue(
  556. text: newText,
  557. selection: TextSelection.collapsed(offset: cursorPosition),
  558. );
  559. setState(() {});
  560. }
  561. Future<void> _handleSend() async {
  562. final content = _inputController.text.trim();
  563. if (content.isEmpty) return;
  564. setState(() => _isSending = true);
  565. await Future.delayed(const Duration(seconds: 2));
  566. if (!mounted) return;
  567. _inputController.clear();
  568. setState(() => _isSending = false);
  569. ScaffoldMessenger.of(context).showSnackBar(
  570. const SnackBar(content: Text('已发送请求,等待 AI 生成新回复')),
  571. );
  572. }
  573. Future<void> _handleRegenerate() async {
  574. setState(() => _isRegenerating = true);
  575. await Future.delayed(const Duration(seconds: 2));
  576. if (!mounted) return;
  577. setState(() => _isRegenerating = false);
  578. ScaffoldMessenger.of(context).showSnackBar(
  579. const SnackBar(content: Text('已重新生成 AI 回复')),
  580. );
  581. }
  582. Color _getElementColor(ElementType type) {
  583. switch (type) {
  584. case ElementType.amount:
  585. return AppColors.amount;
  586. case ElementType.company:
  587. return AppColors.company;
  588. case ElementType.person:
  589. return AppColors.person;
  590. case ElementType.location:
  591. return AppColors.location;
  592. case ElementType.date:
  593. return AppColors.date;
  594. case ElementType.other:
  595. return AppColors.other;
  596. }
  597. }
  598. }