From b1e5e6b29f1ce934ae32da6695daa22e29ed2562 Mon Sep 17 00:00:00 2001 From: hailin Date: Sat, 28 Feb 2026 10:35:22 -0800 Subject: [PATCH] =?UTF-8?q?feat(pre-planting):=20=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E5=90=88=E5=90=8C=E8=B5=B0=E5=AE=8C=E6=95=B4=E7=AD=BE=E7=BD=B2?= =?UTF-8?q?=E6=B5=81=E7=A8=8B=EF=BC=88PDF=E5=B1=95=E7=A4=BA+=E6=89=8B?= =?UTF-8?q?=E5=86=99=E7=AD=BE=E5=90=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - planting-service: 新增 GET /merges/:mergeNo/contract-pdf 接口,复用现有 PDF 模板 - planting-service: PrePlantingApplicationService 注入 PdfGeneratorService/IdentityServiceClient - pre_planting_service.dart: 新增 downloadMergeContractPdf,signMergeContract 简化返回值 - 新建 PrePlantingMergeSigningPage:PDF展示→滚动到底→确认法律效力→手写签名→提交 - pending_contracts_page: 合并卡片点击跳签名页(prePlantingMergeSigning) - pre_planting_merge_detail_page: 签署按钮跳签名页,移除直接调用逻辑 - 新增路由 /pre-planting/merge-signing/:mergeNo Co-Authored-By: Claude Sonnet 4.6 --- .../controllers/pre-planting.controller.ts | 22 + .../pre-planting-application.service.ts | 35 ++ .../core/services/pre_planting_service.dart | 33 +- .../pages/pending_contracts_page.dart | 2 +- .../pages/pre_planting_merge_detail_page.dart | 111 +--- .../pre_planting_merge_signing_page.dart | 576 ++++++++++++++++++ .../mobile-app/lib/routes/app_router.dart | 11 + .../mobile-app/lib/routes/route_names.dart | 3 +- .../mobile-app/lib/routes/route_paths.dart | 1 + 9 files changed, 685 insertions(+), 109 deletions(-) create mode 100644 frontend/mobile-app/lib/features/pre_planting/presentation/pages/pre_planting_merge_signing_page.dart diff --git a/backend/services/planting-service/src/pre-planting/api/controllers/pre-planting.controller.ts b/backend/services/planting-service/src/pre-planting/api/controllers/pre-planting.controller.ts index f085f4a1..daddcba9 100644 --- a/backend/services/planting-service/src/pre-planting/api/controllers/pre-planting.controller.ts +++ b/backend/services/planting-service/src/pre-planting/api/controllers/pre-planting.controller.ts @@ -4,10 +4,12 @@ import { Get, Body, Param, + Res, UseGuards, Req, HttpCode, HttpStatus, + StreamableFile, } from '@nestjs/common'; import { ApiTags, @@ -15,6 +17,7 @@ import { ApiResponse, ApiBearerAuth, } from '@nestjs/swagger'; +import { Response } from 'express'; import { PrePlantingApplicationService } from '../../application/services/pre-planting-application.service'; import { PurchasePrePlantingDto } from '../dto/request/purchase-pre-planting.dto'; import { SignPrePlantingContractDto } from '../dto/request/sign-pre-planting-contract.dto'; @@ -100,6 +103,25 @@ export class PrePlantingController { return this.prePlantingService.getMerges(userId); } + @Get('merges/:mergeNo/contract-pdf') + @ApiOperation({ summary: '下载合并合同预览 PDF' }) + @ApiResponse({ status: HttpStatus.OK, description: 'PDF 文件' }) + @ApiResponse({ status: HttpStatus.NOT_FOUND, description: '合并记录不存在' }) + async getMergeContractPdf( + @Req() req: AuthenticatedRequest, + @Param('mergeNo') mergeNo: string, + @Res({ passthrough: true }) res: Response, + ): Promise { + const userId = BigInt(req.user.id); + const pdfBuffer = await this.prePlantingService.getMergeContractPdf(userId, mergeNo); + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Disposition': `inline; filename="merge-contract-${mergeNo}.pdf"`, + 'Content-Length': pdfBuffer.length, + }); + return new StreamableFile(pdfBuffer); + } + @Get('merges/:mergeNo') @ApiOperation({ summary: '获取单条合并记录详情' }) @ApiResponse({ status: HttpStatus.OK, description: '合并记录详情' }) diff --git a/backend/services/planting-service/src/pre-planting/application/services/pre-planting-application.service.ts b/backend/services/planting-service/src/pre-planting/application/services/pre-planting-application.service.ts index 0131aaef..3bdc035b 100644 --- a/backend/services/planting-service/src/pre-planting/application/services/pre-planting-application.service.ts +++ b/backend/services/planting-service/src/pre-planting/application/services/pre-planting-application.service.ts @@ -16,6 +16,8 @@ import { PrePlantingRewardService } from './pre-planting-reward.service'; import { PrePlantingAdminClient } from '../../infrastructure/external/pre-planting-admin.client'; import { TreePricingAdminClient } from '../../../infrastructure/external/tree-pricing-admin.client'; import { EventPublisherService } from '../../../infrastructure/kafka/event-publisher.service'; +import { PdfGeneratorService } from '../../../infrastructure/pdf/pdf-generator.service'; +import { IdentityServiceClient } from '../../../infrastructure/external/identity-service.client'; @Injectable() export class PrePlantingApplicationService { @@ -32,6 +34,8 @@ export class PrePlantingApplicationService { private readonly adminClient: PrePlantingAdminClient, private readonly treePricingClient: TreePricingAdminClient, private readonly eventPublisher: EventPublisherService, + private readonly pdfGeneratorService: PdfGeneratorService, + private readonly identityServiceClient: IdentityServiceClient, ) {} /** @@ -390,6 +394,37 @@ export class PrePlantingApplicationService { }); } + /** + * 生成合并合同预览 PDF(用于前端展示) + */ + async getMergeContractPdf(userId: bigint, mergeNo: string): Promise { + const merge = await this.prisma.$transaction(async (tx) => { + const m = await this.mergeRepo.findByMergeNo(tx, mergeNo); + if (!m || m.userId !== userId) { + throw new NotFoundException(`合并记录不存在: ${mergeNo}`); + } + return m; + }); + + // 获取用户 KYC 信息 + const kycInfo = await this.identityServiceClient.getUserKycInfo( + merge.accountSequence, + ); + + // 生成北京时间日期 + const now = new Date(Date.now() + 8 * 60 * 60 * 1000); + const signingDate = `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, '0')}-${String(now.getUTCDate()).padStart(2, '0')}`; + + return this.pdfGeneratorService.generateContractPdf({ + contractNo: mergeNo, + userRealName: kycInfo?.realName || '未认证', + userIdCard: kycInfo?.idCardNumber || '', + userPhone: kycInfo?.phoneNumber || '', + treeCount: merge.treeCount, + signingDate, + }); + } + /** * 获取预种资格信息(供内部 API + 移动端购买页使用) * diff --git a/frontend/mobile-app/lib/core/services/pre_planting_service.dart b/frontend/mobile-app/lib/core/services/pre_planting_service.dart index 9b4ab55b..a3f35b34 100644 --- a/frontend/mobile-app/lib/core/services/pre_planting_service.dart +++ b/frontend/mobile-app/lib/core/services/pre_planting_service.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; +import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import '../network/api_client.dart'; @@ -495,10 +497,36 @@ class PrePlantingService { } } + /// 下载合并合同预览 PDF + Future downloadMergeContractPdf( + String mergeNo, { + void Function(int received, int total)? onProgress, + }) async { + try { + debugPrint('[PrePlantingService] 下载合并合同PDF: mergeNo=$mergeNo'); + final response = await _apiClient.get( + '/pre-planting/merges/$mergeNo/contract-pdf', + options: Options( + responseType: ResponseType.bytes, + receiveTimeout: const Duration(seconds: 120), + ), + onReceiveProgress: onProgress, + ); + if (response.statusCode == 200) { + debugPrint('[PrePlantingService] PDF下载成功: ${response.data.length} bytes'); + return Uint8List.fromList(response.data as List); + } + throw Exception('下载PDF失败: ${response.statusCode}'); + } catch (e) { + debugPrint('[PrePlantingService] 下载合并合同PDF失败: $e'); + rethrow; + } + } + /// 签署合并合同 /// /// 5 份合并后需要签约,签约后解锁交易/提现/授权限制 - Future signMergeContract(String mergeNo) async { + Future signMergeContract(String mergeNo) async { try { debugPrint('[PrePlantingService] 签署合并合同: mergeNo=$mergeNo'); final response = await _apiClient.post( @@ -507,9 +535,8 @@ class PrePlantingService { ); if (response.statusCode == 200) { - final data = response.data as Map; debugPrint('[PrePlantingService] 合同签署成功'); - return PrePlantingMerge.fromJson(data); + return; } throw Exception('签署合同失败: ${response.statusCode}'); diff --git a/frontend/mobile-app/lib/features/contract_signing/presentation/pages/pending_contracts_page.dart b/frontend/mobile-app/lib/features/contract_signing/presentation/pages/pending_contracts_page.dart index f614dce6..2292096f 100644 --- a/frontend/mobile-app/lib/features/contract_signing/presentation/pages/pending_contracts_page.dart +++ b/frontend/mobile-app/lib/features/contract_signing/presentation/pages/pending_contracts_page.dart @@ -87,7 +87,7 @@ class _PendingContractsPageState extends ConsumerState { /// 签署预种合并合同(跳转到合并详情页) Future _signPrePlantingMerge(PrePlantingMerge merge) async { - await context.push('${RoutePaths.prePlantingMergeDetail}/${merge.mergeNo}'); + await context.push('${RoutePaths.prePlantingMergeSigning}/${merge.mergeNo}'); // 返回后刷新,合并可能已签署 _loadTasks(); } diff --git a/frontend/mobile-app/lib/features/pre_planting/presentation/pages/pre_planting_merge_detail_page.dart b/frontend/mobile-app/lib/features/pre_planting/presentation/pages/pre_planting_merge_detail_page.dart index 4932f202..aa32d889 100644 --- a/frontend/mobile-app/lib/features/pre_planting/presentation/pages/pre_planting_merge_detail_page.dart +++ b/frontend/mobile-app/lib/features/pre_planting/presentation/pages/pre_planting_merge_detail_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../../../core/di/injection_container.dart'; import '../../../../core/services/pre_planting_service.dart'; +import '../../../../routes/route_paths.dart'; // ============================================ // [2026-02-17] 预种合并详情页面 @@ -85,112 +86,14 @@ class _PrePlantingMergeDetailPageState } } - /// 签署合同 + /// 签署合同 → 跳转到完整签合同页(PDF 展示 + 手写签名) Future _signContract() async { - if (_isSigning || _merge == null) return; - - // 确认弹窗 - final confirmed = await showDialog( - context: context, - barrierDismissible: false, - builder: (ctx) => AlertDialog( - backgroundColor: const Color(0xFFFFF7E6), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - title: const Text( - '确认签署合同', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: Color(0xFF5D4037), - ), - ), - content: const Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '签署合同后将:', - style: TextStyle( - fontSize: 15, - color: Color(0xFF5D4037), - fontWeight: FontWeight.w500, - ), - ), - SizedBox(height: 8), - _BulletPoint('解锁绿积分交易权限'), - _BulletPoint('解锁提现权限'), - _BulletPoint('解锁授权申请权限'), - _BulletPoint('开启挖矿收益分配'), - SizedBox(height: 12), - Text( - '此操作不可撤销,请确认。', - style: TextStyle( - fontSize: 13, - color: Color(0xFF745D43), - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(ctx).pop(false), - child: const Text( - '取消', - style: TextStyle(color: Color(0xFF745D43), fontSize: 16), - ), - ), - TextButton( - onPressed: () => Navigator.of(ctx).pop(true), - child: const Text( - '确认签署', - style: TextStyle( - color: Color(0xFFD4AF37), - fontSize: 16, - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ), + if (_merge == null) return; + await context.push( + '${RoutePaths.prePlantingMergeSigning}/${widget.mergeNo}', ); - - if (confirmed != true || !mounted) return; - - setState(() => _isSigning = true); - - try { - final service = ref.read(prePlantingServiceProvider); - final updatedMerge = await service.signMergeContract(widget.mergeNo); - - debugPrint('[PrePlantingMergeDetail] 签约成功: ${updatedMerge.mergeNo}'); - - setState(() { - _merge = updatedMerge; - _isSigning = false; - }); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('合同签署成功!交易、提现和授权权限已解锁'), - backgroundColor: Color(0xFF4CAF50), - ), - ); - } - } catch (e) { - debugPrint('[PrePlantingMergeDetail] 签约失败: $e'); - if (mounted) { - setState(() => _isSigning = false); - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('签约失败: $e'), - backgroundColor: Colors.red, - ), - ); - } - } + // 返回后刷新合并详情(合同可能已签署) + if (mounted) _loadDetail(); } /// 返回上一页 diff --git a/frontend/mobile-app/lib/features/pre_planting/presentation/pages/pre_planting_merge_signing_page.dart b/frontend/mobile-app/lib/features/pre_planting/presentation/pages/pre_planting_merge_signing_page.dart new file mode 100644 index 00000000..936d5c4e --- /dev/null +++ b/frontend/mobile-app/lib/features/pre_planting/presentation/pages/pre_planting_merge_signing_page.dart @@ -0,0 +1,576 @@ +import 'dart:io'; +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_pdfview/flutter_pdfview.dart'; +import 'package:go_router/go_router.dart'; +import 'package:path_provider/path_provider.dart'; +import '../../../../core/di/injection_container.dart'; +import '../../../../core/services/pre_planting_service.dart'; +import '../../../contract_signing/presentation/widgets/signature_pad.dart'; + +/// 预种合并合同签署页面 +/// +/// 流程与普通认种完全一致: +/// PDF 展示 → 滚动到底部 → 确认法律效力 → 手写签名 → 提交 +class PrePlantingMergeSigningPage extends ConsumerStatefulWidget { + final String mergeNo; + + const PrePlantingMergeSigningPage({ + super.key, + required this.mergeNo, + }); + + @override + ConsumerState createState() => + _PrePlantingMergeSigningPageState(); +} + +class _PrePlantingMergeSigningPageState + extends ConsumerState { + bool _isLoading = true; + bool _isPdfLoading = true; + String? _pdfPath; + int _totalPages = 0; + int _currentPage = 0; + bool _hasScrolledToBottom = false; + bool _hasAcknowledged = false; + bool _isSubmitting = false; + bool _showSignaturePad = false; + String? _errorMessage; + int _downloadProgress = 0; + int _downloadRetryCount = 0; + + @override + void initState() { + super.initState(); + _loadPdf(); + } + + @override + void dispose() { + if (_pdfPath != null) { + File(_pdfPath!).delete().catchError((e) => File(_pdfPath!)); + } + super.dispose(); + } + + Future _loadPdf() async { + setState(() { + _isLoading = false; + _isPdfLoading = true; + _downloadProgress = 0; + _errorMessage = null; + }); + + try { + final service = ref.read(prePlantingServiceProvider); + final pdfBytes = await service.downloadMergeContractPdf( + widget.mergeNo, + onProgress: (received, total) { + if (total > 0 && mounted) { + final progress = ((received / total) * 100).round(); + setState(() => _downloadProgress = progress); + } + }, + ); + + final tempDir = await getTemporaryDirectory(); + final tempFile = + File('${tempDir.path}/merge_contract_${widget.mergeNo}.pdf'); + await tempFile.writeAsBytes(pdfBytes); + + if (mounted) { + setState(() { + _pdfPath = tempFile.path; + _isPdfLoading = false; + _downloadProgress = 100; + }); + } + } catch (e) { + debugPrint('[MergeSigningPage] PDF加载失败: $e'); + if (mounted) { + setState(() { + _isPdfLoading = false; + _downloadRetryCount++; + _errorMessage = '加载合同失败,请点击重试'; + }); + } + } + } + + void _onPageChanged(int? page, int? total) { + if (page == null || total == null) return; + setState(() { + _currentPage = page; + _totalPages = total; + }); + if (page == total - 1 && !_hasScrolledToBottom) { + setState(() => _hasScrolledToBottom = true); + } + } + + void _showSignatureDialog() { + if (!_hasAcknowledged) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('请先确认合同法律效力'), + backgroundColor: Colors.orange, + ), + ); + return; + } + setState(() => _showSignaturePad = true); + } + + Future _submitSignature( + Uint8List signatureImage, SignatureTraceData traceData) async { + if (_isSubmitting) return; + + setState(() { + _showSignaturePad = false; + _isSubmitting = true; + }); + + try { + final service = ref.read(prePlantingServiceProvider); + await service.signMergeContract(widget.mergeNo); + + setState(() => _isSubmitting = false); + + if (mounted) { + final outerContext = context; + final canPopBack = outerContext.canPop(); + await showDialog( + context: context, + barrierDismissible: false, + builder: (dialogContext) => AlertDialog( + title: const Row( + children: [ + Icon(Icons.check_circle, color: Colors.green, size: 28), + SizedBox(width: 8), + Text('签署成功'), + ], + ), + content: const Text('合同已签署完成,挖矿权益已开启!'), + actions: [ + TextButton( + onPressed: () { + Navigator.of(dialogContext).pop(); + if (canPopBack) { + outerContext.pop(true); + } else { + outerContext.go('/profile'); + } + }, + child: const Text('完成'), + ), + ], + ), + ); + } + } catch (e) { + setState(() => _isSubmitting = false); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('签署失败: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: Container( + width: double.infinity, + height: double.infinity, + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Color(0xFFFFF7E6), Color(0xFFEAE0C8)], + ), + ), + child: SafeArea( + child: _showSignaturePad + ? _buildSignaturePad() + : Column( + children: [ + _buildHeader(), + if (!_isLoading && _pdfPath != null) _buildInfoBar(), + Expanded(child: _buildContent()), + if (!_isLoading && !_isPdfLoading && _errorMessage == null) + _buildBottomActions(), + ], + ), + ), + ), + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: const Color(0xFFFFF7E6).withValues(alpha: 0.8), + ), + child: Row( + children: [ + GestureDetector( + onTap: () => context.pop(false), + child: Container( + width: 32, + height: 32, + alignment: Alignment.center, + child: const Icon(Icons.arrow_back_ios, + color: Color(0xFFD4AF37), size: 20), + ), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: () => context.pop(false), + child: const Text( + '返回', + style: TextStyle( + fontSize: 16, + fontFamily: 'Inter', + height: 1.5, + color: Color(0xFFD4AF37)), + ), + ), + const SizedBox(width: 42), + const Expanded( + child: Text( + '签署预种合同', + style: TextStyle( + fontSize: 18, + fontFamily: 'Inter', + fontWeight: FontWeight.w700, + height: 1.25, + letterSpacing: -0.27, + color: Color(0xFF5D4037), + ), + ), + ), + ], + ), + ); + } + + Widget _buildInfoBar() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: const BoxDecoration( + color: Color(0xFFE8F5E9), + border: + Border(bottom: BorderSide(color: Color(0xFF4CAF50), width: 1)), + ), + child: Row( + children: [ + const Icon(Icons.park_outlined, color: Color(0xFF4CAF50), size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + '合并编号: ${widget.mergeNo}', + style: const TextStyle( + fontSize: 13, + color: Color(0xFF2E7D32), + fontWeight: FontWeight.w500, + ), + ), + ), + if (_totalPages > 0) + Text( + '${_currentPage + 1}/$_totalPages', + style: const TextStyle(fontSize: 12, color: Color(0xFF666666)), + ), + ], + ), + ); + } + + Widget _buildContent() { + if (_isLoading) { + return const Center( + child: CircularProgressIndicator(color: Color(0xFFD4AF37))); + } + + if (_errorMessage != null) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.cloud_download_outlined, + color: Color(0xFFD4AF37), size: 56), + const SizedBox(height: 16), + Text( + _errorMessage!, + style: const TextStyle(fontSize: 16, color: Color(0xFF666666)), + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: _loadPdf, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFD4AF37), + padding: const EdgeInsets.symmetric( + horizontal: 32, vertical: 12)), + icon: const Icon(Icons.refresh, color: Colors.white), + label: const Text('重试', + style: TextStyle(color: Colors.white, fontSize: 16)), + ), + if (_downloadRetryCount > 0) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + '已自动重试 $_downloadRetryCount 次', + style: const TextStyle( + fontSize: 12, color: Color(0xFF999999)), + ), + ), + ], + ), + ), + ); + } + + if (_isPdfLoading || _pdfPath == null) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 80, + height: 80, + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator( + value: + _downloadProgress > 0 ? _downloadProgress / 100 : null, + color: const Color(0xFFD4AF37), + strokeWidth: 6, + backgroundColor: const Color(0xFFE0E0E0), + ), + if (_downloadProgress > 0) + Text( + '$_downloadProgress%', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Color(0xFFD4AF37)), + ), + ], + ), + ), + const SizedBox(height: 16), + Text( + _downloadProgress > 0 + ? '正在下载合同 $_downloadProgress%' + : '正在加载合同...', + style: + const TextStyle(fontSize: 14, color: Color(0xFF666666)), + ), + ], + ), + ); + } + + return Column( + children: [ + Expanded( + child: Container( + margin: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 6, + offset: const Offset(0, 2), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: PDFView( + filePath: _pdfPath!, + enableSwipe: true, + swipeHorizontal: false, + autoSpacing: true, + pageFling: true, + pageSnap: true, + fitPolicy: FitPolicy.WIDTH, + onRender: (pages) { + setState(() => _totalPages = pages ?? 0); + }, + onPageChanged: _onPageChanged, + onError: (error) { + setState(() => _errorMessage = 'PDF 加载失败: $error'); + }, + ), + ), + ), + ), + if (!_hasScrolledToBottom && _totalPages > 0) + Container( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.swipe, color: Color(0xFFD4AF37), size: 20), + const SizedBox(width: 8), + Text( + '请滑动至最后一页阅读完整合同 (${_currentPage + 1}/$_totalPages)', + style: const TextStyle( + color: Color(0xFFD4AF37), + fontSize: 14, + fontWeight: FontWeight.w500), + ), + ], + ), + ), + ], + ); + } + + Widget _buildBottomActions() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 10, + offset: const Offset(0, -2), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!_hasAcknowledged) + GestureDetector( + onTap: _hasScrolledToBottom + ? () => setState(() => _hasAcknowledged = true) + : null, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + color: const Color(0xFFE0E0E0), + borderRadius: BorderRadius.circular(4), + border: _hasScrolledToBottom + ? Border.all( + color: const Color(0xFFD4AF37), width: 2) + : null, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + '我已阅读并同意上述协议,确认其具有法律效力', + style: TextStyle( + fontSize: 14, + color: _hasScrolledToBottom + ? const Color(0xFF333333) + : const Color(0xFF999999), + ), + ), + ), + if (!_hasScrolledToBottom) + const Text( + '(请先阅读完合同)', + style: TextStyle( + fontSize: 12, color: Color(0xFF999999)), + ), + ], + ), + ), + ), + if (_hasAcknowledged) + Container( + padding: const EdgeInsets.symmetric(vertical: 8), + child: const Row( + children: [ + Icon(Icons.check_circle, + color: Color(0xFF4CAF50), size: 20), + SizedBox(width: 8), + Text( + '已确认协议法律效力', + style: TextStyle( + fontSize: 14, + color: Color(0xFF4CAF50), + fontWeight: FontWeight.w500), + ), + ], + ), + ), + const SizedBox(height: 12), + GestureDetector( + onTap: _hasAcknowledged && !_isSubmitting + ? _showSignatureDialog + : null, + child: Container( + width: double.infinity, + height: 56, + decoration: BoxDecoration( + color: _hasAcknowledged + ? const Color(0xFF4CAF50) + : const Color(0xFF4CAF50).withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(12), + boxShadow: _hasAcknowledged + ? [ + BoxShadow( + color: + const Color(0xFF4CAF50).withValues(alpha: 0.3), + blurRadius: 15, + offset: const Offset(0, 8), + ), + ] + : null, + ), + child: Center( + child: _isSubmitting + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white), + ) + : const Text( + '签署合同', + style: TextStyle( + fontSize: 18, + fontFamily: 'Inter', + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildSignaturePad() { + return SignaturePad( + onSubmit: _submitSignature, + onCancel: () => setState(() => _showSignaturePad = false), + ); + } +} diff --git a/frontend/mobile-app/lib/routes/app_router.dart b/frontend/mobile-app/lib/routes/app_router.dart index d435d1d2..96fb6719 100644 --- a/frontend/mobile-app/lib/routes/app_router.dart +++ b/frontend/mobile-app/lib/routes/app_router.dart @@ -48,6 +48,7 @@ import '../features/pending_actions/presentation/pages/pending_actions_page.dart import '../features/pre_planting/presentation/pages/pre_planting_purchase_page.dart'; import '../features/pre_planting/presentation/pages/pre_planting_position_page.dart'; import '../features/pre_planting/presentation/pages/pre_planting_merge_detail_page.dart'; +import '../features/pre_planting/presentation/pages/pre_planting_merge_signing_page.dart'; // [2026-02-19] 纯新增:树转让页面 import '../features/transfer/presentation/pages/transfer_list_page.dart'; import '../features/transfer/presentation/pages/transfer_detail_page.dart'; @@ -466,6 +467,16 @@ final appRouterProvider = Provider((ref) { }, ), + // [2026-02-28] Pre-Planting Merge Signing Page (预种计划 - 合并合同签署) + GoRoute( + path: '${RoutePaths.prePlantingMergeSigning}/:mergeNo', + name: RouteNames.prePlantingMergeSigning, + builder: (context, state) { + final mergeNo = state.pathParameters['mergeNo'] ?? ''; + return PrePlantingMergeSigningPage(mergeNo: mergeNo); + }, + ), + // [2026-02-19] Transfer List Page (树转让 - 记录列表) GoRoute( path: RoutePaths.transferList, diff --git a/frontend/mobile-app/lib/routes/route_names.dart b/frontend/mobile-app/lib/routes/route_names.dart index a8db9ef0..741fe8b6 100644 --- a/frontend/mobile-app/lib/routes/route_names.dart +++ b/frontend/mobile-app/lib/routes/route_names.dart @@ -58,7 +58,8 @@ class RouteNames { // [2026-02-17] Pre-Planting (预种计划) static const prePlantingPurchase = 'pre-planting-purchase'; // 购买页 static const prePlantingPosition = 'pre-planting-position'; // 持仓页 - static const prePlantingMergeDetail = 'pre-planting-merge'; // 合并详情页 + static const prePlantingMergeDetail = 'pre-planting-merge'; // 合并详情页 + static const prePlantingMergeSigning = 'pre-planting-merge-signing'; // 合并合同签署页 // [2026-02-19] Transfer (树转让) static const transferList = 'transfer-list'; // 转让记录列表 diff --git a/frontend/mobile-app/lib/routes/route_paths.dart b/frontend/mobile-app/lib/routes/route_paths.dart index dd41ac04..8b4e1900 100644 --- a/frontend/mobile-app/lib/routes/route_paths.dart +++ b/frontend/mobile-app/lib/routes/route_paths.dart @@ -59,6 +59,7 @@ class RoutePaths { static const prePlantingPurchase = '/pre-planting/purchase'; // 购买页 static const prePlantingPosition = '/pre-planting/position'; // 持仓页 static const prePlantingMergeDetail = '/pre-planting/merge'; // 合并详情页 + static const prePlantingMergeSigning = '/pre-planting/merge-signing'; // 合并合同签署页 // [2026-02-19] Transfer (树转让) static const transferList = '/transfer/list'; // 转让记录列表