feat(contract-signing): 使用 pdf-lib 实现专业 PDF 合同展示

后端改动:
- 添加 pdf-lib 和 @pdf-lib/fontkit 依赖
- 新建 PdfGeneratorService 使用 PDF 模板直接填充用户数据
- 添加中文字体支持 (NotoSansSC-Regular.ttf)
- 新增 GET /tasks/:orderNo/pdf 接口返回 PDF 文件
- 合同模板存放于 templates/contract-template.pdf

前端改动:
- 添加 flutter_pdfview 依赖
- 重写合同签署页面使用 PDFView 组件展示 PDF
- 下载 PDF 到临时目录后展示
- 滑动到最后一页自动标记已阅读

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2025-12-25 02:27:54 -08:00
parent 2a85fcc7fa
commit c509daa353
15 changed files with 2112 additions and 192 deletions

View File

@ -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",

View File

@ -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"

View File

@ -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<StreamableFile> {
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;
}
}
}

View File

@ -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,

View File

@ -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,
],

View File

@ -0,0 +1,2 @@
export * from './pdf-generator.service';
export * from './pdf.module';

View File

@ -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<Buffer> {
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<Buffer> {
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<Buffer> {
// 先生成填充数据的 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);
}
}

View File

@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { PdfGeneratorService } from './pdf-generator.service';
@Module({
providers: [PdfGeneratorService],
exports: [PdfGeneratorService],
})
export class PdfModule {}

File diff suppressed because one or more lines are too long

View File

@ -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<Uint8List> 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

View File

@ -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<ContractSigningPage> {
///
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<ContractSigningPage> {
///
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<ContractSigningPage> {
//
_startCountdown();
// PDF
if (task.status != ContractSigningStatus.signed) {
_loadPdf();
}
} catch (e) {
setState(() {
_isLoading = false;
@ -98,6 +115,33 @@ class _ContractSigningPageState extends ConsumerState<ContractSigningPage> {
}
}
/// PDF
Future<void> _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<ContractSigningPage> {
}
}
///
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<ContractSigningPage> {
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<ContractSigningPage> {
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<ContractSigningPage> {
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<ContractSigningPage> {
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<ContractSigningPage> {
}
setState(() {
_task = updatedTask;
_isSubmitting = false;
});
@ -473,6 +515,15 @@ class _ContractSigningPageState extends ConsumerState<ContractSigningPage> {
),
),
),
//
if (_totalPages > 0)
Text(
'${_currentPage + 1}/$_totalPages',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF666666),
),
),
],
),
);
@ -520,39 +571,93 @@ class _ContractSigningPageState extends ConsumerState<ContractSigningPage> {
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<ContractSigningPage> {
///
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('&nbsp;', ' ')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&amp;', '&')
.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) {

View File

@ -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:

View File

@ -68,6 +68,9 @@ dependencies:
# 崩溃收集与错误追踪
sentry_flutter: ^8.10.1
# PDF 查看
flutter_pdfview: ^1.3.2
cupertino_icons: ^1.0.8
dev_dependencies: