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:
parent
2a85fcc7fa
commit
c509daa353
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
],
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
export * from './pdf-generator.service';
|
||||
export * from './pdf.module';
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { PdfGeneratorService } from './pdf-generator.service';
|
||||
|
||||
@Module({
|
||||
providers: [PdfGeneratorService],
|
||||
exports: [PdfGeneratorService],
|
||||
})
|
||||
export class PdfModule {}
|
||||
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
|
|
@ -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 编码函数
|
||||
|
|
|
|||
|
|
@ -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(' ', ' ')
|
||||
.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) {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -68,6 +68,9 @@ dependencies:
|
|||
# 崩溃收集与错误追踪
|
||||
sentry_flutter: ^8.10.1
|
||||
|
||||
# PDF 查看
|
||||
flutter_pdfview: ^1.3.2
|
||||
|
||||
cupertino_icons: ^1.0.8
|
||||
|
||||
dev_dependencies:
|
||||
|
|
|
|||
Loading…
Reference in New Issue