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 * 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
*/
@ -36,6 +63,7 @@ interface SignContractDto {
latitude?: number;
longitude?: number;
};
signatureTrace?: SignatureTraceData; // 签名轨迹数据(可选)
}
/**
@ -274,7 +302,12 @@ export class ContractSigningController {
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, {
signatureCloudUrl,
signatureHash,
@ -283,6 +316,7 @@ export class ContractSigningController {
deviceInfo: dto.deviceInfo,
userAgent,
location: dto.location,
signatureTrace: dto.signatureTrace ? JSON.stringify(dto.signatureTrace) : undefined,
});
return {
@ -361,7 +395,12 @@ export class ContractSigningController {
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, {
signatureCloudUrl,
signatureHash,
@ -370,6 +409,7 @@ export class ContractSigningController {
deviceInfo: dto.deviceInfo,
userAgent,
location: dto.location,
signatureTrace: dto.signatureTrace ? JSON.stringify(dto.signatureTrace) : undefined,
});
return {

View File

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

View File

@ -404,12 +404,16 @@ class ContractSigningService {
String? userAgent,
double? latitude,
double? longitude,
Map<String, dynamic>? signatureTrace, //
}) async {
try {
debugPrint('[ContractSigningService] 签署合同: $orderNo');
debugPrint('[ContractSigningService] 签名图片大小: ${signatureImage.length} bytes');
debugPrint('[ContractSigningService] IP: $ipAddress, 设备: $deviceInfo');
debugPrint('[ContractSigningService] 位置: lat=$latitude, lng=$longitude');
if (signatureTrace != null) {
debugPrint('[ContractSigningService] 签名轨迹: ${signatureTrace['strokeCount']} 笔画, ${signatureTrace['totalDuration']}ms');
}
// base64
final signatureBase64 = base64Encode(signatureImage);
@ -425,6 +429,7 @@ class ContractSigningService {
'location': latitude != null && longitude != null
? {'latitude': latitude, 'longitude': longitude}
: null,
'signatureTrace': signatureTrace, //
};
debugPrint('[ContractSigningService] 请求 URL: /planting/contract-signing/tasks/$orderNo/sign');
@ -470,12 +475,16 @@ class ContractSigningService {
String? userAgent,
double? latitude,
double? longitude,
Map<String, dynamic>? signatureTrace, //
}) async {
try {
debugPrint('[ContractSigningService] 补签合同: $orderNo');
debugPrint('[ContractSigningService] 签名图片大小: ${signatureImage.length} bytes');
debugPrint('[ContractSigningService] IP: $ipAddress, 设备: $deviceInfo');
debugPrint('[ContractSigningService] 位置: lat=$latitude, lng=$longitude');
if (signatureTrace != null) {
debugPrint('[ContractSigningService] 签名轨迹: ${signatureTrace['strokeCount']} 笔画, ${signatureTrace['totalDuration']}ms');
}
final signatureBase64 = base64Encode(signatureImage);
debugPrint('[ContractSigningService] Base64 长度: ${signatureBase64.length}');
@ -490,6 +499,7 @@ class ContractSigningService {
'location': latitude != null && longitude != null
? {'latitude': latitude, 'longitude': longitude}
: null,
'signatureTrace': signatureTrace, //
};
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;
//
debugPrint('[ContractSigningPage] 签名轨迹数据: ${traceData.strokeCount} 笔画, 总时长 ${traceData.totalDuration}ms');
setState(() {
_showSignaturePad = false;
_isSubmitting = true;
@ -318,6 +321,9 @@ class _ContractSigningPageState extends ConsumerState<ContractSigningPage> {
final service = ref.read(contractSigningServiceProvider);
// Map
final traceDataMap = traceData.toJson();
//
if (_task!.status == ContractSigningStatus.unsignedTimeout) {
await service.lateSignContract(
@ -328,6 +334,7 @@ class _ContractSigningPageState extends ConsumerState<ContractSigningPage> {
userAgent: userAgent,
latitude: latitude,
longitude: longitude,
signatureTrace: traceDataMap,
);
} else {
await service.signContract(
@ -338,6 +345,7 @@ class _ContractSigningPageState extends ConsumerState<ContractSigningPage> {
userAgent: userAgent,
latitude: latitude,
longitude: longitude,
signatureTrace: traceDataMap,
);
}
@ -934,16 +942,11 @@ class _ContractSigningPageState extends ConsumerState<ContractSigningPage> {
///
Widget _buildSignaturePad() {
return Column(
children: [
_buildHeader(),
Expanded(
child: SignaturePad(
onSubmit: _submitSignature,
onCancel: () => setState(() => _showSignaturePad = false),
),
),
],
// header
return SignaturePad(
onSubmit: _submitSignature,
onCancel: () => setState(() => _showSignaturePad = false),
userName: _task?.userRealName,
);
}
}

View File

@ -1,16 +1,76 @@
import 'dart:typed_data';
import 'dart:ui' as ui;
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 {
final Function(Uint8List) onSubmit;
final Function(Uint8List, SignatureTraceData) onSubmit;
final VoidCallback onCancel;
final String? userName; //
const SignaturePad({
super.key,
required this.onSubmit,
required this.onCancel,
this.userName,
});
@override
@ -18,20 +78,56 @@ class SignaturePad extends StatefulWidget {
}
class _SignaturePadState extends State<SignaturePad> {
///
///
final List<List<Offset>> _strokes = [];
///
List<Offset> _currentStroke = [];
///
final List<SignatureStroke> _traceStrokes = [];
///
List<SignaturePoint> _currentTracePoints = [];
///
int? _currentStrokeStartTime;
///
int? _signatureStartTime;
///
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() {
setState(() {
_strokes.clear();
_currentStroke.clear();
_traceStrokes.clear();
_currentTracePoints.clear();
_currentStrokeStartTime = null;
_signatureStartTime = null;
});
}
@ -50,7 +146,15 @@ class _SignaturePadState extends State<SignaturePad> {
//
final image = await _renderSignatureImage();
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
Widget build(BuildContext context) {
return Container(
color: const Color(0xFFFFF7E6),
child: Column(
child: Row(
children: [
//
//
Container(
padding: const EdgeInsets.all(16),
child: const Text(
'请在下方区域签名',
style: TextStyle(
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(
width: 100,
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 8),
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
//
Expanded(
child: GestureDetector(
onTap: widget.onCancel,
child: Container(
height: 56,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0xFFD4AF37),
width: 1,
),
GestureDetector(
onTap: widget.onCancel,
child: Container(
width: 80,
height: 48,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFFD4AF37),
width: 1,
),
child: const Center(
child: Text(
'取消',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Color(0xFFD4AF37),
),
),
child: const Center(
child: Text(
'取消',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFFD4AF37),
),
),
),
),
),
const SizedBox(width: 12),
//
GestureDetector(
onTap: _clear,
child: Container(
width: 56,
height: 56,
width: 80,
height: 48,
decoration: BoxDecoration(
color: const Color(0xFFFFEBEE),
borderRadius: BorderRadius.circular(12),
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.refresh,
color: Color(0xFFE53935),
size: 24,
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
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,
child: Container(
height: 56,
decoration: BoxDecoration(
color: _hasSignature
? const Color(0xFFD4AF37)
: const Color(0xFFD4AF37).withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(12),
boxShadow: _hasSignature
? [
BoxShadow(
color: const Color(0xFFD4AF37).withValues(alpha: 0.3),
blurRadius: 15,
offset: const Offset(0, 8),
),
]
: null,
],
),
),
//
Expanded(
child: Column(
children: [
//
Container(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
children: [
//
const Text(
'请使用正楷书写您的真实姓名',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFFE53935),
),
),
child: const Center(
child: Text(
'确认签名',
const SizedBox(height: 4),
//
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(
fontSize: 16,
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
],
),
),
),