diff --git a/backend/services/planting-service/package-lock.json b/backend/services/planting-service/package-lock.json index 927f656c..a1ea0b8e 100644 --- a/backend/services/planting-service/package-lock.json +++ b/backend/services/planting-service/package-lock.json @@ -19,6 +19,7 @@ "@nestjs/platform-express": "^10.0.0", "@nestjs/schedule": "^4.0.0", "@nestjs/swagger": "^7.1.17", + "@pdf-lib/fontkit": "^1.1.1", "@prisma/client": "^5.7.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", @@ -26,6 +27,7 @@ "kafkajs": "^2.2.4", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "pdf-lib": "^1.17.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "uuid": "^9.0.0" @@ -2151,6 +2153,33 @@ "@noble/hashes": "^1.1.5" } }, + "node_modules/@pdf-lib/fontkit": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/fontkit/-/fontkit-1.1.1.tgz", + "integrity": "sha512-KjMd7grNapIWS/Dm0gvfHEilSyAmeLvrEGVcqLGi0VYebuqqzTbgF29efCx7tvx+IEbG3zQciRSWl3GkUSvjZg==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -7782,6 +7811,12 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -7942,6 +7977,24 @@ "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", "integrity": "sha512-KG8UEiEVkR3wGEb4m5yZkVCzigAD+cVEJck2CzYZO37ZGJfctvVptVO192MwrtPhzONn6go8ylnOdMhKqi4nfg==" }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/backend/services/planting-service/package.json b/backend/services/planting-service/package.json index 19011d63..e3bf7eac 100644 --- a/backend/services/planting-service/package.json +++ b/backend/services/planting-service/package.json @@ -24,23 +24,25 @@ "prisma:studio": "prisma studio" }, "dependencies": { + "@nestjs/axios": "^3.0.1", "@nestjs/common": "^10.0.0", "@nestjs/config": "^3.1.1", "@nestjs/core": "^10.0.0", - "@nestjs/microservices": "^10.0.0", - "@nestjs/platform-express": "^10.0.0", - "@nestjs/swagger": "^7.1.17", - "@nestjs/axios": "^3.0.1", - "@nestjs/schedule": "^4.0.0", "@nestjs/jwt": "^10.2.0", + "@nestjs/microservices": "^10.0.0", "@nestjs/passport": "^10.0.3", + "@nestjs/platform-express": "^10.0.0", + "@nestjs/schedule": "^4.0.0", + "@nestjs/swagger": "^7.1.17", + "@pdf-lib/fontkit": "^1.1.1", "@prisma/client": "^5.7.0", - "kafkajs": "^2.2.4", "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "jsonwebtoken": "^9.0.0", + "kafkajs": "^2.2.4", "passport": "^0.7.0", "passport-jwt": "^4.0.1", + "pdf-lib": "^1.17.1", "reflect-metadata": "^0.1.13", "rxjs": "^7.8.1", "uuid": "^9.0.0" diff --git a/backend/services/planting-service/src/api/controllers/contract-signing.controller.ts b/backend/services/planting-service/src/api/controllers/contract-signing.controller.ts index 93ee3213..fe560f0a 100644 --- a/backend/services/planting-service/src/api/controllers/contract-signing.controller.ts +++ b/backend/services/planting-service/src/api/controllers/contract-signing.controller.ts @@ -9,10 +9,14 @@ import { HttpCode, HttpStatus, Logger, + Res, + StreamableFile, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { Response } from 'express'; import { JwtAuthGuard } from '../guards/jwt-auth.guard'; import { ContractSigningService } from '../../application/services/contract-signing.service'; +import { PdfGeneratorService } from '../../infrastructure/pdf/pdf-generator.service'; /** * 签署合同请求DTO @@ -70,7 +74,10 @@ export class ContractSigningConfigController { export class ContractSigningController { private readonly logger = new Logger(ContractSigningController.name); - constructor(private readonly contractSigningService: ContractSigningService) {} + constructor( + private readonly contractSigningService: ContractSigningService, + private readonly pdfGeneratorService: PdfGeneratorService, + ) {} /** * 获取用户待签署的合同任务列表 @@ -279,4 +286,50 @@ export class ContractSigningController { }, }; } + + /** + * 获取合同 PDF 文件 + * 用于前端展示和签署 + */ + @Get('tasks/:orderNo/pdf') + async getContractPdf( + @Param('orderNo') orderNo: string, + @Request() req: { user: { id: string } }, + @Res({ passthrough: true }) res: Response, + ): Promise { + const userId = BigInt(req.user.id); + + try { + const task = await this.contractSigningService.getTask(orderNo, userId); + + if (!task) { + throw new Error('签署任务不存在'); + } + + // 格式化签署日期 + const signingDate = new Date().toISOString().split('T')[0]; + + // 生成 PDF(使用 pdf-lib 直接操作 PDF 模板) + const pdfBuffer = await this.pdfGeneratorService.generateContractPdf({ + orderNo: task.orderNo, + userRealName: task.userRealName || '未认证', + userIdCard: task.userIdCardNumber || '', + userPhone: task.userPhoneNumber || '', + treeCount: task.treeCount, + signingDate, + }); + + // 设置响应头 + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Disposition': `inline; filename="contract-${orderNo}.pdf"`, + 'Content-Length': pdfBuffer.length, + }); + + return new StreamableFile(pdfBuffer); + } catch (error) { + this.logger.error(`Failed to generate PDF for order ${orderNo}:`, error); + throw error; + } + } } diff --git a/backend/services/planting-service/src/application/services/contract-signing.service.ts b/backend/services/planting-service/src/application/services/contract-signing.service.ts index 47c43d10..d7476165 100644 --- a/backend/services/planting-service/src/application/services/contract-signing.service.ts +++ b/backend/services/planting-service/src/application/services/contract-signing.service.ts @@ -45,6 +45,9 @@ export interface ContractSigningTaskDto { provinceName: string; cityName: string; userRealName?: string; + userIdCardNumber?: string; + userPhoneNumber?: string; + accountSequence: string; scrolledToBottomAt?: Date; acknowledgedAt?: Date; signedAt?: Date; @@ -310,6 +313,9 @@ export class ContractSigningService { provinceName: task.provinceName, cityName: task.cityName, userRealName: task.userRealName, + userIdCardNumber: task.userIdCardNumber, + userPhoneNumber: task.userPhoneNumber, + accountSequence: task.accountSequence, scrolledToBottomAt: task.scrolledToBottomAt, acknowledgedAt: task.acknowledgedAt, signedAt: task.signedAt, diff --git a/backend/services/planting-service/src/infrastructure/infrastructure.module.ts b/backend/services/planting-service/src/infrastructure/infrastructure.module.ts index b03635fc..653b5cf4 100644 --- a/backend/services/planting-service/src/infrastructure/infrastructure.module.ts +++ b/backend/services/planting-service/src/infrastructure/infrastructure.module.ts @@ -15,6 +15,7 @@ import { KafkaModule } from './kafka/kafka.module'; import { OutboxPublisherService } from './kafka/outbox-publisher.service'; import { EventAckController } from './kafka/event-ack.controller'; import { ContractSigningEventConsumer } from './kafka/contract-signing-event.consumer'; +import { PdfGeneratorService } from './pdf/pdf-generator.service'; import { PLANTING_ORDER_REPOSITORY } from '../domain/repositories/planting-order.repository.interface'; import { PLANTING_POSITION_REPOSITORY } from '../domain/repositories/planting-position.repository.interface'; import { POOL_INJECTION_BATCH_REPOSITORY } from '../domain/repositories/pool-injection-batch.repository.interface'; @@ -64,6 +65,7 @@ import { ContractSigningService } from '../application/services/contract-signing OutboxPublisherService, PaymentCompensationService, ContractSigningService, + PdfGeneratorService, WalletServiceClient, ReferralServiceClient, ], @@ -80,6 +82,7 @@ import { ContractSigningService } from '../application/services/contract-signing OutboxPublisherService, PaymentCompensationService, ContractSigningService, + PdfGeneratorService, WalletServiceClient, ReferralServiceClient, ], diff --git a/backend/services/planting-service/src/infrastructure/pdf/index.ts b/backend/services/planting-service/src/infrastructure/pdf/index.ts new file mode 100644 index 00000000..4c172999 --- /dev/null +++ b/backend/services/planting-service/src/infrastructure/pdf/index.ts @@ -0,0 +1,2 @@ +export * from './pdf-generator.service'; +export * from './pdf.module'; diff --git a/backend/services/planting-service/src/infrastructure/pdf/pdf-generator.service.ts b/backend/services/planting-service/src/infrastructure/pdf/pdf-generator.service.ts new file mode 100644 index 00000000..ad347847 --- /dev/null +++ b/backend/services/planting-service/src/infrastructure/pdf/pdf-generator.service.ts @@ -0,0 +1,260 @@ +import { Injectable, Logger } from '@nestjs/common'; +import * as fs from 'fs'; +import * as path from 'path'; +import { PDFDocument, rgb } from 'pdf-lib'; +import fontkit from '@pdf-lib/fontkit'; + +/** + * 合同 PDF 生成数据 + */ +export interface ContractPdfData { + orderNo: string; + userRealName: string; + userIdCard: string; + userPhone: string; + treeCount: number; + signingDate: string; // YYYY-MM-DD 格式 +} + +/** + * 签名嵌入数据 + */ +export interface SignatureData { + signatureImagePng: Buffer; // PNG 格式的签名图片 +} + +/** + * PDF 生成服务 + * + * 使用 pdf-lib 直接操作 PDF: + * 1. 加载 PDF 模板 + * 2. 在指定坐标位置填充文字 + * 3. 嵌入用户签名图片 + * 4. 输出最终 PDF + */ +@Injectable() +export class PdfGeneratorService { + private readonly logger = new Logger(PdfGeneratorService.name); + private readonly templatePath: string; + private readonly fontPath: string; + + constructor() { + this.templatePath = path.join( + __dirname, + '../../../templates/contract-template.pdf', + ); + this.fontPath = path.join( + __dirname, + '../../../templates/fonts/NotoSansSC-Regular.ttf', + ); + } + + /** + * 生成合同 PDF(填充用户信息,不含签名) + * @param data 合同数据 + * @returns PDF Buffer + */ + async generateContractPdf(data: ContractPdfData): Promise { + this.logger.log(`Generating PDF for order: ${data.orderNo}`); + + try { + // 1. 加载 PDF 模板 + const templateBytes = fs.readFileSync(this.templatePath); + const pdfDoc = await PDFDocument.load(templateBytes); + + // 2. 注册 fontkit 以支持自定义字体 + pdfDoc.registerFontkit(fontkit); + + // 3. 加载中文字体 + const fontBytes = fs.readFileSync(this.fontPath); + const customFont = await pdfDoc.embedFont(fontBytes); + + // 4. 获取页面 + const pages = pdfDoc.getPages(); + const page1 = pages[0]; // 第1页:协议编号 + const page2 = pages[1]; // 第2页:乙方信息 + const page3 = pages[2]; // 第3页:种植期限、认种数量 + const page6 = pages[5]; // 第6页:签名区域 + + const fontSize = 12; + const textColor = rgb(0, 0, 0); + + // 5. 填充第1页 - 协议编号 + // "协议编号:" 后面的位置(根据 PDF 布局调整坐标) + page1.drawText(data.orderNo, { + x: 390, + y: 95, + size: fontSize, + font: customFont, + color: textColor, + }); + + // 6. 填充第2页 - 乙方信息 + // 姓名/名称: + page2.drawText(data.userRealName || '未认证', { + x: 155, + y: 583, + size: fontSize, + font: customFont, + color: textColor, + }); + + // 身份证号: + page2.drawText(this.maskIdCard(data.userIdCard), { + x: 130, + y: 557, + size: fontSize, + font: customFont, + color: textColor, + }); + + // 联系方式: + page2.drawText(this.maskPhone(data.userPhone), { + x: 130, + y: 531, + size: fontSize, + font: customFont, + color: textColor, + }); + + // 7. 填充第3页 - 认种数量 + // 乙方认种 __ 棵榴莲树苗 + page3.drawText(data.treeCount.toString(), { + x: 138, + y: 477, + size: fontSize, + font: customFont, + color: textColor, + }); + + // 8. 填充第6页 - 签约日期 + const [year, month, day] = data.signingDate.split('-'); + page6.drawText(year, { + x: 290, + y: 103, + size: fontSize, + font: customFont, + color: textColor, + }); + page6.drawText(month, { + x: 345, + y: 103, + size: fontSize, + font: customFont, + color: textColor, + }); + page6.drawText(day, { + x: 385, + y: 103, + size: fontSize, + font: customFont, + color: textColor, + }); + + // 9. 保存 PDF + const pdfBytes = await pdfDoc.save(); + + this.logger.log(`PDF generated successfully for order: ${data.orderNo}`); + return Buffer.from(pdfBytes); + } catch (error) { + this.logger.error( + `Failed to generate PDF for order ${data.orderNo}:`, + error, + ); + throw new Error(`PDF generation failed: ${error.message}`); + } + } + + /** + * 在已生成的 PDF 上嵌入签名 + * @param pdfBuffer 原始 PDF Buffer + * @param signature 签名数据 + * @returns 带签名的 PDF Buffer + */ + async embedSignature( + pdfBuffer: Buffer, + signature: SignatureData, + ): Promise { + this.logger.log('Embedding signature into PDF'); + + try { + const pdfDoc = await PDFDocument.load(pdfBuffer); + const pages = pdfDoc.getPages(); + const page6 = pages[5]; // 第6页:签名区域 + + // 嵌入签名图片 + const signatureImage = await pdfDoc.embedPng(signature.signatureImagePng); + + // 计算签名图片的尺寸(保持宽高比,最大宽度150,最大高度60) + const maxWidth = 150; + const maxHeight = 60; + const { width, height } = signatureImage.scale(1); + let scaledWidth = width; + let scaledHeight = height; + + if (width > maxWidth) { + scaledWidth = maxWidth; + scaledHeight = (height * maxWidth) / width; + } + if (scaledHeight > maxHeight) { + scaledHeight = maxHeight; + scaledWidth = (width * maxHeight) / height; + } + + // 在"乙方(签字/盖章):"下方绘制签名 + // 坐标需要根据实际 PDF 布局调整 + page6.drawImage(signatureImage, { + x: 350, + y: 180, + width: scaledWidth, + height: scaledHeight, + }); + + const pdfBytes = await pdfDoc.save(); + this.logger.log('Signature embedded successfully'); + return Buffer.from(pdfBytes); + } catch (error) { + this.logger.error('Failed to embed signature:', error); + throw new Error(`Signature embedding failed: ${error.message}`); + } + } + + /** + * 生成带签名的完整合同 PDF + * @param data 合同数据 + * @param signature 签名数据(可选,如果没有签名则只生成填充数据的 PDF) + * @returns PDF Buffer + */ + async generateSignedContractPdf( + data: ContractPdfData, + signature?: SignatureData, + ): Promise { + // 先生成填充数据的 PDF + let pdfBuffer = await this.generateContractPdf(data); + + // 如果有签名,嵌入签名 + if (signature) { + pdfBuffer = await this.embedSignature(pdfBuffer, signature); + } + + return pdfBuffer; + } + + /** + * 脱敏身份证号 + */ + private maskIdCard(idCard: string | undefined): string { + if (!idCard) return '****'; + if (idCard.length < 6) return '****'; + return idCard.slice(0, 6) + '********' + idCard.slice(-4); + } + + /** + * 脱敏手机号 + */ + private maskPhone(phone: string | undefined): string { + if (!phone) return '****'; + if (phone.length < 7) return '****'; + return phone.slice(0, 3) + '****' + phone.slice(-4); + } +} diff --git a/backend/services/planting-service/src/infrastructure/pdf/pdf.module.ts b/backend/services/planting-service/src/infrastructure/pdf/pdf.module.ts new file mode 100644 index 00000000..e43e003c --- /dev/null +++ b/backend/services/planting-service/src/infrastructure/pdf/pdf.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { PdfGeneratorService } from './pdf-generator.service'; + +@Module({ + providers: [PdfGeneratorService], + exports: [PdfGeneratorService], +}) +export class PdfModule {} diff --git a/backend/services/planting-service/templates/contract-template.pdf b/backend/services/planting-service/templates/contract-template.pdf new file mode 100644 index 00000000..0c369945 Binary files /dev/null and b/backend/services/planting-service/templates/contract-template.pdf differ diff --git a/backend/services/planting-service/templates/contract.docx b/backend/services/planting-service/templates/contract.docx new file mode 100644 index 00000000..3c85bbc0 Binary files /dev/null and b/backend/services/planting-service/templates/contract.docx differ diff --git a/backend/services/planting-service/templates/fonts/NotoSansSC-Regular.ttf b/backend/services/planting-service/templates/fonts/NotoSansSC-Regular.ttf new file mode 100644 index 00000000..e533c47f --- /dev/null +++ b/backend/services/planting-service/templates/fonts/NotoSansSC-Regular.ttf @@ -0,0 +1,1447 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Page not found · GitHub · GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + +
+ Skip to content + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+
+ + + +
+
+ +
+
+ 404 “This is not the web page you are looking for” + + + + + + + + + + + + +
+
+ +
+
+ +
+ + +
+
+ +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + diff --git a/frontend/mobile-app/lib/core/services/contract_signing_service.dart b/frontend/mobile-app/lib/core/services/contract_signing_service.dart index e3b0af7d..b3a31341 100644 --- a/frontend/mobile-app/lib/core/services/contract_signing_service.dart +++ b/frontend/mobile-app/lib/core/services/contract_signing_service.dart @@ -1,4 +1,4 @@ -import 'dart:typed_data'; +import 'package:dio/dio.dart'; import 'package:flutter/foundation.dart'; import '../network/api_client.dart'; @@ -387,6 +387,28 @@ class ContractSigningService { rethrow; } } + + /// 下载合同 PDF 文件 + Future downloadContractPdf(String orderNo) async { + try { + debugPrint('[ContractSigningService] 下载合同 PDF: $orderNo'); + + final response = await _apiClient.get( + '/planting/contract-signing/tasks/$orderNo/pdf', + options: Options(responseType: ResponseType.bytes), + ); + + if (response.statusCode == 200) { + debugPrint('[ContractSigningService] PDF 下载成功'); + return Uint8List.fromList(response.data); + } + + throw Exception('下载 PDF 失败: ${response.statusCode}'); + } catch (e) { + debugPrint('[ContractSigningService] 下载 PDF 失败: $e'); + rethrow; + } + } } /// Base64 编码函数 diff --git a/frontend/mobile-app/lib/features/contract_signing/presentation/pages/contract_signing_page.dart b/frontend/mobile-app/lib/features/contract_signing/presentation/pages/contract_signing_page.dart index 497738c7..0b2f8c00 100644 --- a/frontend/mobile-app/lib/features/contract_signing/presentation/pages/contract_signing_page.dart +++ b/frontend/mobile-app/lib/features/contract_signing/presentation/pages/contract_signing_page.dart @@ -6,6 +6,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:geolocator/geolocator.dart'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter_pdfview/flutter_pdfview.dart'; +import 'package:path_provider/path_provider.dart'; import '../../../../core/di/injection_container.dart'; import '../../../../core/services/contract_signing_service.dart'; import '../widgets/signature_pad.dart'; @@ -30,7 +32,19 @@ class _ContractSigningPageState extends ConsumerState { /// 是否正在加载 bool _isLoading = true; - /// 是否已滚动到底部 + /// PDF 是否正在加载 + bool _isPdfLoading = true; + + /// PDF 本地文件路径 + String? _pdfPath; + + /// PDF 总页数 + int _totalPages = 0; + + /// 当前页码 + int _currentPage = 0; + + /// 是否已滚动到底部(最后一页) bool _hasScrolledToBottom = false; /// 是否已确认法律效力 @@ -51,21 +65,19 @@ class _ContractSigningPageState extends ConsumerState { /// 剩余秒数 int _remainingSeconds = 0; - /// 滚动控制器 - final ScrollController _scrollController = ScrollController(); - @override void initState() { super.initState(); _loadTask(); - _scrollController.addListener(_onScroll); } @override void dispose() { _countdownTimer?.cancel(); - _scrollController.removeListener(_onScroll); - _scrollController.dispose(); + // 清理临时 PDF 文件 + if (_pdfPath != null) { + File(_pdfPath!).delete().catchError((e) => File(_pdfPath!)); + } super.dispose(); } @@ -90,6 +102,11 @@ class _ContractSigningPageState extends ConsumerState { // 启动倒计时 _startCountdown(); + + // 加载 PDF + if (task.status != ContractSigningStatus.signed) { + _loadPdf(); + } } catch (e) { setState(() { _isLoading = false; @@ -98,6 +115,33 @@ class _ContractSigningPageState extends ConsumerState { } } + /// 加载 PDF 文件 + Future _loadPdf() async { + setState(() { + _isPdfLoading = true; + }); + + try { + final service = ref.read(contractSigningServiceProvider); + final pdfBytes = await service.downloadContractPdf(widget.orderNo); + + // 保存到临时文件 + final tempDir = await getTemporaryDirectory(); + final tempFile = File('${tempDir.path}/contract_${widget.orderNo}.pdf'); + await tempFile.writeAsBytes(pdfBytes); + + setState(() { + _pdfPath = tempFile.path; + _isPdfLoading = false; + }); + } catch (e) { + setState(() { + _isPdfLoading = false; + _errorMessage = '加载合同 PDF 失败: $e'; + }); + } + } + /// 启动倒计时 void _startCountdown() { _countdownTimer?.cancel(); @@ -137,15 +181,17 @@ class _ContractSigningPageState extends ConsumerState { } } - /// 监听滚动 - void _onScroll() { - if (_hasScrolledToBottom) return; + /// PDF 页面变化回调 + void _onPageChanged(int? page, int? total) { + if (page == null || total == null) return; - final maxScroll = _scrollController.position.maxScrollExtent; - final currentScroll = _scrollController.position.pixels; + setState(() { + _currentPage = page; + _totalPages = total; + }); - // 滚动到底部 90% 以上视为已读完 - if (currentScroll >= maxScroll * 0.9) { + // 如果已经翻到最后一页,标记已阅读完成 + if (page == total - 1 && !_hasScrolledToBottom) { _markScrollComplete(); } } @@ -156,10 +202,9 @@ class _ContractSigningPageState extends ConsumerState { try { final service = ref.read(contractSigningServiceProvider); - final updatedTask = await service.markScrollComplete(widget.orderNo); + await service.markScrollComplete(widget.orderNo); setState(() { - _task = updatedTask; _hasScrolledToBottom = true; }); } catch (e) { @@ -175,10 +220,9 @@ class _ContractSigningPageState extends ConsumerState { try { final service = ref.read(contractSigningServiceProvider); - final updatedTask = await service.acknowledgeContract(widget.orderNo); + await service.acknowledgeContract(widget.orderNo); setState(() { - _task = updatedTask; _hasAcknowledged = true; _isSubmitting = false; }); @@ -256,9 +300,8 @@ class _ContractSigningPageState extends ConsumerState { final service = ref.read(contractSigningServiceProvider); // 判断是正常签署还是补签 - ContractSigningTask updatedTask; if (_task!.status == ContractSigningStatus.unsignedTimeout) { - updatedTask = await service.lateSignContract( + await service.lateSignContract( orderNo: widget.orderNo, signatureImage: signatureImage, ipAddress: ipAddress, @@ -268,7 +311,7 @@ class _ContractSigningPageState extends ConsumerState { longitude: longitude, ); } else { - updatedTask = await service.signContract( + await service.signContract( orderNo: widget.orderNo, signatureImage: signatureImage, ipAddress: ipAddress, @@ -280,7 +323,6 @@ class _ContractSigningPageState extends ConsumerState { } setState(() { - _task = updatedTask; _isSubmitting = false; }); @@ -473,6 +515,15 @@ class _ContractSigningPageState extends ConsumerState { ), ), ), + // 页码显示 + if (_totalPages > 0) + Text( + '${_currentPage + 1}/$_totalPages', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF666666), + ), + ), ], ), ); @@ -520,39 +571,93 @@ class _ContractSigningPageState extends ConsumerState { return _buildSignedContent(); } - return SingleChildScrollView( - controller: _scrollController, - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildContractInfo(), - const SizedBox(height: 16), - _buildContractContent(), - const SizedBox(height: 24), - if (!_hasScrolledToBottom) - const Padding( - padding: EdgeInsets.symmetric(vertical: 16), - child: Center( - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.arrow_downward, color: Color(0xFFD4AF37), size: 20), - SizedBox(width: 8), - Text( - '请滚动阅读完整合同', - style: TextStyle( - color: Color(0xFFD4AF37), - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ], - ), + // PDF 加载中 + if (_isPdfLoading || _pdfPath == null) { + return const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(color: Color(0xFFD4AF37)), + SizedBox(height: 16), + Text( + '正在加载合同...', + style: TextStyle( + fontSize: 14, + color: Color(0xFF666666), ), ), - ], - ), + ], + ), + ); + } + + // PDF 查看器 + return Column( + children: [ + // 合同信息卡片 + _buildContractInfo(), + // PDF 内容 + 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) { + debugPrint('PDF 加载错误: $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, + ), + ), + ], + ), + ), + ], ); } @@ -628,156 +733,56 @@ class _ContractSigningPageState extends ConsumerState { /// 构建合同信息卡片 Widget _buildContractInfo() { return Container( - width: double.infinity, - padding: const EdgeInsets.all(16), + margin: const EdgeInsets.all(8), + padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(8), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.1), - blurRadius: 6, + blurRadius: 4, offset: const Offset(0, 2), ), ], ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Text( - '订单号: ${_task!.orderNo}', - style: const TextStyle( - fontSize: 14, - color: Color(0xFF666666), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '订单: ${_task!.orderNo}', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF666666), + ), + ), + const SizedBox(height: 4), + Text( + '${_task!.treeCount} 棵榴莲树 · ${_task!.totalAmount.toStringAsFixed(2)} USDT', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Color(0xFF5D4037), + ), + ), + ], ), ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('认种数量', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), - Text( - '${_task!.treeCount} 棵', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Color(0xFF5D4037), - ), - ), - ], - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text('认种金额', style: TextStyle(fontSize: 12, color: Color(0xFF999999))), - Text( - '${_task!.totalAmount.toStringAsFixed(2)} USDT', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Color(0xFFD4AF37), - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 8), Text( - '种植区域: ${_task!.provinceName} ${_task!.cityName}', - style: const TextStyle(fontSize: 14, color: Color(0xFF666666)), + '${_task!.provinceName} ${_task!.cityName}', + style: const TextStyle( + fontSize: 12, + color: Color(0xFF999999), + ), ), ], ), ); } - /// 构建合同内容 - Widget _buildContractContent() { - return Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 6, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - '合同内容', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.bold, - color: Color(0xFF5D4037), - ), - ), - Text( - '版本: ${_task!.contractVersion}', - style: const TextStyle( - fontSize: 12, - color: Color(0xFF999999), - ), - ), - ], - ), - const Divider(height: 24), - // 使用 HTML 渲染合同内容 - _buildHtmlContent(_task!.contractContent), - ], - ), - ); - } - - /// 构建 HTML 内容(简单实现) - Widget _buildHtmlContent(String htmlContent) { - // 简单解析 HTML 内容,提取纯文本显示 - // 实际项目中可以使用 flutter_html 或 flutter_widget_from_html 包 - final text = htmlContent - .replaceAll(RegExp(r'<[^>]*>'), '') - .replaceAll(' ', ' ') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('&', '&') - .replaceAll('{{ORDER_NO}}', _task!.orderNo) - .replaceAll('{{USER_REAL_NAME}}', '***') - .replaceAll('{{USER_ID_CARD}}', '****') - .replaceAll('{{USER_PHONE}}', '****') - .replaceAll('{{ACCOUNT_SEQUENCE}}', _task!.accountSequence) - .replaceAll('{{TREE_COUNT}}', _task!.treeCount.toString()) - .replaceAll('{{TOTAL_AMOUNT}}', _task!.totalAmount.toStringAsFixed(2)) - .replaceAll('{{PROVINCE_NAME}}', _task!.provinceName) - .replaceAll('{{CITY_NAME}}', _task!.cityName) - .replaceAll('{{SIGNING_DATE}}', _formatDateTime(DateTime.now()).split(' ')[0]) - .replaceAll('{{USER_SIGNATURE}}', '[待签名]') - .replaceAll('{{SIGNING_TIMESTAMP}}', DateTime.now().toIso8601String()); - - return Text( - text.trim(), - style: const TextStyle( - fontSize: 14, - height: 1.8, - color: Color(0xFF333333), - ), - ); - } - /// 构建底部操作区 Widget _buildBottomActions() { if (_task!.status == ContractSigningStatus.signed) { diff --git a/frontend/mobile-app/pubspec.lock b/frontend/mobile-app/pubspec.lock index f9a6e5fb..a3b4af4a 100644 --- a/frontend/mobile-app/pubspec.lock +++ b/frontend/mobile-app/pubspec.lock @@ -419,6 +419,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_pdfview: + dependency: "direct main" + description: + name: flutter_pdfview + sha256: c0b2cc4ebf461a5a4bb9312a165222475a7d93845c7a0703f4abb7f442eb6d54 + url: "https://pub.dev" + source: hosted + version: "1.4.3" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -533,6 +541,54 @@ packages: url: "https://pub.dev" source: hosted version: "4.0.0" + geolocator: + dependency: "direct main" + description: + name: geolocator + sha256: f62bcd90459e63210bbf9c35deb6a51c521f992a78de19a1fe5c11704f9530e2 + url: "https://pub.dev" + source: hosted + version: "13.0.4" + geolocator_android: + dependency: transitive + description: + name: geolocator_android + sha256: fcb1760a50d7500deca37c9a666785c047139b5f9ee15aa5469fae7dbbe3170d + url: "https://pub.dev" + source: hosted + version: "4.6.2" + geolocator_apple: + dependency: transitive + description: + name: geolocator_apple + sha256: dbdd8789d5aaf14cf69f74d4925ad1336b4433a6efdf2fce91e8955dc921bf22 + url: "https://pub.dev" + source: hosted + version: "2.3.13" + geolocator_platform_interface: + dependency: transitive + description: + name: geolocator_platform_interface + sha256: "30cb64f0b9adcc0fb36f628b4ebf4f731a2961a0ebd849f4b56200205056fe67" + url: "https://pub.dev" + source: hosted + version: "4.2.6" + geolocator_web: + dependency: transitive + description: + name: geolocator_web + sha256: b1ae9bdfd90f861fde8fd4f209c37b953d65e92823cb73c7dee1fa021b06f172 + url: "https://pub.dev" + source: hosted + version: "4.1.3" + geolocator_windows: + dependency: transitive + description: + name: geolocator_windows + sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" + url: "https://pub.dev" + source: hosted + version: "0.2.5" glob: dependency: transitive description: diff --git a/frontend/mobile-app/pubspec.yaml b/frontend/mobile-app/pubspec.yaml index f73ce6af..c8347aef 100644 --- a/frontend/mobile-app/pubspec.yaml +++ b/frontend/mobile-app/pubspec.yaml @@ -68,6 +68,9 @@ dependencies: # 崩溃收集与错误追踪 sentry_flutter: ^8.10.1 + # PDF 查看 + flutter_pdfview: ^1.3.2 + cupertino_icons: ^1.0.8 dev_dependencies: