feat(contract-signing): 增强签名功能

前端改进:
- 签名页面添加红色醒目提示"请使用正楷书写您的真实姓名"
- 签名前显示用户真实姓名供参照
- 实现全屏横向签名面板(自动切换横屏/竖屏)
- 记录签名轨迹数据(每个点的坐标和时间戳、笔画顺序)

后端改进:
- 扩展SignContractParams接口支持signatureTrace字段
- 控制器记录签名轨迹日志(笔画数、总时长)
- 签名轨迹数据以JSON格式存储,作为法律凭证

🤖 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-26 06:42:15 -08:00
parent d4763ea5bf
commit 954f170bd4
5 changed files with 420 additions and 148 deletions

View File

@ -20,6 +20,33 @@ import { PdfGeneratorService } from '../../infrastructure/pdf/pdf-generator.serv
import { MinioStorageService } from '../../infrastructure/storage/minio-storage.service'; import { MinioStorageService } from '../../infrastructure/storage/minio-storage.service';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
/**
*
*/
interface SignatureTracePoint {
x: number;
y: number;
t: number; // 毫秒时间戳
}
/**
*
*/
interface SignatureTraceStroke {
points: SignatureTracePoint[];
startTime: number;
endTime: number;
}
/**
*
*/
interface SignatureTraceData {
strokes: SignatureTraceStroke[];
totalDuration: number; // 总签名时长(毫秒)
strokeCount: number; // 笔画数量
}
/** /**
* DTO * DTO
*/ */
@ -36,6 +63,7 @@ interface SignContractDto {
latitude?: number; latitude?: number;
longitude?: number; longitude?: number;
}; };
signatureTrace?: SignatureTraceData; // 签名轨迹数据(可选)
} }
/** /**
@ -274,7 +302,12 @@ export class ContractSigningController {
signedPdfUrl = ''; signedPdfUrl = '';
} }
// 8. 完成签署 // 8. 记录签名轨迹数据(如果有)
if (dto.signatureTrace) {
this.logger.log(`Signature trace: ${dto.signatureTrace.strokeCount} strokes, ${dto.signatureTrace.totalDuration}ms`);
}
// 9. 完成签署
await this.contractSigningService.signContract(orderNo, userId, { await this.contractSigningService.signContract(orderNo, userId, {
signatureCloudUrl, signatureCloudUrl,
signatureHash, signatureHash,
@ -283,6 +316,7 @@ export class ContractSigningController {
deviceInfo: dto.deviceInfo, deviceInfo: dto.deviceInfo,
userAgent, userAgent,
location: dto.location, location: dto.location,
signatureTrace: dto.signatureTrace ? JSON.stringify(dto.signatureTrace) : undefined,
}); });
return { return {
@ -361,7 +395,12 @@ export class ContractSigningController {
signedPdfUrl = ''; signedPdfUrl = '';
} }
// 8. 完成补签 // 8. 记录签名轨迹数据(如果有)
if (dto.signatureTrace) {
this.logger.log(`Signature trace: ${dto.signatureTrace.strokeCount} strokes, ${dto.signatureTrace.totalDuration}ms`);
}
// 9. 完成补签
await this.contractSigningService.lateSignContract(orderNo, userId, { await this.contractSigningService.lateSignContract(orderNo, userId, {
signatureCloudUrl, signatureCloudUrl,
signatureHash, signatureHash,
@ -370,6 +409,7 @@ export class ContractSigningController {
deviceInfo: dto.deviceInfo, deviceInfo: dto.deviceInfo,
userAgent, userAgent,
location: dto.location, location: dto.location,
signatureTrace: dto.signatureTrace ? JSON.stringify(dto.signatureTrace) : undefined,
}); });
return { return {

View File

@ -58,6 +58,7 @@ export interface SignContractParams {
deviceInfo: DeviceInfo; deviceInfo: DeviceInfo;
userAgent: string; userAgent: string;
location?: SigningLocation; location?: SigningLocation;
signatureTrace?: string; // JSON 格式的签名轨迹数据(时间戳、笔画顺序)
} }
export class ContractSigningTask { export class ContractSigningTask {

View File

@ -404,12 +404,16 @@ class ContractSigningService {
String? userAgent, String? userAgent,
double? latitude, double? latitude,
double? longitude, double? longitude,
Map<String, dynamic>? signatureTrace, //
}) async { }) async {
try { try {
debugPrint('[ContractSigningService] 签署合同: $orderNo'); debugPrint('[ContractSigningService] 签署合同: $orderNo');
debugPrint('[ContractSigningService] 签名图片大小: ${signatureImage.length} bytes'); debugPrint('[ContractSigningService] 签名图片大小: ${signatureImage.length} bytes');
debugPrint('[ContractSigningService] IP: $ipAddress, 设备: $deviceInfo'); debugPrint('[ContractSigningService] IP: $ipAddress, 设备: $deviceInfo');
debugPrint('[ContractSigningService] 位置: lat=$latitude, lng=$longitude'); debugPrint('[ContractSigningService] 位置: lat=$latitude, lng=$longitude');
if (signatureTrace != null) {
debugPrint('[ContractSigningService] 签名轨迹: ${signatureTrace['strokeCount']} 笔画, ${signatureTrace['totalDuration']}ms');
}
// base64 // base64
final signatureBase64 = base64Encode(signatureImage); final signatureBase64 = base64Encode(signatureImage);
@ -425,6 +429,7 @@ class ContractSigningService {
'location': latitude != null && longitude != null 'location': latitude != null && longitude != null
? {'latitude': latitude, 'longitude': longitude} ? {'latitude': latitude, 'longitude': longitude}
: null, : null,
'signatureTrace': signatureTrace, //
}; };
debugPrint('[ContractSigningService] 请求 URL: /planting/contract-signing/tasks/$orderNo/sign'); debugPrint('[ContractSigningService] 请求 URL: /planting/contract-signing/tasks/$orderNo/sign');
@ -470,12 +475,16 @@ class ContractSigningService {
String? userAgent, String? userAgent,
double? latitude, double? latitude,
double? longitude, double? longitude,
Map<String, dynamic>? signatureTrace, //
}) async { }) async {
try { try {
debugPrint('[ContractSigningService] 补签合同: $orderNo'); debugPrint('[ContractSigningService] 补签合同: $orderNo');
debugPrint('[ContractSigningService] 签名图片大小: ${signatureImage.length} bytes'); debugPrint('[ContractSigningService] 签名图片大小: ${signatureImage.length} bytes');
debugPrint('[ContractSigningService] IP: $ipAddress, 设备: $deviceInfo'); debugPrint('[ContractSigningService] IP: $ipAddress, 设备: $deviceInfo');
debugPrint('[ContractSigningService] 位置: lat=$latitude, lng=$longitude'); debugPrint('[ContractSigningService] 位置: lat=$latitude, lng=$longitude');
if (signatureTrace != null) {
debugPrint('[ContractSigningService] 签名轨迹: ${signatureTrace['strokeCount']} 笔画, ${signatureTrace['totalDuration']}ms');
}
final signatureBase64 = base64Encode(signatureImage); final signatureBase64 = base64Encode(signatureImage);
debugPrint('[ContractSigningService] Base64 长度: ${signatureBase64.length}'); debugPrint('[ContractSigningService] Base64 长度: ${signatureBase64.length}');
@ -490,6 +499,7 @@ class ContractSigningService {
'location': latitude != null && longitude != null 'location': latitude != null && longitude != null
? {'latitude': latitude, 'longitude': longitude} ? {'latitude': latitude, 'longitude': longitude}
: null, : null,
'signatureTrace': signatureTrace, //
}; };
debugPrint('[ContractSigningService] 请求 URL: /planting/contract-signing/tasks/$orderNo/late-sign'); debugPrint('[ContractSigningService] 请求 URL: /planting/contract-signing/tasks/$orderNo/late-sign');

View File

@ -274,9 +274,12 @@ class _ContractSigningPageState extends ConsumerState<ContractSigningPage> {
} }
/// ///
Future<void> _submitSignature(Uint8List signatureImage) async { Future<void> _submitSignature(Uint8List signatureImage, SignatureTraceData traceData) async {
if (_isSubmitting || _task == null) return; if (_isSubmitting || _task == null) return;
//
debugPrint('[ContractSigningPage] 签名轨迹数据: ${traceData.strokeCount} 笔画, 总时长 ${traceData.totalDuration}ms');
setState(() { setState(() {
_showSignaturePad = false; _showSignaturePad = false;
_isSubmitting = true; _isSubmitting = true;
@ -318,6 +321,9 @@ class _ContractSigningPageState extends ConsumerState<ContractSigningPage> {
final service = ref.read(contractSigningServiceProvider); final service = ref.read(contractSigningServiceProvider);
// Map
final traceDataMap = traceData.toJson();
// //
if (_task!.status == ContractSigningStatus.unsignedTimeout) { if (_task!.status == ContractSigningStatus.unsignedTimeout) {
await service.lateSignContract( await service.lateSignContract(
@ -328,6 +334,7 @@ class _ContractSigningPageState extends ConsumerState<ContractSigningPage> {
userAgent: userAgent, userAgent: userAgent,
latitude: latitude, latitude: latitude,
longitude: longitude, longitude: longitude,
signatureTrace: traceDataMap,
); );
} else { } else {
await service.signContract( await service.signContract(
@ -338,6 +345,7 @@ class _ContractSigningPageState extends ConsumerState<ContractSigningPage> {
userAgent: userAgent, userAgent: userAgent,
latitude: latitude, latitude: latitude,
longitude: longitude, longitude: longitude,
signatureTrace: traceDataMap,
); );
} }
@ -934,16 +942,11 @@ class _ContractSigningPageState extends ConsumerState<ContractSigningPage> {
/// ///
Widget _buildSignaturePad() { Widget _buildSignaturePad() {
return Column( // header
children: [ return SignaturePad(
_buildHeader(), onSubmit: _submitSignature,
Expanded( onCancel: () => setState(() => _showSignaturePad = false),
child: SignaturePad( userName: _task?.userRealName,
onSubmit: _submitSignature,
onCancel: () => setState(() => _showSignaturePad = false),
),
),
],
); );
} }
} }

View File

@ -1,16 +1,76 @@
import 'dart:typed_data'; import 'dart:typed_data';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
///
class SignaturePoint {
final double x;
final double y;
final int timestamp; //
SignaturePoint({
required this.x,
required this.y,
required this.timestamp,
});
Map<String, dynamic> toJson() => {
'x': x,
'y': y,
't': timestamp,
};
}
///
class SignatureStroke {
final List<SignaturePoint> points;
final int startTime;
final int endTime;
SignatureStroke({
required this.points,
required this.startTime,
required this.endTime,
});
Map<String, dynamic> toJson() => {
'points': points.map((p) => p.toJson()).toList(),
'startTime': startTime,
'endTime': endTime,
};
}
///
class SignatureTraceData {
final List<SignatureStroke> strokes;
final int totalDuration; //
final int strokeCount; //
SignatureTraceData({
required this.strokes,
required this.totalDuration,
required this.strokeCount,
});
Map<String, dynamic> toJson() => {
'strokes': strokes.map((s) => s.toJson()).toList(),
'totalDuration': totalDuration,
'strokeCount': strokeCount,
};
}
/// ///
class SignaturePad extends StatefulWidget { class SignaturePad extends StatefulWidget {
final Function(Uint8List) onSubmit; final Function(Uint8List, SignatureTraceData) onSubmit;
final VoidCallback onCancel; final VoidCallback onCancel;
final String? userName; //
const SignaturePad({ const SignaturePad({
super.key, super.key,
required this.onSubmit, required this.onSubmit,
required this.onCancel, required this.onCancel,
this.userName,
}); });
@override @override
@ -18,20 +78,56 @@ class SignaturePad extends StatefulWidget {
} }
class _SignaturePadState extends State<SignaturePad> { class _SignaturePadState extends State<SignaturePad> {
/// ///
final List<List<Offset>> _strokes = []; final List<List<Offset>> _strokes = [];
/// ///
List<Offset> _currentStroke = []; List<Offset> _currentStroke = [];
///
final List<SignatureStroke> _traceStrokes = [];
///
List<SignaturePoint> _currentTracePoints = [];
///
int? _currentStrokeStartTime;
///
int? _signatureStartTime;
/// ///
bool get _hasSignature => _strokes.isNotEmpty || _currentStroke.isNotEmpty; bool get _hasSignature => _strokes.isNotEmpty || _currentStroke.isNotEmpty;
@override
void initState() {
super.initState();
//
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
}
@override
void dispose() {
//
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]);
super.dispose();
}
/// ///
void _clear() { void _clear() {
setState(() { setState(() {
_strokes.clear(); _strokes.clear();
_currentStroke.clear(); _currentStroke.clear();
_traceStrokes.clear();
_currentTracePoints.clear();
_currentStrokeStartTime = null;
_signatureStartTime = null;
}); });
} }
@ -50,7 +146,15 @@ class _SignaturePadState extends State<SignaturePad> {
// //
final image = await _renderSignatureImage(); final image = await _renderSignatureImage();
if (image != null) { if (image != null) {
widget.onSubmit(image); //
final now = DateTime.now().millisecondsSinceEpoch;
final totalDuration = _signatureStartTime != null ? now - _signatureStartTime! : 0;
final traceData = SignatureTraceData(
strokes: _traceStrokes,
totalDuration: totalDuration,
strokeCount: _traceStrokes.length,
);
widget.onSubmit(image, traceData);
} }
} }
@ -108,168 +212,282 @@ class _SignaturePadState extends State<SignaturePad> {
} }
} }
///
void _onPanStart(DragStartDetails details) {
final now = DateTime.now().millisecondsSinceEpoch;
//
_signatureStartTime ??= now;
//
_currentStrokeStartTime = now;
setState(() {
_currentStroke = [details.localPosition];
_currentTracePoints = [
SignaturePoint(
x: details.localPosition.dx,
y: details.localPosition.dy,
timestamp: now,
),
];
});
}
///
void _onPanUpdate(DragUpdateDetails details) {
final now = DateTime.now().millisecondsSinceEpoch;
setState(() {
_currentStroke.add(details.localPosition);
_currentTracePoints.add(
SignaturePoint(
x: details.localPosition.dx,
y: details.localPosition.dy,
timestamp: now,
),
);
});
}
///
void _onPanEnd(DragEndDetails details) {
final now = DateTime.now().millisecondsSinceEpoch;
setState(() {
if (_currentStroke.isNotEmpty) {
_strokes.add(List.from(_currentStroke));
_currentStroke.clear();
//
if (_currentTracePoints.isNotEmpty && _currentStrokeStartTime != null) {
_traceStrokes.add(
SignatureStroke(
points: List.from(_currentTracePoints),
startTime: _currentStrokeStartTime!,
endTime: now,
),
);
}
_currentTracePoints.clear();
_currentStrokeStartTime = null;
}
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
color: const Color(0xFFFFF7E6), color: const Color(0xFFFFF7E6),
child: Column( child: Row(
children: [ children: [
// //
Container( Container(
padding: const EdgeInsets.all(16), width: 100,
child: const Text( padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
'请在下方区域签名', child: Column(
style: TextStyle( mainAxisAlignment: MainAxisAlignment.spaceBetween,
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
),
//
Expanded(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0xFFD4AF37),
width: 2,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: GestureDetector(
onPanStart: (details) {
setState(() {
_currentStroke = [details.localPosition];
});
},
onPanUpdate: (details) {
setState(() {
_currentStroke.add(details.localPosition);
});
},
onPanEnd: (details) {
setState(() {
if (_currentStroke.isNotEmpty) {
_strokes.add(List.from(_currentStroke));
_currentStroke.clear();
}
});
},
child: CustomPaint(
painter: _SignaturePainter(
strokes: _strokes,
currentStroke: _currentStroke,
),
child: Container(),
),
),
),
),
),
//
Padding(
padding: const EdgeInsets.all(16),
child: Text(
_hasSignature ? '已签名,点击确认提交' : '在白色区域内签名',
style: TextStyle(
fontSize: 14,
color: _hasSignature ? const Color(0xFF4CAF50) : const Color(0xFF999999),
),
),
),
//
Container(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
child: Row(
children: [ children: [
// //
Expanded( GestureDetector(
child: GestureDetector( onTap: widget.onCancel,
onTap: widget.onCancel, child: Container(
child: Container( width: 80,
height: 56, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: const Color(0xFFD4AF37), color: const Color(0xFFD4AF37),
width: 1, width: 1,
),
), ),
child: const Center( ),
child: Text( child: const Center(
'取消', child: Text(
style: TextStyle( '取消',
fontSize: 16, style: TextStyle(
fontWeight: FontWeight.w600, fontSize: 14,
color: Color(0xFFD4AF37), fontWeight: FontWeight.w600,
), color: Color(0xFFD4AF37),
), ),
), ),
), ),
), ),
), ),
const SizedBox(width: 12),
// //
GestureDetector( GestureDetector(
onTap: _clear, onTap: _clear,
child: Container( child: Container(
width: 56, width: 80,
height: 56, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFFEBEE), color: const Color(0xFFFFEBEE),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(8),
), ),
child: const Icon( child: const Row(
Icons.refresh, mainAxisAlignment: MainAxisAlignment.center,
color: Color(0xFFE53935), children: [
size: 24, Icon(
Icons.refresh,
color: Color(0xFFE53935),
size: 18,
),
SizedBox(width: 4),
Text(
'清除',
style: TextStyle(
fontSize: 14,
color: Color(0xFFE53935),
),
),
],
), ),
), ),
), ),
const SizedBox(width: 12), ],
// ),
Expanded( ),
child: GestureDetector( //
onTap: _submit, Expanded(
child: Container( child: Column(
height: 56, children: [
decoration: BoxDecoration( //
color: _hasSignature Container(
? const Color(0xFFD4AF37) padding: const EdgeInsets.symmetric(vertical: 8),
: const Color(0xFFD4AF37).withValues(alpha: 0.5), child: Column(
borderRadius: BorderRadius.circular(12), children: [
boxShadow: _hasSignature //
? [ const Text(
BoxShadow( '请使用正楷书写您的真实姓名',
color: const Color(0xFFD4AF37).withValues(alpha: 0.3), style: TextStyle(
blurRadius: 15, fontSize: 16,
offset: const Offset(0, 8), fontWeight: FontWeight.bold,
), color: Color(0xFFE53935),
] ),
: null,
), ),
child: const Center( const SizedBox(height: 4),
child: Text( //
'确认签名', if (widget.userName != null && widget.userName!.isNotEmpty)
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'请签署:',
style: TextStyle(
fontSize: 14,
color: Color(0xFF666666),
),
),
Text(
widget.userName!,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Color(0xFF5D4037),
),
),
],
),
],
),
),
//
Expanded(
child: Container(
margin: const EdgeInsets.fromLTRB(0, 0, 0, 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0xFFD4AF37),
width: 2,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(10),
child: GestureDetector(
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
onPanEnd: _onPanEnd,
child: CustomPaint(
painter: _SignaturePainter(
strokes: _strokes,
currentStroke: _currentStroke,
),
child: Container(),
),
),
),
),
),
//
Container(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
_hasSignature ? '已签名,点击右侧确认提交' : '在白色区域内签名',
style: TextStyle(
fontSize: 12,
color: _hasSignature
? const Color(0xFF4CAF50)
: const Color(0xFF999999),
),
),
),
],
),
),
//
Container(
width: 100,
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
GestureDetector(
onTap: _submit,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: _hasSignature
? const Color(0xFFD4AF37)
: const Color(0xFFD4AF37).withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(40),
boxShadow: _hasSignature
? [
BoxShadow(
color:
const Color(0xFFD4AF37).withValues(alpha: 0.3),
blurRadius: 15,
offset: const Offset(0, 8),
),
]
: null,
),
child: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.check,
color: Colors.white,
size: 32,
),
Text(
'确认',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.white, color: Colors.white,
), ),
), ),
), ],
), ),
), ),
), ),