diff --git a/backend/services/planting-service/src/api/controllers/contract-signing.controller.ts b/backend/services/planting-service/src/api/controllers/contract-signing.controller.ts index 8956df61..8b9cd0d1 100644 --- a/backend/services/planting-service/src/api/controllers/contract-signing.controller.ts +++ b/backend/services/planting-service/src/api/controllers/contract-signing.controller.ts @@ -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 { diff --git a/backend/services/planting-service/src/domain/aggregates/contract-signing-task.aggregate.ts b/backend/services/planting-service/src/domain/aggregates/contract-signing-task.aggregate.ts index bf229049..63785ebf 100644 --- a/backend/services/planting-service/src/domain/aggregates/contract-signing-task.aggregate.ts +++ b/backend/services/planting-service/src/domain/aggregates/contract-signing-task.aggregate.ts @@ -58,6 +58,7 @@ export interface SignContractParams { deviceInfo: DeviceInfo; userAgent: string; location?: SigningLocation; + signatureTrace?: string; // JSON 格式的签名轨迹数据(时间戳、笔画顺序) } export class ContractSigningTask { diff --git a/frontend/mobile-app/lib/core/services/contract_signing_service.dart b/frontend/mobile-app/lib/core/services/contract_signing_service.dart index d3bfaad5..bcc1f059 100644 --- a/frontend/mobile-app/lib/core/services/contract_signing_service.dart +++ b/frontend/mobile-app/lib/core/services/contract_signing_service.dart @@ -404,12 +404,16 @@ class ContractSigningService { String? userAgent, double? latitude, double? longitude, + Map? 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? 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'); diff --git a/frontend/mobile-app/lib/features/contract_signing/presentation/pages/contract_signing_page.dart b/frontend/mobile-app/lib/features/contract_signing/presentation/pages/contract_signing_page.dart index 6e4d8b89..b4aa3b17 100644 --- a/frontend/mobile-app/lib/features/contract_signing/presentation/pages/contract_signing_page.dart +++ b/frontend/mobile-app/lib/features/contract_signing/presentation/pages/contract_signing_page.dart @@ -274,9 +274,12 @@ class _ContractSigningPageState extends ConsumerState { } /// 提交签名 - Future _submitSignature(Uint8List signatureImage) async { + Future _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 { 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 { userAgent: userAgent, latitude: latitude, longitude: longitude, + signatureTrace: traceDataMap, ); } else { await service.signContract( @@ -338,6 +345,7 @@ class _ContractSigningPageState extends ConsumerState { userAgent: userAgent, latitude: latitude, longitude: longitude, + signatureTrace: traceDataMap, ); } @@ -934,16 +942,11 @@ class _ContractSigningPageState extends ConsumerState { /// 构建签名面板 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, ); } } diff --git a/frontend/mobile-app/lib/features/contract_signing/presentation/widgets/signature_pad.dart b/frontend/mobile-app/lib/features/contract_signing/presentation/widgets/signature_pad.dart index 178f4869..a033c17a 100644 --- a/frontend/mobile-app/lib/features/contract_signing/presentation/widgets/signature_pad.dart +++ b/frontend/mobile-app/lib/features/contract_signing/presentation/widgets/signature_pad.dart @@ -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 toJson() => { + 'x': x, + 'y': y, + 't': timestamp, + }; +} + +/// 签名笔画数据 +class SignatureStroke { + final List points; + final int startTime; + final int endTime; + + SignatureStroke({ + required this.points, + required this.startTime, + required this.endTime, + }); + + Map toJson() => { + 'points': points.map((p) => p.toJson()).toList(), + 'startTime': startTime, + 'endTime': endTime, + }; +} + +/// 签名轨迹数据(用于法律凭证) +class SignatureTraceData { + final List strokes; + final int totalDuration; // 总签名时长(毫秒) + final int strokeCount; // 笔画数量 + + SignatureTraceData({ + required this.strokes, + required this.totalDuration, + required this.strokeCount, + }); + + Map 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 { - /// 签名路径列表 + /// 签名路径列表(用于绘制) final List> _strokes = []; /// 当前笔画 List _currentStroke = []; + /// 签名轨迹数据(带时间戳) + final List _traceStrokes = []; + + /// 当前笔画的轨迹点 + List _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 { // 将签名转换为图片 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 { } } + /// 处理笔画开始 + 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, ), ), - ), + ], ), ), ),