From ad8fbfae1b2d6679dae02ed35fc007fb3239ab51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=86=95?= Date: Tue, 21 Jul 2026 17:47:42 +0800 Subject: [PATCH] sync driver training module integration on V1.3.2 base --- .../xll-miniapp/annotation-source.json | 46 +- .../xll-miniapp/directory.pages.json | 2 +- .../driver-training/DriverTrainingModule.jsx | 136 ++ .../components/CameraCapture.jsx | 169 ++ .../components/CertificateUploadCard.jsx | 42 + .../components/ConfirmDialog.jsx | 33 + .../components/DriverTrainingLayout.jsx | 116 + .../components/OCRResultForm.jsx | 87 + .../components/PhoneVerificationForm.jsx | 137 ++ .../components/PickupCodeCard.jsx | 89 + .../components/PrototypeDebugPanel.jsx | 78 + .../components/SignatureCanvas.jsx | 165 ++ .../driver-training/components/StatusTag.jsx | 17 + .../driver-training/components/Toast.jsx | 28 + .../components/TrainingDocumentViewer.jsx | 31 + .../components/TrainingVideoPlayer.jsx | 106 + .../components/UploadSourceSheet.jsx | 33 + .../context/TrainingContext.jsx | 227 ++ .../driver-training/data/constants.js | 146 ++ .../driver-training/data/storage.js | 106 + .../driver-training/steps/Step1Document.jsx | 104 + .../driver-training/steps/Step2Phone.jsx | 105 + .../steps/Step3Certificates.jsx | 334 +++ .../driver-training/steps/Step4Video.jsx | 60 + .../driver-training/steps/Step5Portrait.jsx | 60 + .../driver-training/steps/Step6Signature.jsx | 86 + .../driver-training/steps/Step7PickupCode.jsx | 11 + .../driver-training/steps/Step7Signature.jsx | 169 ++ .../styles/driver-training.css | 1945 +++++++++++++++++ .../driver-training/utils/formatDateTime.js | 11 + src/prototypes/xll-miniapp/index.tsx | 11 +- 31 files changed, 4677 insertions(+), 13 deletions(-) create mode 100644 src/prototypes/xll-miniapp/driver-training/DriverTrainingModule.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/components/CameraCapture.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/components/CertificateUploadCard.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/components/ConfirmDialog.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/components/DriverTrainingLayout.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/components/OCRResultForm.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/components/PhoneVerificationForm.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/components/PickupCodeCard.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/components/PrototypeDebugPanel.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/components/SignatureCanvas.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/components/StatusTag.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/components/Toast.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/components/TrainingDocumentViewer.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/components/TrainingVideoPlayer.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/components/UploadSourceSheet.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/context/TrainingContext.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/data/constants.js create mode 100644 src/prototypes/xll-miniapp/driver-training/data/storage.js create mode 100644 src/prototypes/xll-miniapp/driver-training/steps/Step1Document.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/steps/Step2Phone.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/steps/Step3Certificates.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/steps/Step4Video.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/steps/Step5Portrait.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/steps/Step6Signature.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/steps/Step7PickupCode.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/steps/Step7Signature.jsx create mode 100644 src/prototypes/xll-miniapp/driver-training/styles/driver-training.css create mode 100644 src/prototypes/xll-miniapp/driver-training/utils/formatDateTime.js diff --git a/src/prototypes/xll-miniapp/annotation-source.json b/src/prototypes/xll-miniapp/annotation-source.json index 56a4922..93c3fab 100644 --- a/src/prototypes/xll-miniapp/annotation-source.json +++ b/src/prototypes/xll-miniapp/annotation-source.json @@ -104,7 +104,7 @@ }, { "id": "hc-settle-group-status", - "index": 5.5, + "index": 6, "title": "部门结算状态标签", "pageId": "audit-return", "locator": { @@ -122,7 +122,7 @@ }, { "id": "tc-btn-approve", - "index": 5.6, + "index": 7, "title": "审批通过按钮", "pageId": "audit", "locator": { @@ -141,7 +141,7 @@ }, { "id": "page-audit-pickup", - "index": 6, + "index": 8, "title": "提车应收款审批", "pageId": "audit-pickup", "locator": { @@ -159,7 +159,7 @@ }, { "id": "page-delivery", - "index": 7, + "index": 9, "title": "交车", "pageId": "delivery", "locator": { @@ -177,7 +177,7 @@ }, { "id": "page-inspection", - "index": 8, + "index": 10, "title": "年审", "pageId": "inspection", "locator": { @@ -195,7 +195,7 @@ }, { "id": "page-vehicle", - "index": 9, + "index": 11, "title": "车辆管理", "pageId": "vehicle", "locator": { @@ -213,7 +213,7 @@ }, { "id": "page-vehicle-detail-location", - "index": 9.5, + "index": 12, "title": "车辆位置", "pageId": "vehicle", "locator": { @@ -231,7 +231,7 @@ }, { "id": "page-third-return", - "index": 10, + "index": 13, "title": "三方退车", "pageId": "third-return", "locator": { @@ -249,7 +249,7 @@ }, { "id": "page-replace", - "index": 11, + "index": 14, "title": "替换车", "pageId": "replace", "locator": { @@ -265,9 +265,27 @@ "createdAt": 1779667200000, "updatedAt": 1779667200000 }, + { + "id": "page-driver-training", + "index": 15, + "title": "司机安全培训", + "pageId": "driver-training", + "locator": { + "selectors": [ + "[data-annotation-id=\"page-driver-training\"]" + ] + }, + "aiPrompt": "说明司机扫码后的七步安全培训流程:文档阅读、手机验证、证照 OCR、培训视频、本人照片、签字确认与提车码生成。", + "annotationText": "", + "hasMarkdown": true, + "color": "#7AB929", + "images": [], + "createdAt": 1779667200000, + "updatedAt": 1779667200000 + }, { "id": "page-map", - "index": 12, + "index": 16, "title": "地图", "pageId": "map", "locator": { @@ -285,7 +303,7 @@ }, { "id": "page-mine", - "index": 13, + "index": 17, "title": "我的", "pageId": "mine", "locator": { @@ -399,6 +417,12 @@ "title": "替换车", "route": "replace" }, + { + "type": "route", + "id": "route-driver-training", + "title": "司机安全培训", + "route": "driver-training" + }, { "type": "route", "id": "route-audit-return", diff --git a/src/prototypes/xll-miniapp/directory.pages.json b/src/prototypes/xll-miniapp/directory.pages.json index 06e28d1..7631163 100644 --- a/src/prototypes/xll-miniapp/directory.pages.json +++ b/src/prototypes/xll-miniapp/directory.pages.json @@ -11,7 +11,7 @@ "folderId": "directory-modules", "folderTitle": "业务模块", "defaultExpanded": true, - "pageIds": ["audit", "inspection", "vehicle", "delivery", "third-return", "replace"] + "pageIds": ["audit", "inspection", "vehicle", "delivery", "third-return", "replace", "driver-training"] } ], "extraRoutes": [ diff --git a/src/prototypes/xll-miniapp/driver-training/DriverTrainingModule.jsx b/src/prototypes/xll-miniapp/driver-training/DriverTrainingModule.jsx new file mode 100644 index 0000000..f5ba670 --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/DriverTrainingModule.jsx @@ -0,0 +1,136 @@ +import React, { useCallback, useEffect } from 'react'; +import { TrainingProvider, useTraining } from './context/TrainingContext'; +import { TOTAL_STEPS } from './data/constants'; +import { DriverTrainingLayout } from './components/DriverTrainingLayout'; +import PrototypeDebugPanel from './components/PrototypeDebugPanel'; +import Toast, { useToast } from './components/Toast'; +import Step1Document from './steps/Step1Document'; +import Step2Phone from './steps/Step2Phone'; +import Step3Certificates from './steps/Step3Certificates'; +import Step4Video from './steps/Step4Video'; +import Step5Portrait from './steps/Step5Portrait'; +import Step6Signature from './steps/Step6Signature'; +import Step7Signature from './steps/Step7Signature'; +import Step7PickupCode from './steps/Step7PickupCode'; +import './styles/driver-training.css'; + +const STEPS = [ + Step1Document, + Step2Phone, + Step3Certificates, + Step4Video, + Step5Portrait, + Step6Signature, + Step7Signature, + Step7PickupCode, +]; + +const SIGNATURE_STEP = 7; + +function StepFooterBar({ currentStep, goToStep, children }) { + if (currentStep >= TOTAL_STEPS) return null; + + if (currentStep <= 1) { + return
{children}
; + } + + return ( +
+
+ +
{children}
+
+
+ ); +} + +function getStepDetailHint(state) { + const { currentStep, step1, step4 } = state; + if (currentStep === 1 && step1.readProgress > 0 && step1.readProgress < 100) { + return `阅读 ${step1.readProgress}%`; + } + if (currentStep === 4 && !step4.completed && step4.watchedSeconds > 0) { + return `已观看 ${step4.watchedSeconds}s`; + } + if (currentStep === SIGNATURE_STEP) { + return '请横屏签字'; + } + return ''; +} + +function DriverTrainingContent({ onRegisterBack }) { + const { state, updateState, goToStep, completeStep, stepNames, updateCertificate, submitTraining } = useTraining(); + const { message, show, clear } = useToast(); + const { currentStep, maxCompletedStep } = state; + const stepDetailHint = getStepDetailHint(state); + const isSignatureStep = currentStep === SIGNATURE_STEP; + + useEffect(() => { + if (!onRegisterBack) return undefined; + onRegisterBack(() => { + if (currentStep > 1) { + goToStep(currentStep - 1); + return true; + } + return false; + }); + return () => onRegisterBack(null); + }, [onRegisterBack, currentStep, goToStep]); + + const StepComponent = STEPS[currentStep - 1] || STEPS[0]; + const stepProps = { + state, + updateState, + completeStep, + goToStep, + showToast: show, + updateCertificate, + submitTraining, + }; + + return ( + <> + + + + ) : null + } + > + + + + + + ); +} + +export default function DriverTrainingModule({ onRegisterBack }) { + const registerBack = useCallback( + (handler) => { + if (typeof onRegisterBack === 'function') { + onRegisterBack(handler); + } + }, + [onRegisterBack], + ); + + return ( + +
+ +
+
+ ); +} diff --git a/src/prototypes/xll-miniapp/driver-training/components/CameraCapture.jsx b/src/prototypes/xll-miniapp/driver-training/components/CameraCapture.jsx new file mode 100644 index 0000000..e97415d --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/components/CameraCapture.jsx @@ -0,0 +1,169 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import ConfirmDialog from './ConfirmDialog'; + +const SAMPLE_PORTRAIT = + 'data:image/svg+xml,' + + encodeURIComponent( + ` + + + + 示例照片 + ` + ); + +export default function CameraCapture({ + photoDataUrl, + confirmed, + onCapture, + onConfirm, + onRetake, + cameraPermission, + showDevFallback, +}) { + const videoRef = useRef(null); + const streamRef = useRef(null); + const [preview, setPreview] = useState(photoDataUrl); + const [error, setError] = useState(''); + const [permissionDenied, setPermissionDenied] = useState(false); + const [showPermissionDialog, setShowPermissionDialog] = useState(false); + + const stopStream = useCallback(() => { + if (streamRef.current) { + streamRef.current.getTracks().forEach((t) => t.stop()); + streamRef.current = null; + } + }, []); + + const startCamera = useCallback(async () => { + if (cameraPermission === 'deny') { + setPermissionDenied(true); + setShowPermissionDialog(true); + return; + } + try { + const stream = await navigator.mediaDevices.getUserMedia({ + video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } }, + audio: false, + }); + streamRef.current = stream; + if (videoRef.current) { + videoRef.current.srcObject = stream; + await videoRef.current.play(); + } + setError(''); + setPermissionDenied(false); + } catch { + setPermissionDenied(true); + setShowPermissionDialog(true); + } + }, [cameraPermission]); + + useEffect(() => { + if (!preview && !confirmed) startCamera(); + return () => stopStream(); + }, [preview, confirmed, startCamera, stopStream]); + + const handleCapture = () => { + const video = videoRef.current; + if (!video) return; + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth || 640; + canvas.height = video.videoHeight || 480; + const ctx = canvas.getContext('2d'); + ctx.translate(canvas.width, 0); + ctx.scale(-1, 1); + ctx.drawImage(video, 0, 0); + const dataUrl = canvas.toDataURL('image/jpeg', 0.85); + setPreview(dataUrl); + stopStream(); + onCapture?.(dataUrl); + }; + + const handleRetake = () => { + setPreview(null); + onRetake?.(); + startCamera(); + }; + + const handleUseSample = () => { + setPreview(SAMPLE_PORTRAIT); + onCapture?.(SAMPLE_PORTRAIT); + }; + + if (confirmed && photoDataUrl) { + return ( +
+ 本人照片 +
+ + 本人照片已确认 +
+
+ ); + } + + return ( +
+

+ 请由司机本人使用前置摄像头拍摄正面照片,用于本次培训和现场交车核验。 +

+
    +
  • 请正对摄像头
  • +
  • 保持光线充足
  • +
  • 请勿佩戴口罩或遮挡面部
  • +
+ + {!preview ? ( +
+
+ ); +} diff --git a/src/prototypes/xll-miniapp/driver-training/components/CertificateUploadCard.jsx b/src/prototypes/xll-miniapp/driver-training/components/CertificateUploadCard.jsx new file mode 100644 index 0000000..0dbd5b9 --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/components/CertificateUploadCard.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { CERT_LABELS } from '../data/constants'; + +const STATUS_TEXT = { + pending: '待上传', + uploading: '上传中', + recognizing: '识别中', + confirm: '待确认', + done: '已完成', + failed: '识别失败', +}; + +export default function CertificateUploadCard({ certType, required, status, onClick, imageUrl }) { + const label = CERT_LABELS[certType]; + const isDone = status === 'done'; + const isFailed = status === 'failed'; + + return ( +
e.key === 'Enter' && onClick?.()} + > +
+ {label} + {required ? ( + 必填 + ) : ( + 选填 + )} +
+
{STATUS_TEXT[status] || '待上传'}
+ {imageUrl && ( +
+ {label} +
+ )} +
+ ); +} diff --git a/src/prototypes/xll-miniapp/driver-training/components/ConfirmDialog.jsx b/src/prototypes/xll-miniapp/driver-training/components/ConfirmDialog.jsx new file mode 100644 index 0000000..d31df4d --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/components/ConfirmDialog.jsx @@ -0,0 +1,33 @@ +import React from 'react'; + +export default function ConfirmDialog({ + visible, + title, + content, + note, + summary, + onCancel, + onConfirm, + cancelText = '取消', + confirmText = '确认', +}) { + if (!visible) return null; + return ( +
+
e.stopPropagation()}> +

{title}

+ {summary ?
{summary}
: null} + {note ?

{note}

: null} + {content ?

{content}

: null} +
+ + +
+
+
+ ); +} diff --git a/src/prototypes/xll-miniapp/driver-training/components/DriverTrainingLayout.jsx b/src/prototypes/xll-miniapp/driver-training/components/DriverTrainingLayout.jsx new file mode 100644 index 0000000..14fad1c --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/components/DriverTrainingLayout.jsx @@ -0,0 +1,116 @@ +import React from 'react'; + +const TRAINING_PHASES = [ + { id: 'prep', label: '身份与须知', steps: [1, 2] }, + { id: 'verify', label: '资质与培训', steps: [3, 4, 5] }, + { id: 'confirm', label: '签署与提车', steps: [6, 7, 8] }, +]; + +function getPhaseStatus(phase, currentStep) { + const maxStep = Math.max(...phase.steps); + const minStep = Math.min(...phase.steps); + if (currentStep > maxStep) return 'done'; + if (currentStep >= minStep && currentStep <= maxStep) return 'active'; + return 'pending'; +} + +function TrainingPhaseBar({ currentStep }) { + return ( +
+ {TRAINING_PHASES.map((phase, index) => { + const status = getPhaseStatus(phase, currentStep); + return ( + + {index > 0 && ( +
+ )} +
+ + {status === 'done' ? '✓' : index + 1} + + {phase.label} +
+ + ); + })} +
+ ); +} + +export function TrainingStepHeader({ + currentStep, + totalSteps = 8, + stepNames, + stepDetailHint = '', +}) { + const isComplete = currentStep >= totalSteps; + const progressPercent = (currentStep / totalSteps) * 100; + + return ( +
+
+ {isComplete ? '培训已完成' : '培训进度'} + + 步骤 {currentStep}/{totalSteps} + +
+ + + + + ); +} + +export function DriverTrainingLayout({ + currentStep, + totalSteps = 8, + stepNames, + stepDetailHint, + children, + footer, + embedded = false, + immersive = false, +}) { + return ( +
+
+ {!embedded && ( +
+
+

司机安全培训

+
+
+ )} +
+ {!immersive ? ( + + ) : null} + {children} +
+ {footer &&
{footer}
} +
+
+ ); +} diff --git a/src/prototypes/xll-miniapp/driver-training/components/OCRResultForm.jsx b/src/prototypes/xll-miniapp/driver-training/components/OCRResultForm.jsx new file mode 100644 index 0000000..f0e880a --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/components/OCRResultForm.jsx @@ -0,0 +1,87 @@ +import React from 'react'; +import { VEHICLE_CLASS_OPTIONS } from '../data/constants'; + +const FIELD_CONFIG = { + idFront: [ + { key: 'name', label: '姓名', required: true }, + { key: 'idNumber', label: '身份证号', required: true }, + ], + idBack: [{ key: 'validPeriod', label: '有效期限', required: true }], + licenseFront: [ + { key: 'name', label: '姓名', required: true }, + { key: 'licenseNumber', label: '证号', required: true }, + { key: 'vehicleClass', label: '准驾车型', required: true, inputType: 'select', options: VEHICLE_CLASS_OPTIONS }, + { key: 'validPeriod', label: '有效期限', required: true }, + ], + qualification: [ + { key: 'name', label: '姓名', required: true }, + { key: 'gender', label: '性别', required: false }, + { key: 'birthDate', label: '出生日期', required: false }, + { key: 'nationality', label: '国籍', required: false }, + { key: 'address', label: '住址', required: false }, + { key: 'certNumber', label: '证号', required: true }, + { key: 'vehicleClass', label: '准驾车型', required: true }, + { key: 'category', label: '从业资格类别', required: true }, + { key: 'initialIssueDate', label: '初次领证日期', required: true }, + { key: 'validFrom', label: '有效起始日期', required: true }, + { key: 'validPeriod', label: '有效期限', required: true }, + ], +}; + +export const CERT_FIELD_LABELS = Object.fromEntries( + Object.values(FIELD_CONFIG) + .flat() + .map((f) => [f.key, f.label]), +); + +export function getCertFieldLabel(key) { + return CERT_FIELD_LABELS[key] || key; +} + +export default function OCRResultForm({ certType, data, onChange, errors = {} }) { + const fields = FIELD_CONFIG[certType]; + if (!fields) return null; + + return ( +
+ {fields.map((f) => ( +
+ + {f.inputType === 'select' ? ( + + ) : ( + onChange(f.key, e.target.value)} + /> + )} + {errors[f.key] &&
{errors[f.key]}
} +
+ ))} +
+ ); +} + +export function ManualCertificateForm({ certType, data, onChange, errors = {} }) { + return ( +
+
请根据证件原件准确填写,相关信息将用于本次交车核验。
+ +
+ ); +} diff --git a/src/prototypes/xll-miniapp/driver-training/components/PhoneVerificationForm.jsx b/src/prototypes/xll-miniapp/driver-training/components/PhoneVerificationForm.jsx new file mode 100644 index 0000000..005da1f --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/components/PhoneVerificationForm.jsx @@ -0,0 +1,137 @@ +import React, { useEffect, useState } from 'react'; +import { DEMO_PHONE, MOCK_SMS_CODE } from '../data/constants'; + +const PHONE_REG = /^1[3-9]\d{9}$/; + +export default function PhoneVerificationForm({ + value, + onChange, + onVerified, + showActions = true, +}) { + const phone = value.phone || DEMO_PHONE; + const code = value.code || MOCK_SMS_CODE; + const [phoneError, setPhoneError] = useState(''); + const [codeError, setCodeError] = useState(''); + const [countdown, setCountdown] = useState(0); + const [sending, setSending] = useState(false); + const [success, setSuccess] = useState(false); + + useEffect(() => { + if (countdown <= 0) return undefined; + const t = setTimeout(() => setCountdown((c) => c - 1), 1000); + return () => clearTimeout(t); + }, [countdown]); + + const patch = (data) => onChange?.({ phone, code, ...value, ...data }); + + const handleSendCode = () => { + if (!PHONE_REG.test(phone)) { + setPhoneError('请输入正确的11位手机号'); + return; + } + setPhoneError(''); + setSending(true); + setTimeout(() => { + setSending(false); + setCountdown(60); + }, 500); + }; + + const handleVerify = () => { + if (!PHONE_REG.test(phone)) { + setPhoneError('请输入正确的11位手机号'); + return; + } + setPhoneError(''); + if (code !== MOCK_SMS_CODE) { + setCodeError('验证码错误,请重新输入。'); + return; + } + setCodeError(''); + setSuccess(true); + onChange?.({ + phone, + code, + verified: true, + verifiedAt: new Date().toISOString(), + }); + setTimeout(() => onVerified?.(), 800); + }; + + if (success) { + return ( +
+ + 手机号验证成功 +
+ ); + } + + return ( +
+
+ + { + patch({ phone: e.target.value.replace(/\D/g, '') }); + setPhoneError(''); + }} + /> + {phoneError &&
{phoneError}
} +
+
+ +
+ { + patch({ code: e.target.value.replace(/\D/g, '') }); + setCodeError(''); + }} + /> + +
+ {codeError &&
{codeError}
} +
+

+ 手机号用于绑定本次培训记录和接收培训状态通知。 +
+ 演示环境已预填手机号与验证码({MOCK_SMS_CODE}),可直接验证继续。 +

+ {showActions && ( + + )} +
+ ); +} + +export function verifyPhoneStep(step2) { + const phone = step2.phone || DEMO_PHONE; + const code = step2.code || MOCK_SMS_CODE; + if (!PHONE_REG.test(phone)) { + return { ok: false, phoneError: '请输入正确的11位手机号' }; + } + if (code !== MOCK_SMS_CODE) { + return { ok: false, codeError: '验证码错误,请重新输入。' }; + } + return { ok: true, phone, code }; +} diff --git a/src/prototypes/xll-miniapp/driver-training/components/PickupCodeCard.jsx b/src/prototypes/xll-miniapp/driver-training/components/PickupCodeCard.jsx new file mode 100644 index 0000000..91defb6 --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/components/PickupCodeCard.jsx @@ -0,0 +1,89 @@ +import React, { useEffect, useRef } from 'react'; + +import StatusTag from './StatusTag'; + +import { PICKUP_STATUS } from '../data/constants'; + +function drawQRCode(canvas, text) { + const ctx = canvas.getContext('2d'); + const size = 160; + canvas.width = size; + canvas.height = size; + ctx.fillStyle = '#fff'; + ctx.fillRect(0, 0, size, size); + + const hash = text.split('').reduce((a, c) => a + c.charCodeAt(0), 0); + const cell = 8; + const cells = size / cell; + + for (let y = 0; y < cells; y++) { + for (let x = 0; x < cells; x++) { + const inCorner = + (x < 9 && y < 9) || (x > cells - 10 && y < 9) || (x < 9 && y > cells - 10); + const val = (hash * (x + 1) * (y + 1) + x * y) % 7; + if (inCorner) { + if ( + (x === 1 || x === 7 || x === cells - 8 || x === cells - 2) && + (y === 1 || y === 7) + ) { + ctx.fillStyle = '#1D2129'; + ctx.fillRect(x * cell, y * cell, cell, cell); + } else if (x >= 2 && x <= 6 && y >= 2 && y <= 6) { + ctx.fillStyle = '#1D2129'; + ctx.fillRect(x * cell, y * cell, cell, cell); + } + } else if (val < 3) { + ctx.fillStyle = '#1D2129'; + ctx.fillRect(x * cell, y * cell, cell, cell); + } + } + } +} + +const STATUS_HINTS = { + [PICKUP_STATUS.PENDING]: '请向运维人员出示二维码', + [PICKUP_STATUS.LOCKED]: '运维已核验,正在办理交车', + [PICKUP_STATUS.PROCESSING]: '等待双方完成电子签署', + [PICKUP_STATUS.COMPLETED]: '交车已完成,提车码已失效', + [PICKUP_STATUS.REVOKED]: '交车任务已取消,提车码不可使用', + [PICKUP_STATUS.EXPIRED]: '提车码已失效', +}; + +export default function PickupCodeCard({ step7 }) { + const canvasRef = useRef(null); + const isDisabled = + step7.status === PICKUP_STATUS.COMPLETED || + step7.status === PICKUP_STATUS.REVOKED || + step7.status === PICKUP_STATUS.EXPIRED; + + useEffect(() => { + if (canvasRef.current && step7.pickupCode) { + drawQRCode(canvasRef.current, step7.pickupCode); + } + }, [step7.pickupCode]); + + const hint = STATUS_HINTS[step7.status]; + + return ( +
+
+
+

安全培训已完成

+

请向运维人员出示下方提车码

+
+ +
+
+ + {hint ?

{hint}

: null} +
+
+ + {isDisabled &&
已失效
} +
+ +
提车码仅限本次交车使用,请勿转发给他人。
+
+
+ ); +} diff --git a/src/prototypes/xll-miniapp/driver-training/components/PrototypeDebugPanel.jsx b/src/prototypes/xll-miniapp/driver-training/components/PrototypeDebugPanel.jsx new file mode 100644 index 0000000..3b1021a --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/components/PrototypeDebugPanel.jsx @@ -0,0 +1,78 @@ +import React, { useState } from 'react'; +import { useTraining } from '../context/TrainingContext'; + +export default function PrototypeDebugPanel() { + const [open, setOpen] = useState(false); + const { state, applyDebugSettings, resetAll } = useTraining(); + const { debug } = state; + + return ( +
+ {open && ( +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ )} + +
+ ); +} diff --git a/src/prototypes/xll-miniapp/driver-training/components/SignatureCanvas.jsx b/src/prototypes/xll-miniapp/driver-training/components/SignatureCanvas.jsx new file mode 100644 index 0000000..a6f1ee6 --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/components/SignatureCanvas.jsx @@ -0,0 +1,165 @@ +import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'; + +const SignatureCanvas = forwardRef(function SignatureCanvas( + { value, onChange, onClear, variant = 'default', hideActions = false, watermark = '' }, + ref, +) { + const canvasRef = useRef(null); + const wrapRef = useRef(null); + const drawing = useRef(false); + const [hasStroke, setHasStroke] = useState(!!value); + const isEsign = variant === 'esign'; + const isFullscreen = variant === 'fullscreen' || isEsign; + + const valueRef = useRef(value); + valueRef.current = value; + + const setupCanvas = useCallback(() => { + const canvas = canvasRef.current; + const wrap = wrapRef.current; + if (!canvas || !wrap) return; + + const rect = wrap.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + + const savedValue = valueRef.current; + canvas.width = rect.width * 2; + canvas.height = rect.height * 2; + const ctx = canvas.getContext('2d'); + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.scale(2, 2); + ctx.strokeStyle = '#1D2129'; + ctx.lineWidth = isEsign ? 3 : isFullscreen ? 2.5 : 2; + ctx.lineCap = 'round'; + ctx.lineJoin = 'round'; + ctx.clearRect(0, 0, rect.width, rect.height); + + if (savedValue) { + const img = new Image(); + img.onload = () => ctx.drawImage(img, 0, 0, rect.width, rect.height); + img.src = savedValue; + } + }, [isEsign, isFullscreen]); + + const handleClear = useCallback(() => { + const canvas = canvasRef.current; + const wrap = wrapRef.current; + if (!canvas || !wrap) return; + const ctx = canvas.getContext('2d'); + const rect = wrap.getBoundingClientRect(); + ctx.clearRect(0, 0, rect.width, rect.height); + setHasStroke(false); + onChange?.(null); + onClear?.(); + }, [onChange, onClear]); + + useImperativeHandle(ref, () => ({ + clear: handleClear, + hasStroke: () => hasStroke, + })); + + useEffect(() => { + setupCanvas(); + const wrap = wrapRef.current; + if (!wrap || typeof ResizeObserver === 'undefined') return undefined; + + const observer = new ResizeObserver(() => setupCanvas()); + observer.observe(wrap); + return () => observer.disconnect(); + }, [setupCanvas]); + + useEffect(() => { + if (!drawing.current) { + setupCanvas(); + } + }, [value, setupCanvas]); + + useEffect(() => { + setHasStroke(!!value); + }, [value]); + + const getPos = (e, canvas) => { + const rect = canvas.getBoundingClientRect(); + const clientX = e.touches ? e.touches[0].clientX : e.clientX; + const clientY = e.touches ? e.touches[0].clientY : e.clientY; + return { x: clientX - rect.left, y: clientY - rect.top }; + }; + + const startDraw = (e) => { + e.preventDefault(); + drawing.current = true; + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d'); + const { x, y } = getPos(e, canvas); + ctx.beginPath(); + ctx.moveTo(x, y); + }; + + const draw = (e) => { + if (!drawing.current) return; + e.preventDefault(); + const canvas = canvasRef.current; + const ctx = canvas.getContext('2d'); + const { x, y } = getPos(e, canvas); + ctx.lineTo(x, y); + ctx.stroke(); + setHasStroke(true); + }; + + const endDraw = () => { + if (!drawing.current) return; + drawing.current = false; + const canvas = canvasRef.current; + onChange?.(canvas.toDataURL('image/png')); + }; + + const placeholderText = isEsign ? watermark || '请在此处签名' : isFullscreen ? '请横屏在此处签名' : '请在此处签名'; + + return ( +
+
+ {!hasStroke && !isEsign && ( + + )} + {isEsign && !hasStroke && ( + + )} + + {!hideActions && ( +
+ + {hasStroke && ( + + 已签名 + + )} +
+ )} +
+ ); +}); + +export default SignatureCanvas; diff --git a/src/prototypes/xll-miniapp/driver-training/components/StatusTag.jsx b/src/prototypes/xll-miniapp/driver-training/components/StatusTag.jsx new file mode 100644 index 0000000..cbfd23d --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/components/StatusTag.jsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { PICKUP_STATUS, PICKUP_STATUS_LABELS } from '../data/constants'; + +const STATUS_CLASS = { + [PICKUP_STATUS.PENDING]: 'pending', + [PICKUP_STATUS.LOCKED]: 'locked', + [PICKUP_STATUS.PROCESSING]: 'processing', + [PICKUP_STATUS.COMPLETED]: 'completed', + [PICKUP_STATUS.EXPIRED]: 'expired', + [PICKUP_STATUS.REVOKED]: 'revoked', +}; + +export default function StatusTag({ status }) { + const cls = STATUS_CLASS[status] || 'pending'; + const label = PICKUP_STATUS_LABELS[status] || status; + return {label}; +} diff --git a/src/prototypes/xll-miniapp/driver-training/components/Toast.jsx b/src/prototypes/xll-miniapp/driver-training/components/Toast.jsx new file mode 100644 index 0000000..6baae37 --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/components/Toast.jsx @@ -0,0 +1,28 @@ +import React, { useEffect, useState } from 'react'; + +let toastTimer = null; + +export default function Toast({ message, onClose }) { + const [visible, setVisible] = useState(!!message); + + useEffect(() => { + if (!message) return undefined; + setVisible(true); + if (toastTimer) clearTimeout(toastTimer); + toastTimer = setTimeout(() => { + setVisible(false); + onClose?.(); + }, 2500); + return () => clearTimeout(toastTimer); + }, [message, onClose]); + + if (!visible || !message) return null; + return
{message}
; +} + +export function useToast() { + const [message, setMessage] = useState(''); + const show = (msg) => setMessage(msg); + const clear = () => setMessage(''); + return { message, show, clear }; +} diff --git a/src/prototypes/xll-miniapp/driver-training/components/TrainingDocumentViewer.jsx b/src/prototypes/xll-miniapp/driver-training/components/TrainingDocumentViewer.jsx new file mode 100644 index 0000000..62d18f9 --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/components/TrainingDocumentViewer.jsx @@ -0,0 +1,31 @@ +import React, { useCallback, useEffect, useRef } from 'react'; + +export default function TrainingDocumentViewer({ content, onProgress, onReachBottom, startTime, onStart }) { + const ref = useRef(null); + const started = useRef(false); + + const handleScroll = useCallback(() => { + const el = ref.current; + if (!el) return; + if (!started.current) { + started.current = true; + onStart?.(); + } + const { scrollTop, scrollHeight, clientHeight } = el; + const progress = Math.min(100, Math.round((scrollTop / (scrollHeight - clientHeight || 1)) * 100)); + onProgress?.(progress); + if (scrollTop + clientHeight >= scrollHeight - 8) { + onReachBottom?.(); + } + }, [onProgress, onReachBottom, onStart]); + + useEffect(() => { + handleScroll(); + }, [handleScroll, content]); + + return ( +
+ {content} +
+ ); +} diff --git a/src/prototypes/xll-miniapp/driver-training/components/TrainingVideoPlayer.jsx b/src/prototypes/xll-miniapp/driver-training/components/TrainingVideoPlayer.jsx new file mode 100644 index 0000000..88ecfbd --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/components/TrainingVideoPlayer.jsx @@ -0,0 +1,106 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +const DURATION = 30; + +function IconPlay() { + return ( + + + + ); +} + +function IconPause() { + return ( + + + + ); +} + +export default function TrainingVideoPlayer({ completed, watchedSeconds, onProgress, onComplete, debugComplete }) { + const [playing, setPlaying] = useState(false); + const [current, setCurrent] = useState(watchedSeconds || 0); + const timerRef = useRef(null); + + const stopTimer = useCallback(() => { + if (timerRef.current) { + clearInterval(timerRef.current); + timerRef.current = null; + } + }, []); + + useEffect(() => { + const handleVisibility = () => { + if (document.hidden) setPlaying(false); + }; + document.addEventListener('visibilitychange', handleVisibility); + return () => document.removeEventListener('visibilitychange', handleVisibility); + }, []); + + useEffect(() => { + if (!playing || completed) { + stopTimer(); + return undefined; + } + timerRef.current = setInterval(() => { + setCurrent((prev) => { + const next = Math.min(prev + 1, DURATION); + onProgress?.(next); + if (next >= DURATION) { + setPlaying(false); + onComplete?.(); + } + return next; + }); + }, 1000); + return () => stopTimer(); + }, [playing, completed, onProgress, onComplete, stopTimer]); + + useEffect(() => { + if (debugComplete && !completed) { + setCurrent(DURATION); + onProgress?.(DURATION); + onComplete?.(); + } + }, [debugComplete, completed, onProgress, onComplete]); + + useEffect(() => { + if (completed) setCurrent(DURATION); + }, [completed]); + + const togglePlay = () => { + if (completed) return; + setPlaying((p) => !p); + }; + + const progress = (current / DURATION) * 100; + const formatTime = (s) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`; + + return ( +
+
+
+
氢能重卡安全驾驶培训
+
预计时长 30 秒
+ {!playing && !completed && ( + + )} +
+
+
+ +
+
+
+ + {formatTime(current)}/{formatTime(DURATION)} + +
+
+ ); +} diff --git a/src/prototypes/xll-miniapp/driver-training/components/UploadSourceSheet.jsx b/src/prototypes/xll-miniapp/driver-training/components/UploadSourceSheet.jsx new file mode 100644 index 0000000..160ccbb --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/components/UploadSourceSheet.jsx @@ -0,0 +1,33 @@ +import React from 'react'; +import { CERT_LABELS } from '../data/constants'; + +export default function UploadSourceSheet({ visible, certType, onCamera, onAlbum, onCancel }) { + if (!visible || !certType) return null; + const label = CERT_LABELS[certType] || '证件'; + + return ( +
+
e.stopPropagation()}> +
上传{label}
+

请选择获取证件照片的方式

+ + + +
+
+ ); +} diff --git a/src/prototypes/xll-miniapp/driver-training/context/TrainingContext.jsx b/src/prototypes/xll-miniapp/driver-training/context/TrainingContext.jsx new file mode 100644 index 0000000..20f6642 --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/context/TrainingContext.jsx @@ -0,0 +1,227 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'; +import { + DOCUMENT_VERSION, + PICKUP_STATUS, + STEP_NAMES, + TOTAL_STEPS, +} from '../data/constants'; +import { + createInitialState, + generatePickupCode, + loadTrainingState, + resetTrainingState, + saveTrainingState, +} from '../data/storage'; + +const TrainingContext = createContext(null); + +function isQualificationRequired(tonnage) { + return tonnage > 18; +} + +function getRequiredCertTypes(tonnage) { + const base = ['idFront', 'idBack', 'licenseFront', 'licenseBack']; + if (isQualificationRequired(tonnage)) base.push('qualification'); + return base; +} + +export { isQualificationRequired, getRequiredCertTypes }; + +export function TrainingProvider({ children }) { + const [state, setState] = useState(() => loadTrainingState()); + + useEffect(() => { + saveTrainingState(state); + }, [state]); + + const updateState = useCallback((updater) => { + setState((prev) => { + const next = typeof updater === 'function' ? updater(prev) : { ...prev, ...updater }; + return next; + }); + }, []); + + const invalidateAfterStep3 = useCallback((prev) => ({ + ...prev, + step6: { + signatureDataUrl: null, + checked: false, + submitted: false, + submittedAt: null, + }, + step7: { + pickupCode: null, + numericCode: null, + status: PICKUP_STATUS.PENDING, + generatedAt: null, + completedAt: null, + lockedAt: null, + }, + maxCompletedStep: Math.min(prev.maxCompletedStep, 5), + currentStep: Math.min(prev.currentStep, 6), + }), []); + + const goToStep = useCallback((step) => { + setState((prev) => { + if (step > prev.maxCompletedStep + 1) return prev; + return { ...prev, currentStep: step }; + }); + }, []); + + const completeStep = useCallback((step) => { + setState((prev) => ({ + ...prev, + maxCompletedStep: Math.max(prev.maxCompletedStep, step), + currentStep: Math.min(step + 1, TOTAL_STEPS), + })); + }, []); + + const resetAll = useCallback(() => { + const fresh = resetTrainingState(); + setState(fresh); + return fresh; + }, []); + + const applyDebugSettings = useCallback((debugPatch) => { + setState((prev) => { + let next = { + ...prev, + debug: { ...prev.debug, ...debugPatch }, + }; + + if (debugPatch.vehicleTonnage !== undefined) { + next.trainingTask = { + ...next.trainingTask, + vehicleTonnage: debugPatch.vehicleTonnage, + vehicleType: + debugPatch.vehicleTonnage >= 49 + ? '49吨氢能重卡' + : debugPatch.vehicleTonnage >= 18 + ? '18吨氢能轻卡' + : '4.5吨氢能物流车', + }; + if (prev.step7.pickupCode) { + next = invalidateAfterStep3(next); + next.step7 = { ...next.step7, status: PICKUP_STATUS.REVOKED }; + } + } + + if (debugPatch.pickupStatus !== undefined) { + const statusMap = { + pending: PICKUP_STATUS.PENDING, + locked: PICKUP_STATUS.LOCKED, + single_sign: PICKUP_STATUS.PROCESSING, + both_signed: PICKUP_STATUS.COMPLETED, + revoked: PICKUP_STATUS.REVOKED, + }; + const status = statusMap[debugPatch.pickupStatus] || PICKUP_STATUS.PENDING; + next.step7 = { + ...next.step7, + status, + pickupCode: next.step7.pickupCode || `PK-${next.trainingTask.taskId}`, + numericCode: next.step7.numericCode || generatePickupCode(), + generatedAt: next.step7.generatedAt || new Date().toISOString(), + lockedAt: status !== PICKUP_STATUS.PENDING ? new Date().toISOString() : null, + completedAt: status === PICKUP_STATUS.COMPLETED ? new Date().toISOString() : null, + }; + if (status === PICKUP_STATUS.COMPLETED || status === PICKUP_STATUS.REVOKED) { + next.step7.status = status; + } + } + + if (debugPatch.videoStatus === 'completed') { + next.step4 = { + completed: true, + watchedSeconds: 30, + completedAt: new Date().toISOString(), + }; + } else if (debugPatch.videoStatus === 'incomplete') { + next.step4 = { completed: false, watchedSeconds: 0, completedAt: null }; + next.maxCompletedStep = Math.min(next.maxCompletedStep, 3); + } + + return next; + }); + }, [invalidateAfterStep3]); + + const updateCertificate = useCallback((certType, data) => { + setState((prev) => { + let next = { + ...prev, + step3: { + ...prev.step3, + certificates: { + ...prev.step3.certificates, + [certType]: { ...prev.step3.certificates[certType], ...data }, + }, + }, + }; + if (prev.step6.submitted || prev.step7.pickupCode) { + next = invalidateAfterStep3(next); + } + return next; + }); + }, [invalidateAfterStep3]); + + const submitTraining = useCallback(() => { + setState((prev) => { + const numericCode = generatePickupCode(); + const now = new Date().toISOString(); + return { + ...prev, + step6: { + ...prev.step6, + submitted: true, + submittedAt: now, + }, + step7: { + pickupCode: `PK-${prev.trainingTask.taskId}-${numericCode}`, + numericCode, + status: PICKUP_STATUS.PENDING, + generatedAt: now, + completedAt: null, + lockedAt: null, + }, + maxCompletedStep: TOTAL_STEPS, + currentStep: TOTAL_STEPS, + }; + }); + }, []); + + const value = useMemo( + () => ({ + state, + updateState, + goToStep, + completeStep, + resetAll, + applyDebugSettings, + updateCertificate, + submitTraining, + isQualificationRequired, + getRequiredCertTypes, + stepNames: STEP_NAMES, + documentVersion: DOCUMENT_VERSION, + }), + [ + state, + updateState, + goToStep, + completeStep, + resetAll, + applyDebugSettings, + updateCertificate, + submitTraining, + ] + ); + + return {children}; +} + +export function useTraining() { + const ctx = useContext(TrainingContext); + if (!ctx) throw new Error('useTraining must be used within TrainingProvider'); + return ctx; +} + +export { createInitialState }; diff --git a/src/prototypes/xll-miniapp/driver-training/data/constants.js b/src/prototypes/xll-miniapp/driver-training/data/constants.js new file mode 100644 index 0000000..fdb2496 --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/data/constants.js @@ -0,0 +1,146 @@ +export const THEME = { + primary: '#7AB929', + primaryLight: 'rgba(122, 185, 41, 0.14)', + bg: '#F7F8FA', + card: '#FFFFFF', + title: '#1D2129', + text: '#4E5969', + textSecondary: '#86909C', + border: '#E5E6EB', + danger: '#F53F3F', + warning: '#FF7D00', +}; + +export const STORAGE_KEY = 'xll_driver_training_state_v1'; + +export const DOCUMENT_VERSION = 'V2.1.0-20260701'; + +export const MOCK_SMS_CODE = '123456'; + +/** 原型演示用手机号(虚构脱敏) */ +export const DEMO_PHONE = '13812345678'; + +export const STEP_NAMES = [ + '阅读培训文档', + '手机号验证', + '司机证照', + '安全培训视频', + '本人照片', + '信息确认', + '手写签名', + '生成提车码', +]; + +export const TOTAL_STEPS = STEP_NAMES.length; + +export const CERT_TYPES = { + ID_FRONT: 'idFront', + ID_BACK: 'idBack', + LICENSE_FRONT: 'licenseFront', + LICENSE_BACK: 'licenseBack', + QUALIFICATION: 'qualification', +}; + +export const CERT_LABELS = { + [CERT_TYPES.ID_FRONT]: '身份证正面', + [CERT_TYPES.ID_BACK]: '身份证反面', + [CERT_TYPES.LICENSE_FRONT]: '驾驶证正页', + [CERT_TYPES.LICENSE_BACK]: '驾驶证副页', + [CERT_TYPES.QUALIFICATION]: '从业资格证', +}; + +export const PICKUP_STATUS = { + PENDING: 'pending', + LOCKED: 'locked', + PROCESSING: 'processing', + COMPLETED: 'completed', + EXPIRED: 'expired', + REVOKED: 'revoked', +}; + +export const PICKUP_STATUS_LABELS = { + [PICKUP_STATUS.PENDING]: '待核验', + [PICKUP_STATUS.LOCKED]: '已锁定', + [PICKUP_STATUS.PROCESSING]: '交车处理中', + [PICKUP_STATUS.COMPLETED]: '已完成', + [PICKUP_STATUS.EXPIRED]: '已失效', + [PICKUP_STATUS.REVOKED]: '已撤销', +}; + +export const OCR_MOCK = { + idFront: { + name: '张建国', + idNumber: '3304**********1234', + }, + idBack: { + validPeriod: '2024.01.01—2044.01.01', + }, + licenseFront: { + name: '张建国', + licenseNumber: '3304**********1234', + vehicleClass: 'A2', + validPeriod: '2024.01.01—2034.01.01', + }, + licenseBack: {}, + qualification: { + name: '张建国', + gender: '男', + birthDate: '1985-03-27', + nationality: '中国', + address: '浙江省嘉兴市南湖区', + certNumber: '3304********5678', + vehicleClass: 'A1A2', + category: '经营性道路货物运输驾驶员', + initialIssueDate: '2021年04月22日', + validFrom: '2021年04月22日', + validPeriod: '2027年04月21日止', + }, +}; + +export const TRAINING_DOCUMENT = `一、驾驶员安全责任 + +1. 驾驶员须严格遵守国家道路交通安全法律法规及公司安全管理制度。 +2. 出车前须确认本人身体状况良好,严禁疲劳驾驶、酒后驾驶或服用影响驾驶安全的药物后驾车。 +3. 驾驶员对车辆运行安全、货物安全及氢能系统安全负有直接责任。 + +二、出车前检查要求 + +1. 检查轮胎气压、制动系统、转向系统、灯光信号是否正常。 +2. 检查氢气储存系统、管路接头、阀门及泄漏报警装置。 +3. 检查仪表盘各项指示,确认无故障报警后方可出车。 +4. 检查随车安全设施:灭火器、三角警示牌、反光背心等。 + +三、氢能车辆使用注意事项 + +1. 加氢须在指定加氢站进行,严禁私自改装氢气系统。 +2. 发现氢气泄漏气味或报警时,立即停车熄火,撤离至安全区域并上报。 +3. 禁止在加氢站、隧道、密闭空间等危险区域吸烟或使用明火。 +4. 氢气瓶区域严禁敲击、碰撞,避免高温环境长时间停放。 +5. 车辆停放时应选择通风良好区域,远离火源和人员密集场所。 + +四、突发事件处理要求 + +1. 发生交通事故时,立即报警并保护现场,按规定上报公司安全部门。 +2. 发生氢气泄漏时,切断电源,疏散人员,设置警戒区,等待专业人员处置。 +3. 发生火情时,使用车载灭火器进行初期扑救,无法控制时立即撤离并报警。 +4. 所有突发事件须在30分钟内完成首次上报。 + +五、禁止事项 + +1. 严禁无证驾驶、准驾车型不符驾驶。 +2. 严禁超速、超载、超员行驶。 +3. 严禁行车中使用手机或其他分散注意力的行为。 +4. 严禁擅自拆卸、改装车辆安全及氢能系统。 +5. 严禁将车辆交予未经培训和授权的人员驾驶。 + +六、个人信息和证件使用说明 + +1. 本次培训采集的身份证、驾驶证、从业资格证及本人照片仅用于本次交车核验和安全培训存档。 +2. 证件信息须真实有效,如有虚假将承担相应法律责任。 +3. 培训记录与交车任务绑定,提车码仅限本人本次交车使用。 +4. 如对证件采集有异议,请联系现场运维人员协助处理。`; + +export const REQUIRED_VEHICLE_CLASS = 'A2'; + +/** 现行机动车驾驶证准驾车型代号(本流程可选范围) */ +export const VEHICLE_CLASS_OPTIONS = ['A1', 'A2', 'A3', 'B1', 'B2', 'C1', 'C2']; diff --git a/src/prototypes/xll-miniapp/driver-training/data/storage.js b/src/prototypes/xll-miniapp/driver-training/data/storage.js new file mode 100644 index 0000000..8b94379 --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/data/storage.js @@ -0,0 +1,106 @@ +import { STORAGE_KEY, DEMO_PHONE, MOCK_SMS_CODE } from './constants'; + +export function createInitialState(overrides = {}) { + const now = new Date().toISOString(); + return { + trainingTask: { + taskId: 'JC202607170023', + projectName: '上海示范运营项目', + vehiclePlate: '浙F·****8', + vehicleType: '49吨氢能重卡', + vehicleTonnage: 49, + plannedDate: '2026-07-20', + requiredVehicleClass: 'A2', + ...overrides.trainingTask, + }, + currentStep: 1, + maxCompletedStep: 0, + step1: { + readProgress: 0, + scrolledToBottom: false, + confirmed: false, + checked: false, + startTime: null, + endTime: null, + confirmTime: null, + documentVersion: null, + }, + step2: { + phone: DEMO_PHONE, + code: MOCK_SMS_CODE, + verified: false, + verifiedAt: null, + }, + step3: { + certificates: {}, + }, + step4: { + completed: false, + watchedSeconds: 0, + completedAt: null, + }, + step5: { + photoDataUrl: null, + confirmed: false, + capturedAt: null, + }, + step6: { + signatureDataUrl: null, + checked: false, + submitted: false, + submittedAt: null, + }, + step7: { + pickupCode: null, + numericCode: null, + status: 'pending', + generatedAt: null, + completedAt: null, + lockedAt: null, + }, + debug: { + vehicleTonnage: 49, + ocrMode: 'success', + cameraPermission: 'allow', + videoStatus: 'incomplete', + pickupStatus: 'pending', + }, + createdAt: now, + updatedAt: now, + }; +} + +export function loadTrainingState() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return createInitialState(); + const parsed = JSON.parse(raw); + const merged = { ...createInitialState(), ...parsed }; + merged.trainingTask.vehicleTonnage = merged.debug?.vehicleTonnage ?? merged.trainingTask.vehicleTonnage; + if (merged.step6?.submitted && merged.step7?.pickupCode && merged.currentStep < 8) { + merged.currentStep = 8; + merged.maxCompletedStep = Math.max(merged.maxCompletedStep || 0, 8); + } + if (merged.currentStep === 7 && merged.step7?.pickupCode && !merged.step6?.signatureDataUrl) { + merged.currentStep = 8; + } + return merged; + } catch { + return createInitialState(); + } +} + +export function saveTrainingState(state) { + const toSave = { ...state, updatedAt: new Date().toISOString() }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave)); + return toSave; +} + +export function resetTrainingState() { + localStorage.removeItem(STORAGE_KEY); + return createInitialState(); +} + +export function generatePickupCode() { + return String(Math.floor(100000 + Math.random() * 900000)); +} diff --git a/src/prototypes/xll-miniapp/driver-training/steps/Step1Document.jsx b/src/prototypes/xll-miniapp/driver-training/steps/Step1Document.jsx new file mode 100644 index 0000000..7f5017f --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/steps/Step1Document.jsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { DOCUMENT_VERSION, TRAINING_DOCUMENT } from '../data/constants'; +import TrainingDocumentViewer from '../components/TrainingDocumentViewer'; + +export default function Step1Document({ state, updateState, completeStep, renderPart = 'all' }) { + const { step1 } = state; + const readProgress = step1.readProgress || 0; + const scrolledToBottom = step1.scrolledToBottom; + const checked = step1.checked; + + const handleStart = () => { + if (!step1.startTime) { + updateState((prev) => ({ + ...prev, + step1: { ...prev.step1, startTime: new Date().toISOString() }, + })); + } + }; + + const handleReachBottom = () => { + updateState((prev) => ({ + ...prev, + step1: { + ...prev.step1, + scrolledToBottom: true, + readProgress: 100, + endTime: prev.step1.endTime || new Date().toISOString(), + }, + })); + }; + + const handleNext = () => { + const now = new Date().toISOString(); + updateState((prev) => ({ + ...prev, + step1: { + ...prev.step1, + confirmed: true, + checked: true, + confirmTime: now, + documentVersion: DOCUMENT_VERSION, + readProgress: 100, + scrolledToBottom: true, + }, + })); + completeStep(1); + }; + + const canProceed = scrolledToBottom && checked; + + const content = ( +
+

安全培训须知

+ { + updateState((prev) => ({ ...prev, step1: { ...prev.step1, readProgress: p } })); + }} + onReachBottom={handleReachBottom} + onStart={handleStart} + /> +
+
+
+
+ 已阅读 {readProgress}% +
+ +
+ ); + + const footer = ( + <> +
+ {!scrolledToBottom ? '请完整阅读培训文档后继续。' : !checked ? '请勾选确认后继续。' : ''} +
+ + + ); + + if (renderPart === 'content') return content; + if (renderPart === 'footer') return footer; + return ( + <> + {content} + {footer} + + ); +} diff --git a/src/prototypes/xll-miniapp/driver-training/steps/Step2Phone.jsx b/src/prototypes/xll-miniapp/driver-training/steps/Step2Phone.jsx new file mode 100644 index 0000000..f863658 --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/steps/Step2Phone.jsx @@ -0,0 +1,105 @@ +import React, { useState } from 'react'; +import PhoneVerificationForm, { verifyPhoneStep } from '../components/PhoneVerificationForm'; +import { DEMO_PHONE, MOCK_SMS_CODE } from '../data/constants'; + +export default function Step2Phone({ state, updateState, completeStep, renderPart = 'all' }) { + const [errors, setErrors] = useState({}); + + const handleVerify = () => { + const result = verifyPhoneStep(state.step2); + if (!result.ok) { + setErrors({ + phoneError: result.phoneError, + codeError: result.codeError, + }); + return; + } + setErrors({}); + updateState((prev) => ({ + ...prev, + step2: { + ...prev.step2, + phone: result.phone, + code: result.code, + verified: true, + verifiedAt: new Date().toISOString(), + }, + })); + completeStep(2); + }; + + const handleDemoSkip = () => { + updateState((prev) => ({ + ...prev, + step2: { + phone: DEMO_PHONE, + code: MOCK_SMS_CODE, + verified: true, + verifiedAt: new Date().toISOString(), + }, + })); + completeStep(2); + }; + + if (state.step2.verified) { + const content = ( +
+
+ + 手机号 {state.step2.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')} 已验证 +
+
+ ); + const footer = ( + + ); + if (renderPart === 'content') return content; + if (renderPart === 'footer') return footer; + return ( + <> + {content} + {footer} + + ); + } + + const content = ( +
+

手机号验证

+ updateState((prev) => ({ ...prev, step2: { ...prev.step2, ...data } }))} + /> + {errors.phoneError &&
{errors.phoneError}
} + {errors.codeError &&
{errors.codeError}
} +
+ ); + + const footer = ( + <> + + + + ); + + if (renderPart === 'content') return content; + if (renderPart === 'footer') return footer; + return ( + <> + {content} + {footer} + + ); +} diff --git a/src/prototypes/xll-miniapp/driver-training/steps/Step3Certificates.jsx b/src/prototypes/xll-miniapp/driver-training/steps/Step3Certificates.jsx new file mode 100644 index 0000000..e3c5771 --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/steps/Step3Certificates.jsx @@ -0,0 +1,334 @@ +import React, { useRef, useState } from 'react'; +import CertificateUploadCard from '../components/CertificateUploadCard'; +import { ManualCertificateForm, getCertFieldLabel } from '../components/OCRResultForm'; +import UploadSourceSheet from '../components/UploadSourceSheet'; +import { CERT_TYPES, OCR_MOCK } from '../data/constants'; +import { isQualificationRequired, getRequiredCertTypes } from '../context/TrainingContext'; + +const OCR_CERT_TYPES = [CERT_TYPES.ID_FRONT, CERT_TYPES.LICENSE_FRONT, CERT_TYPES.QUALIFICATION]; +const BACK_ONLY = [CERT_TYPES.ID_BACK, CERT_TYPES.LICENSE_BACK]; + +function parseValidEnd(period) { + if (!period) return null; + const parts = period.split(/[—\-~]/); + const end = parts[parts.length - 1]?.trim().replace(/\./g, '-'); + return end ? new Date(end) : null; +} + +export default function Step3Certificates({ state, updateCertificate, completeStep, renderPart = 'all' }) { + const tonnage = state.trainingTask.vehicleTonnage; + const requiredTypes = getRequiredCertTypes(tonnage); + const showOptionalQual = !isQualificationRequired(tonnage); + const certs = state.step3.certificates; + const requiredDoneCount = requiredTypes.filter((t) => certs[t]?.status === 'done').length; + const allDone = requiredDoneCount === requiredTypes.length; + const cameraRef = useRef(null); + const albumRef = useRef(null); + const [uploadSheetCert, setUploadSheetCert] = useState(null); + const [activeCert, setActiveCert] = useState(null); + const [modal, setModal] = useState(null); + const [formData, setFormData] = useState({}); + const [formErrors, setFormErrors] = useState({}); + const [validationError, setValidationError] = useState(''); + const ocrMode = state.debug.ocrMode; + + const allCertTypes = showOptionalQual + ? [...requiredTypes, CERT_TYPES.QUALIFICATION] + : requiredTypes; + + const showUploadOptions = (certType) => { + setUploadSheetCert(certType); + }; + + const pickCamera = () => { + if (!uploadSheetCert) return; + setActiveCert(uploadSheetCert); + setUploadSheetCert(null); + setTimeout(() => cameraRef.current?.click(), 0); + }; + + const pickAlbum = () => { + if (!uploadSheetCert) return; + setActiveCert(uploadSheetCert); + setUploadSheetCert(null); + setTimeout(() => albumRef.current?.click(), 0); + }; + + const runOCR = (certType, imageUrl) => { + updateCertificate(certType, { status: 'recognizing', imageUrl }); + setTimeout(() => { + if (ocrMode === 'fail') { + updateCertificate(certType, { status: 'failed', imageUrl, mode: 'ocr' }); + setModal({ type: 'ocr_fail', certType, imageUrl }); + return; + } + let mock = { ...OCR_MOCK[certType] }; + if (ocrMode === 'mismatch' && certType === CERT_TYPES.LICENSE_FRONT) { + mock = { ...mock, name: '李晓明', licenseNumber: '3304**********9999' }; + } + updateCertificate(certType, { status: 'confirm', imageUrl, mode: 'ocr', fields: mock, ocrData: mock }); + setModal({ type: 'confirm', certType, imageUrl, fields: mock }); + setFormData(mock); + }, 1000); + }; + + const handleFile = (e) => { + const file = e.target.files?.[0]; + if (!file || !activeCert) return; + const reader = new FileReader(); + updateCertificate(activeCert, { status: 'uploading' }); + reader.onload = () => { + if (BACK_ONLY.includes(activeCert)) { + setTimeout(() => { + updateCertificate(activeCert, { + status: 'done', + imageUrl: reader.result, + fields: OCR_MOCK[activeCert] || {}, + completedAt: new Date().toISOString(), + }); + }, 800); + } else { + runOCR(activeCert, reader.result); + } + }; + reader.readAsDataURL(file); + e.target.value = ''; + }; + + const openCert = (certType) => { + const cert = certs[certType]; + if (cert?.status === 'done') { + setModal({ type: 'view', certType, ...cert }); + setFormData(cert.fields || {}); + return; + } + if (cert?.status === 'confirm') { + setModal({ type: 'confirm', certType, imageUrl: cert.imageUrl, fields: cert.fields }); + setFormData(cert.fields || {}); + return; + } + if (cert?.status === 'failed') { + setModal({ type: 'ocr_fail', certType, imageUrl: cert.imageUrl }); + return; + } + showUploadOptions(certType); + }; + + const confirmCert = () => { + const { certType } = modal; + const errors = {}; + const fields = formData; + if (OCR_CERT_TYPES.includes(certType)) { + if (!fields.name) errors.name = '请填写姓名'; + if (certType === CERT_TYPES.ID_FRONT && !fields.idNumber) errors.idNumber = '请填写身份证号'; + if (certType === CERT_TYPES.LICENSE_FRONT) { + if (!fields.licenseNumber) errors.licenseNumber = '请填写证号'; + if (!fields.vehicleClass) errors.vehicleClass = '请选择准驾车型'; + if (!fields.validPeriod) errors.validPeriod = '请填写有效期限'; + } + if (certType === CERT_TYPES.QUALIFICATION) { + if (!fields.certNumber) errors.certNumber = '请填写证号'; + if (!fields.vehicleClass) errors.vehicleClass = '请填写准驾车型'; + if (!fields.category) errors.category = '请填写从业资格类别'; + if (!fields.initialIssueDate) errors.initialIssueDate = '请填写初次领证日期'; + if (!fields.validFrom) errors.validFrom = '请填写有效起始日期'; + if (!fields.validPeriod) errors.validPeriod = '请填写有效期限'; + } + } + setFormErrors(errors); + if (Object.keys(errors).length) return; + + updateCertificate(certType, { + status: 'done', + fields, + imageUrl: modal.imageUrl || certs[certType]?.imageUrl, + mode: modal.mode || certs[certType]?.mode || 'ocr', + completedAt: new Date().toISOString(), + }); + setModal(null); + setFormErrors({}); + }; + + const startManual = () => { + const { certType, imageUrl } = modal; + updateCertificate(certType, { status: 'confirm', mode: 'manual', imageUrl, fields: {} }); + setModal({ type: 'manual', certType, imageUrl, mode: 'manual' }); + setFormData({}); + }; + + const validateAll = () => { + const idFront = certs[CERT_TYPES.ID_FRONT]?.fields; + const license = certs[CERT_TYPES.LICENSE_FRONT]?.fields; + + for (const t of requiredTypes) { + if (!certs[t] || certs[t].status !== 'done') { + setValidationError('请完成所有必填证照上传'); + return false; + } + } + if (!idFront || !license) { + setValidationError('请完成身份证和驾驶证信息确认'); + return false; + } + if (idFront.name !== license.name) { + setValidationError('身份证姓名与驾驶证姓名不一致'); + return false; + } + if (idFront.idNumber !== license.licenseNumber) { + setValidationError('身份证号码与驾驶证证号不一致'); + return false; + } + const endDate = parseValidEnd(license.validPeriod); + if (endDate && endDate < new Date()) { + setValidationError('驾驶证已过期,请更新证件'); + return false; + } + if (license.vehicleClass !== state.trainingTask.requiredVehicleClass) { + setValidationError(`准驾车型不满足要求,当前车辆需要 ${state.trainingTask.requiredVehicleClass} 驾照`); + return false; + } + setValidationError(''); + return true; + }; + + const modals = ( + <> + setUploadSheetCert(null)} + /> + {modal?.type === 'ocr_fail' && ( +
setModal(null)}> +
e.stopPropagation()}> +
+

证件信息识别失败,可以重新拍摄或手动填写。

+
+ + +
+
+
+
+ )} + {(modal?.type === 'confirm' || modal?.type === 'manual' || modal?.type === 'view') && ( +
setModal(null)}> +
e.stopPropagation()} style={{ maxHeight: '80vh', overflow: 'auto' }}> +

+ {modal.type === 'view' ? '查看证照' : modal.type === 'manual' ? '手动填写' : '确认识别结果'} +

+ {modal.imageUrl && } + {(modal.type === 'confirm' || modal.type === 'manual') && OCR_CERT_TYPES.includes(modal.certType) && ( + setFormData((d) => ({ ...d, [key]: val }))} + errors={formErrors} + /> + )} + {modal.type === 'view' && modal.fields && Object.keys(modal.fields).length > 0 && ( +
+ {Object.entries(modal.fields) + .filter(([k]) => k !== 'fileNumber') + .map(([k, v]) => ( +
+ {getCertFieldLabel(k)} + {v} +
+ ))} +
+ )} +
+ + {modal.type !== 'view' && ( + + )} +
+
+
+ )} + + ); + + const content = ( + <> + + +
+

司机证照

+

+ {isQualificationRequired(tonnage) + ? `当前车辆为 ${tonnage} 吨,从业资格证为必填项。` + : `当前车辆为 ${tonnage} 吨,从业资格证为选填项。`} + 点击证件卡片可选择拍照或从相册上传。 +

+ {allCertTypes.map((t) => ( + openCert(t)} + /> + ))} + {allDone && ( +
+ + 证照信息已完成 +
+ )} + {validationError &&
{validationError}
} +
+ {modals} + + ); + + const footer = ( + <> + {!allDone && ( +
+ 请完成全部必填证照上传({requiredDoneCount}/{requiredTypes.length}) +
+ )} + + + ); + + if (renderPart === 'content') return content; + if (renderPart === 'footer') return footer; + return ( + <> + {content} + {footer} + + ); +} diff --git a/src/prototypes/xll-miniapp/driver-training/steps/Step4Video.jsx b/src/prototypes/xll-miniapp/driver-training/steps/Step4Video.jsx new file mode 100644 index 0000000..4a5abc4 --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/steps/Step4Video.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import TrainingVideoPlayer from '../components/TrainingVideoPlayer'; + +export default function Step4Video({ state, updateState, completeStep, renderPart = 'all' }) { + const { step4, debug } = state; + const completed = step4.completed; + + const handleComplete = () => { + updateState((prev) => ({ + ...prev, + step4: { + completed: true, + watchedSeconds: 30, + completedAt: new Date().toISOString(), + }, + })); + }; + + const content = ( +
+

安全培训视频

+

+ 氢能重卡安全驾驶培训 · 预计时长 30 秒 +

+ + updateState((prev) => ({ ...prev, step4: { ...prev.step4, watchedSeconds: s } })) + } + onComplete={handleComplete} + debugComplete={debug.videoStatus === 'completed' && !completed} + /> + {completed && ( +
+ + 视频观看完成 +
+ )} +
+ ); + + const footer = ( + <> + {!completed &&
请完整观看安全培训视频
} + + + ); + + if (renderPart === 'content') return content; + if (renderPart === 'footer') return footer; + return ( + <> + {content} + {footer} + + ); +} diff --git a/src/prototypes/xll-miniapp/driver-training/steps/Step5Portrait.jsx b/src/prototypes/xll-miniapp/driver-training/steps/Step5Portrait.jsx new file mode 100644 index 0000000..5e44131 --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/steps/Step5Portrait.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import CameraCapture from '../components/CameraCapture'; + +export default function Step5Portrait({ state, updateState, completeStep, renderPart = 'all' }) { + const { step5, debug } = state; + + const content = ( +
+

本人照片

+ + updateState((prev) => ({ + ...prev, + step5: { ...prev.step5, photoDataUrl: url, confirmed: false }, + })) + } + onConfirm={() => + updateState((prev) => ({ + ...prev, + step5: { + ...prev.step5, + confirmed: true, + capturedAt: new Date().toISOString(), + }, + })) + } + onRetake={() => + updateState((prev) => ({ + ...prev, + step5: { photoDataUrl: null, confirmed: false, capturedAt: null }, + })) + } + /> +
+ ); + + const footer = ( + <> + {!step5.confirmed && ( +
请拍照并确认使用本人照片
+ )} + + + ); + + if (renderPart === 'content') return content; + if (renderPart === 'footer') return footer; + return ( + <> + {content} + {footer} + + ); +} diff --git a/src/prototypes/xll-miniapp/driver-training/steps/Step6Signature.jsx b/src/prototypes/xll-miniapp/driver-training/steps/Step6Signature.jsx new file mode 100644 index 0000000..a5f87ef --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/steps/Step6Signature.jsx @@ -0,0 +1,86 @@ +import React from 'react'; + +import { formatNow } from '../utils/formatDateTime'; + +const DECLARATION = + '本人确认所提交的身份及驾驶证件信息真实有效,已完整阅读安全培训文档并观看安全培训视频,已知悉氢能车辆驾驶及应急处置要求,并确认由本人执行本次提车。'; + +export default function Step6Signature({ state, updateState, completeStep, renderPart = 'all' }) { + const { step6, step3, trainingTask } = state; + const checked = step6.checked; + + const driverName = step3.certificates?.idFront?.fields?.name || '—'; + const idLast4 = (step3.certificates?.idFront?.fields?.idNumber || '****').slice(-4); + const vehicleClass = step3.certificates?.licenseFront?.fields?.vehicleClass || '—'; + + const content = ( +
+

签字确认

+ +
+

① 阅读声明

+
{DECLARATION}
+
+ +
+

② 核对信息

+
+
+ 司机姓名 + {driverName} +
+
+ 当前车辆 + {trainingTask.vehiclePlate} +
+
+ 身份证后四位 + {idLast4} +
+
+ 驾驶证准驾车型 + {vehicleClass} +
+
+ 培训完成时间 + {formatNow()} +
+
+
+ + +
+ ); + + const footer = ( + <> + {!checked ?
请勾选「本人确认以上信息及签名真实有效」
: null} + + + ); + + if (renderPart === 'content') return content; + if (renderPart === 'footer') return footer; + return ( + <> + {content} + {footer} + + ); +} diff --git a/src/prototypes/xll-miniapp/driver-training/steps/Step7PickupCode.jsx b/src/prototypes/xll-miniapp/driver-training/steps/Step7PickupCode.jsx new file mode 100644 index 0000000..66cb218 --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/steps/Step7PickupCode.jsx @@ -0,0 +1,11 @@ +import React from 'react'; +import PickupCodeCard from '../components/PickupCodeCard'; + +export default function Step7PickupCode({ state, renderPart = 'all' }) { + const { step7 } = state; + + const content = ; + + if (renderPart === 'footer') return null; + return content; +} diff --git a/src/prototypes/xll-miniapp/driver-training/steps/Step7Signature.jsx b/src/prototypes/xll-miniapp/driver-training/steps/Step7Signature.jsx new file mode 100644 index 0000000..ae1a3c0 --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/steps/Step7Signature.jsx @@ -0,0 +1,169 @@ +import React, { useEffect, useRef, useState } from 'react'; + +import { formatNow } from '../utils/formatDateTime'; + +import SignatureCanvas from '../components/SignatureCanvas'; +import ConfirmDialog from '../components/ConfirmDialog'; + +function useContainerLandscape(rootRef) { + const [isLandscape, setIsLandscape] = useState(false); + + useEffect(() => { + const root = rootRef.current?.closest('.driver-training-app') || rootRef.current; + if (!root) return undefined; + + const check = () => { + const { width, height } = root.getBoundingClientRect(); + setIsLandscape(width > height * 1.05); + }; + + check(); + const observer = new ResizeObserver(check); + observer.observe(root); + window.addEventListener('orientationchange', check); + return () => { + observer.disconnect(); + window.removeEventListener('orientationchange', check); + }; + }, [rootRef]); + + return isLandscape; +} + +export default function Step7Signature({ + state, + updateState, + submitTraining, + goToStep, + renderPart = 'all', +}) { + const { step6, step3, trainingTask } = state; + const [showSubmitDialog, setShowSubmitDialog] = useState(false); + const rootRef = useRef(null); + const canvasRef = useRef(null); + const isLandscape = useContainerLandscape(rootRef); + const hasSignature = !!step6.signatureDataUrl; + + const driverName = step3.certificates?.idFront?.fields?.name || '—'; + + const handleSubmit = () => { + submitTraining(); + setShowSubmitDialog(false); + }; + + if (step6.submitted) { + if (renderPart === 'footer') return null; + return ( +
+
+ + 培训记录已提交 +
+
+ ); + } + + if (renderPart === 'footer') return null; + + const dialogSummary = ( + <> +
+ 司机 + {driverName} +
+
+ 车辆 + {trainingTask.vehiclePlate} +
+
+ 培训完成时间 + {formatNow()} +
+ {hasSignature && ( +
+ 签名预览 + 签名预览 +
+ )} + + ); + + const submitDialog = ( + setShowSubmitDialog(false)} + onConfirm={handleSubmit} + /> + ); + + return ( +
+ {!isLandscape && ( +
+ +
+ 横屏签字预览 + 正式使用时请旋转手机至横屏;下方为横屏全屏签名页效果,可直接体验签字并完成提交。 +
+
+ )} + +
+
+ +

手写签名

+ +
+ +
+ {driverName} + · + {trainingTask.vehiclePlate} +
+ +
+ + updateState((prev) => ({ ...prev, step6: { ...prev.step6, signatureDataUrl: url } })) + } + /> +
+ +
+ + + +
+
+ {submitDialog} +
+ ); +} diff --git a/src/prototypes/xll-miniapp/driver-training/styles/driver-training.css b/src/prototypes/xll-miniapp/driver-training/styles/driver-training.css new file mode 100644 index 0000000..712638b --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/styles/driver-training.css @@ -0,0 +1,1945 @@ +.driver-training-app { + --dt-primary: #7AB929; + --dt-primary-light: rgba(122, 185, 41, 0.14); + --dt-bg: #F7F8FA; + --dt-card: #FFFFFF; + --dt-title: #1D2129; + --dt-text: #4E5969; + --dt-text-secondary: #86909C; + --dt-border: #E5E6EB; + --dt-danger: #F53F3F; + --dt-warning: #FF7D00; + --dt-safe-top: env(safe-area-inset-top, 0px); + --dt-safe-bottom: env(safe-area-inset-bottom, 0px); + max-width: 430px; + min-width: 375px; + margin: 0 auto; + min-height: 100vh; + background: var(--dt-bg); + color: var(--dt-title); + font-family: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Microsoft YaHei', sans-serif; + position: relative; + overflow-x: hidden; +} + +.driver-training-app * { + box-sizing: border-box; +} + +.driver-training-app--embedded { + max-width: none; + min-width: 0; + min-height: 0; + height: 100%; + margin: 0; + background: transparent; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.driver-training-app--embedded .dt-page { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + height: 100%; + padding-bottom: 0; +} + +.driver-training-app--embedded .dt-summary-card { + margin: 0 16px 8px; + flex-shrink: 0; +} + +.driver-training-app--embedded .dt-content { + flex: 1; + min-height: 0; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; +} + +.driver-training-app--embedded .dt-footer { + position: static; + left: auto; + transform: none; + max-width: none; + min-width: 0; + width: 100%; + flex-shrink: 0; + z-index: 1; +} + +.dt-module-root { + position: relative; + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; +} + +.dt-footer-stack { + display: flex; + flex-direction: column; + gap: 8px; +} + +.dt-footer-row { + display: flex; + gap: 10px; + align-items: stretch; +} + +.dt-footer-row .dt-btn-primary, +.dt-footer-row .dt-btn-secondary { + flex: 1; +} + +.dt-btn-prev { + flex: 0 0 96px; + width: 96px; + height: 48px; + border: 1px solid var(--dt-border); + border-radius: 12px; + background: var(--dt-card); + color: var(--dt-title); + font-size: 15px; + font-weight: 500; + cursor: pointer; +} + +.dt-btn-prev:active { + background: var(--dt-bg); +} + +.dt-footer-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.dt-footer-main .dt-btn-primary, +.dt-footer-main .dt-btn-secondary { + width: 100%; +} + +.dt-page { + display: flex; + flex-direction: column; + min-height: 100vh; + padding-bottom: calc(80px + var(--dt-safe-bottom)); +} + +.dt-header { + position: sticky; + top: 0; + z-index: 100; + background: var(--dt-card); + padding-top: var(--dt-safe-top); + border-bottom: 1px solid var(--dt-border); +} + +.dt-header-bar { + display: flex; + align-items: center; + height: 48px; + padding: 0 12px; + gap: 8px; +} + +.dt-header-back { + width: 36px; + height: 36px; + border: none; + background: transparent; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 8px; + color: var(--dt-title); + flex-shrink: 0; +} + +.dt-header-back:active { + background: var(--dt-bg); +} + +.dt-header-title { + flex: 1; + text-align: center; + font-size: 17px; + font-weight: 600; + margin: 0; + padding-right: 36px; +} + +.dt-content { + flex: 1; + padding: 12px 16px 16px; +} + +.dt-card { + background: var(--dt-card); + border-radius: 16px; + padding: 16px; + margin-bottom: 12px; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04); +} + +.dt-card-title { + font-size: 16px; + font-weight: 600; + margin: 0 0 12px; + color: var(--dt-title); +} + +.dt-step-header { + margin-bottom: 12px; +} + +.dt-step-indicator { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.dt-step-text { + font-size: 14px; + color: var(--dt-text); + font-weight: 500; +} + +.dt-step-count { + font-size: 13px; + color: var(--dt-text-secondary); +} + +.dt-step-dots { + display: flex; + gap: 6px; + justify-content: center; + margin-bottom: 8px; +} + +.dt-step-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--dt-border); + transition: all 0.2s; +} + +.dt-step-dot.active { + background: var(--dt-primary); + transform: scale(1.2); +} + +.dt-step-dot.done { + background: var(--dt-primary); + opacity: 0.5; +} + +.dt-progress-bar { + height: 4px; + background: var(--dt-border); + border-radius: 2px; + overflow: hidden; +} + +.dt-progress-fill { + height: 100%; + background: var(--dt-primary); + border-radius: 2px; + transition: width 0.3s; +} + +.dt-step-name { + text-align: center; + font-size: 13px; + color: var(--dt-text-secondary); + margin-top: 6px; +} + +.dt-summary-card { + background: var(--dt-card); + border-radius: 12px; + margin: 0 16px 12px; + border: 1px solid var(--dt-border); + overflow: hidden; +} + +.dt-summary-toggle { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 14px; + border: none; + background: var(--dt-primary-light); + cursor: pointer; + font-size: 13px; + color: var(--dt-title); + font-weight: 500; +} + +.dt-summary-body { + padding: 12px 14px; + display: grid; + gap: 8px; +} + +.dt-summary-row { + display: flex; + justify-content: space-between; + font-size: 13px; + gap: 12px; +} + +.dt-summary-label { + color: var(--dt-text-secondary); + flex-shrink: 0; +} + +.dt-summary-value { + color: var(--dt-title); + text-align: right; +} + +.dt-footer { + position: fixed; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 100%; + max-width: 430px; + min-width: 375px; + background: var(--dt-card); + border-top: 1px solid var(--dt-border); + padding: 12px 16px calc(12px + var(--dt-safe-bottom)); + z-index: 90; +} + +.dt-footer-hint { + font-size: 12px; + color: var(--dt-text-secondary); + text-align: center; + margin-bottom: 8px; +} + +.dt-btn-primary { + width: 100%; + height: 48px; + border: none; + border-radius: 12px; + background: var(--dt-primary); + color: #fff; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: opacity 0.2s; +} + +.dt-btn-primary:disabled { + background: #C9CDD4; + cursor: not-allowed; +} + +.dt-btn-primary:not(:disabled):active { + opacity: 0.85; +} + +.dt-btn-secondary { + width: 100%; + height: 44px; + border: 1px solid var(--dt-border); + border-radius: 12px; + background: var(--dt-card); + color: var(--dt-title); + font-size: 15px; + cursor: pointer; +} + +.dt-btn-outline { + flex: 1; + height: 40px; + border: 1px solid var(--dt-primary); + border-radius: 10px; + background: var(--dt-card); + color: var(--dt-primary); + font-size: 14px; + cursor: pointer; +} + +.dt-input { + width: 100%; + height: 48px; + border: 1px solid var(--dt-border); + border-radius: 12px; + padding: 0 14px; + font-size: 16px; + outline: none; + background: var(--dt-card); + transition: border-color 0.2s; +} + +.dt-input:focus { + border-color: var(--dt-primary); +} + +.dt-input.error { + border-color: var(--dt-danger); +} + +.dt-select { + appearance: auto; + cursor: pointer; + color: var(--dt-title); +} + +.dt-select:invalid, +.dt-select option[value=''] { + color: var(--dt-text-secondary); +} + +.dt-field-error { + font-size: 12px; + color: var(--dt-danger); + margin-top: 4px; +} + +.dt-field-hint { + font-size: 12px; + color: var(--dt-text-secondary); + margin-top: 6px; + line-height: 1.5; +} + +.dt-label { + font-size: 14px; + font-weight: 500; + margin-bottom: 8px; + display: block; + color: var(--dt-title); +} + +.dt-status-tag { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; +} + +.dt-status-tag.pending { background: #E8F3FF; color: #165DFF; } +.dt-status-tag.locked { background: #FFF7E8; color: #FF7D00; } +.dt-status-tag.processing { background: #E8F7FF; color: #0FC6C2; } +.dt-status-tag.completed { background: var(--dt-primary-light); color: var(--dt-primary); } +.dt-status-tag.expired, .dt-status-tag.revoked { background: #F2F3F5; color: #86909C; } + +.dt-toast { + position: fixed; + top: calc(60px + var(--dt-safe-top)); + left: 50%; + transform: translateX(-50%); + background: rgba(29, 33, 41, 0.88); + color: #fff; + padding: 10px 20px; + border-radius: 8px; + font-size: 14px; + z-index: 1000; + animation: dt-toast-in 0.3s ease; + max-width: 320px; + text-align: center; +} + +@keyframes dt-toast-in { + from { opacity: 0; transform: translateX(-50%) translateY(-10px); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} + +.dt-document-viewer { + height: 360px; + overflow-y: auto; + border: 1px solid var(--dt-border); + border-radius: 12px; + padding: 16px; + background: #FAFBFC; + font-size: 14px; + line-height: 1.8; + color: var(--dt-text); + white-space: pre-wrap; +} + +.dt-read-progress { + display: flex; + flex-direction: column; + gap: 6px; + align-items: stretch; + margin: 12px 0; +} + +.dt-checkbox-row { + display: flex; + align-items: flex-start; + gap: 8px; + margin: 12px 0; + font-size: 14px; + color: var(--dt-text); +} + +.dt-checkbox-row input { + margin-top: 3px; + accent-color: var(--dt-primary); + width: 18px; + height: 18px; + flex-shrink: 0; +} + +.dt-cert-card { + border: 1px solid var(--dt-border); + border-radius: 12px; + padding: 14px; + margin-bottom: 10px; + cursor: pointer; + transition: border-color 0.2s; +} + +.dt-cert-card:hover { + border-color: var(--dt-primary); +} + +.dt-cert-card.done { + border-color: var(--dt-primary); + background: var(--dt-primary-light); +} + +.dt-cert-card.error { + border-color: var(--dt-danger); + background: #FFF0F0; +} + +.dt-cert-card-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.dt-cert-card-title { + font-size: 15px; + font-weight: 500; +} + +.dt-cert-badge { + font-size: 11px; + padding: 2px 6px; + border-radius: 4px; + background: #FFECE8; + color: var(--dt-danger); +} + +.dt-cert-badge.optional { + background: #F2F3F5; + color: var(--dt-text-secondary); +} + +.dt-cert-status { + font-size: 12px; + color: var(--dt-text-secondary); + margin-top: 6px; +} + +.dt-cert-preview { + margin-top: 10px; + display: flex; + gap: 8px; + align-items: center; +} + +.dt-cert-thumb { + width: 64px; + height: 44px; + border-radius: 6px; + object-fit: cover; + border: 1px solid var(--dt-border); +} + +.dt-error-card { + background: #FFF0F0; + border: 1px solid #FFCCC7; + border-radius: 12px; + padding: 14px; + margin: 12px 0; +} + +.dt-error-card p { + margin: 0 0 12px; + font-size: 14px; + color: var(--dt-danger); +} + +.dt-error-actions { + display: flex; + gap: 10px; +} + +.dt-success-banner { + background: var(--dt-primary-light); + border-radius: 12px; + padding: 12px 14px; + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: var(--dt-primary); + font-weight: 500; + margin-bottom: 12px; +} + +.dt-video-player { + border-radius: 12px; + overflow: hidden; + background: #000; + position: relative; +} + +.dt-video-screen { + width: 100%; + aspect-ratio: 16/9; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 14px; + background: linear-gradient(135deg, #1a3a1a 0%, #0d1f0d 100%); + position: relative; +} + +.dt-video-overlay { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; +} + +.dt-video-controls { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 14px; + background: #1D2129; +} + +.dt-video-progress { + flex: 1; + height: 4px; + background: rgba(255,255,255,0.2); + border-radius: 2px; + overflow: hidden; +} + +.dt-video-progress-fill { + height: 100%; + background: var(--dt-primary); + pointer-events: none; +} + +.dt-video-play-btn { + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + background: var(--dt-primary); + color: #fff; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.dt-camera-container { + position: relative; + border-radius: 16px; + overflow: hidden; + background: #000; + aspect-ratio: 3/4; + max-height: 420px; +} + +.dt-camera-video { + width: 100%; + height: 100%; + object-fit: cover; + transform: scaleX(-1); +} + +.dt-camera-guide { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; +} + +.dt-camera-oval { + width: 200px; + height: 260px; + border: 2px dashed rgba(255,255,255,0.7); + border-radius: 50%; +} + +.dt-camera-tips { + list-style: none; + padding: 0; + margin: 12px 0; +} + +.dt-camera-tips li { + font-size: 13px; + color: var(--dt-text-secondary); + padding: 4px 0; + padding-left: 16px; + position: relative; +} + +.dt-camera-tips li::before { + content: '•'; + position: absolute; + left: 0; + color: var(--dt-primary); +} + +.dt-portrait-preview { + width: 120px; + height: 150px; + border-radius: 12px; + object-fit: cover; + border: 2px solid var(--dt-primary); +} + +.dt-signature-pad { + border: 1px solid var(--dt-border); + border-radius: 12px; + background: #FAFBFC; + touch-action: none; +} + +.dt-signature-canvas { + width: 100%; + height: 180px; + display: block; + cursor: crosshair; +} + +.dt-signature-actions { + display: flex; + gap: 10px; + margin-top: 10px; +} + +.dt-pickup-success { + text-align: center; + padding: 20px 0 12px; +} + +.dt-pickup-icon { + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--dt-primary-light); + color: var(--dt-primary); + display: flex; + align-items: center; + justify-content: center; + margin: 0 auto 12px; + font-size: 28px; +} + +.dt-pickup-title { + font-size: 18px; + font-weight: 600; + margin: 0 0 4px; +} + +.dt-pickup-subtitle { + font-size: 13px; + color: var(--dt-text-secondary); + margin: 0; +} + +.dt-qr-card { + text-align: center; + position: relative; +} + +.dt-qr-code { + width: 180px; + height: 180px; + margin: 12px auto; + border: 1px solid var(--dt-border); + border-radius: 8px; + padding: 8px; + background: #fff; +} + +.dt-qr-code.disabled { + opacity: 0.35; + filter: grayscale(1); +} + +.dt-qr-overlay { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(0,0,0,0.6); + color: #fff; + padding: 6px 16px; + border-radius: 6px; + font-size: 14px; + font-weight: 600; +} + +.dt-numeric-code { + font-size: 32px; + font-weight: 700; + letter-spacing: 8px; + color: var(--dt-primary); + margin: 8px 0; +} + +.dt-info-list { + display: grid; + gap: 10px; + text-align: left; +} + +.dt-info-item { + display: flex; + justify-content: space-between; + font-size: 14px; +} + +.dt-info-item .label { + color: var(--dt-text-secondary); +} + +.dt-warning-tip { + background: #FFF7E8; + border-radius: 8px; + padding: 10px 12px; + font-size: 12px; + color: #FF7D00; + margin-top: 12px; + line-height: 1.5; +} + +.dt-debug-panel { + position: absolute; + right: 8px; + bottom: 88px; + z-index: 200; + max-width: 200px; +} + +.dt-debug-toggle { + width: 40px; + height: 40px; + border-radius: 50%; + border: none; + background: #1D2129; + color: #fff; + font-size: 11px; + cursor: pointer; + box-shadow: 0 2px 8px rgba(0,0,0,0.2); +} + +.dt-debug-body { + background: #1D2129; + color: #fff; + border-radius: 12px; + padding: 12px; + margin-bottom: 8px; + font-size: 11px; + max-height: 360px; + overflow-y: auto; +} + +.dt-debug-section { + margin-bottom: 10px; +} + +.dt-debug-section label { + display: block; + margin-bottom: 4px; + color: #C9CDD4; +} + +.dt-debug-section select, +.dt-debug-section button { + width: 100%; + padding: 4px 6px; + border-radius: 6px; + border: none; + font-size: 11px; + margin-bottom: 4px; +} + +.dt-debug-reset { + background: var(--dt-danger); + color: #fff; + cursor: pointer; + padding: 6px; + border: none; + border-radius: 6px; + width: 100%; +} + +.dt-modal-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.5); + z-index: 300; + display: flex; + align-items: center; + justify-content: center; + padding: 24px; +} + +.dt-modal { + background: var(--dt-card); + border-radius: 16px; + width: 100%; + max-width: 340px; + padding: 20px; +} + +.dt-modal-title { + font-size: 17px; + font-weight: 600; + margin: 0 0 10px; + text-align: center; +} + +.dt-modal-content { + font-size: 14px; + color: var(--dt-text); + line-height: 1.6; + margin-bottom: 20px; + text-align: center; +} + +.dt-modal-actions { + display: flex; + gap: 10px; +} + +/* Upload source bottom sheet */ +.dt-sheet-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 320; + display: flex; + align-items: flex-end; + justify-content: center; +} + +.dt-sheet { + width: 100%; + max-width: 430px; + background: var(--dt-card); + border-radius: 16px 16px 0 0; + padding: 16px 16px calc(16px + var(--dt-safe-bottom)); + animation: dt-sheet-up 0.25s ease; +} + +@keyframes dt-sheet-up { + from { transform: translateY(100%); } + to { transform: translateY(0); } +} + +.dt-sheet-title { + font-size: 16px; + font-weight: 600; + text-align: center; + margin-bottom: 4px; +} + +.dt-sheet-desc { + font-size: 13px; + color: var(--dt-text-secondary); + text-align: center; + margin: 0 0 16px; +} + +.dt-sheet-option { + width: 100%; + display: flex; + align-items: center; + gap: 14px; + padding: 14px 16px; + border: 1px solid var(--dt-border); + border-radius: 12px; + background: var(--dt-card); + cursor: pointer; + margin-bottom: 10px; + text-align: left; +} + +.dt-sheet-option:active { + background: var(--dt-bg); +} + +.dt-sheet-option-icon { + font-size: 24px; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + background: var(--dt-primary-light); + border-radius: 10px; + flex-shrink: 0; +} + +.dt-sheet-option-text { + display: flex; + flex-direction: column; + gap: 2px; +} + +.dt-sheet-option-text strong { + font-size: 15px; + color: var(--dt-title); + font-weight: 600; +} + +.dt-sheet-option-text small { + font-size: 12px; + color: var(--dt-text-secondary); +} + +.dt-sheet-cancel { + width: 100%; + height: 48px; + border: none; + border-radius: 12px; + background: var(--dt-bg); + color: var(--dt-text); + font-size: 16px; + cursor: pointer; + margin-top: 4px; +} + +.dt-form-group { + margin-bottom: 16px; +} + +.dt-code-row { + display: flex; + gap: 10px; +} + +.dt-code-row .dt-input { + flex: 1; +} + +.dt-code-btn { + flex-shrink: 0; + height: 48px; + padding: 0 14px; + border: 1px solid var(--dt-primary); + border-radius: 12px; + background: var(--dt-card); + color: var(--dt-primary); + font-size: 13px; + cursor: pointer; + white-space: nowrap; +} + +.dt-code-btn:disabled { + border-color: var(--dt-border); + color: var(--dt-text-secondary); + cursor: not-allowed; +} + +.dt-ocr-form .dt-form-group { + margin-bottom: 12px; +} + +.dt-manual-tip { + background: #E8F3FF; + border-radius: 10px; + padding: 10px 12px; + font-size: 13px; + color: #165DFF; + margin-bottom: 12px; + line-height: 1.5; +} + +/* Mini Program Business Page */ +.mp-app { + max-width: 430px; + min-width: 375px; + margin: 0 auto; + min-height: 100vh; + background: var(--dt-bg); + padding-bottom: calc(60px + var(--dt-safe-bottom)); +} + +.mp-header { + background: var(--dt-card); + text-align: center; + padding: calc(12px + var(--dt-safe-top)) 16px 12px; + font-size: 17px; + font-weight: 600; + border-bottom: 1px solid var(--dt-border); +} + +.mp-section { + margin: 12px 16px; +} + +.mp-section-title { + font-size: 15px; + font-weight: 600; + margin-bottom: 12px; + color: var(--dt-title); +} + +.mp-grid { + background: var(--dt-card); + border-radius: 16px; + padding: 16px 8px; + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 8px; +} + +.mp-grid-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 12px 4px; + border: none; + background: transparent; + cursor: pointer; + border-radius: 12px; + transition: background 0.2s; +} + +.mp-grid-item:active { + background: var(--dt-bg); +} + +.mp-grid-icon { + width: 48px; + height: 48px; + border-radius: 12px; + background: var(--dt-primary-light); + display: flex; + align-items: center; + justify-content: center; + color: var(--dt-primary); + font-size: 22px; + position: relative; +} + +.mp-grid-badge { + position: absolute; + top: -4px; + right: -4px; + background: var(--dt-primary); + color: #fff; + font-size: 10px; + min-width: 18px; + height: 18px; + border-radius: 9px; + display: flex; + align-items: center; + justify-content: center; + padding: 0 4px; +} + +.mp-grid-label { + font-size: 12px; + color: var(--dt-text); + text-align: center; +} + +.mp-tabbar { + position: fixed; + bottom: 0; + left: 50%; + transform: translateX(-50%); + width: 100%; + max-width: 430px; + min-width: 375px; + background: var(--dt-card); + border-top: 1px solid var(--dt-border); + display: flex; + padding-bottom: var(--dt-safe-bottom); +} + +.mp-tabbar-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 0; + border: none; + background: transparent; + cursor: pointer; + color: var(--dt-text-secondary); + font-size: 10px; + gap: 2px; +} + +.mp-tabbar-item.active { + color: var(--dt-primary); +} + +.mp-tabbar-icon { + font-size: 20px; +} + +.mp-tabbar-item.active::after { + content: ''; + width: 20px; + height: 2px; + background: var(--dt-primary); + border-radius: 1px; + margin-top: 2px; +} + +/* --- UI optimization: progress, signature, pickup --- */ + +.dt-step-header--complete .dt-progress-fill { + background: linear-gradient(90deg, var(--dt-primary), #95d63a); +} + +.dt-phase-bar { + display: flex; + align-items: flex-start; + justify-content: space-between; + margin-bottom: 10px; + gap: 4px; +} + +.dt-phase-connector { + flex: 1; + height: 2px; + background: var(--dt-border); + margin-top: 14px; + min-width: 8px; + transition: background 0.3s; +} + +.dt-phase-connector.done { + background: var(--dt-primary); +} + +.dt-phase-item { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + flex: 1; + min-width: 0; +} + +.dt-phase-icon { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; + background: var(--dt-border); + color: var(--dt-text-secondary); + transition: all 0.25s; +} + +.dt-phase-item--active .dt-phase-icon { + background: var(--dt-primary); + color: #fff; + box-shadow: 0 0 0 4px var(--dt-primary-light); +} + +.dt-phase-item--done .dt-phase-icon { + background: var(--dt-primary); + color: #fff; +} + +.dt-phase-label { + font-size: 11px; + color: var(--dt-text-secondary); + text-align: center; + line-height: 1.3; +} + +.dt-phase-item--active .dt-phase-label { + color: var(--dt-primary); + font-weight: 600; +} + +.dt-phase-item--done .dt-phase-label { + color: var(--dt-text); +} + +.dt-step-meta { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-wrap: wrap; + margin-top: 6px; +} + +.dt-step-detail-hint { + font-size: 12px; + color: var(--dt-primary); + background: var(--dt-primary-light); + padding: 2px 8px; + border-radius: 999px; +} + +.dt-read-progress span { + text-align: center; + font-size: 13px; + color: var(--dt-text-secondary); +} + +.dt-read-progress-bar { + height: 4px; + background: var(--dt-border); + border-radius: 2px; + overflow: hidden; +} + +.dt-read-progress-fill { + height: 100%; + background: var(--dt-primary); + border-radius: 2px; + transition: width 0.2s; +} + +.dt-btn-primary--ready:not(:disabled) { + animation: dt-btn-ready 0.35s ease; +} + +@keyframes dt-btn-ready { + from { transform: scale(0.98); } + to { transform: scale(1); } +} + +.dt-submit-checklist { + list-style: none; + margin: 0 0 8px; + padding: 10px 12px; + background: #FAFBFC; + border-radius: 10px; + border: 1px solid var(--dt-border); +} + +.dt-submit-checklist li { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--dt-text-secondary); + padding: 3px 0; +} + +.dt-submit-checklist li.done { + color: var(--dt-primary); +} + +.dt-submit-checklist-icon { + width: 16px; + text-align: center; + flex-shrink: 0; +} + +.dt-step6-section { + margin-bottom: 16px; +} + +.dt-step6-section-title { + font-size: 13px; + font-weight: 600; + color: var(--dt-title); + margin: 0 0 8px; +} + +.dt-step6-declaration { + max-height: 120px; + overflow-y: auto; + padding: 12px; + background: #FAFBFC; + border: 1px solid var(--dt-border); + border-radius: 10px; + font-size: 14px; + line-height: 1.7; + color: var(--dt-text); +} + +.dt-step6-info { + padding: 12px; + background: #F7F8FA; + border-radius: 12px; +} + +.dt-info-item--highlight span:last-child { + font-weight: 600; + color: var(--dt-title); +} + +.dt-signature-wrap { + margin-top: 4px; +} + +.dt-signature-pad { + position: relative; + border: 2px dashed var(--dt-border); + border-radius: 12px; + background: #FAFBFC; + touch-action: none; + transition: border-color 0.2s; +} + +.dt-signature-pad--signed { + border-style: solid; + border-color: var(--dt-primary); + background: #fff; +} + +.dt-signature-placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--dt-text-secondary); + font-size: 15px; + pointer-events: none; + z-index: 1; +} + +.dt-signature-baseline { + position: absolute; + left: 16px; + right: 16px; + bottom: 36px; + height: 1px; + background: var(--dt-border); + pointer-events: none; + z-index: 1; +} + +.dt-signature-canvas { + position: relative; + z-index: 2; +} + +.dt-signature-clear { + flex: 0 0 auto; + width: auto; + min-width: 96px; + height: 40px; + padding: 0 14px; +} + +.dt-signature-done-badge { + flex: 1; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 4px; + font-size: 14px; + font-weight: 600; + color: var(--dt-primary); +} + +.dt-modal--summary { + max-width: 360px; +} + +.dt-modal-summary { + background: #F7F8FA; + border-radius: 12px; + padding: 12px; + margin-bottom: 12px; + text-align: left; +} + +.dt-modal-summary-row { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 14px; + padding: 4px 0; +} + +.dt-modal-summary-row .label { + color: var(--dt-text-secondary); + flex-shrink: 0; +} + +.dt-modal-signature-preview { + margin-top: 10px; + padding-top: 10px; + border-top: 1px dashed var(--dt-border); +} + +.dt-modal-signature-preview .label { + display: block; + font-size: 12px; + color: var(--dt-text-secondary); + margin-bottom: 6px; +} + +.dt-modal-signature-preview img { + width: 100%; + max-height: 72px; + object-fit: contain; + background: #fff; + border: 1px solid var(--dt-border); + border-radius: 8px; +} + +.dt-modal-note { + font-size: 13px; + color: var(--dt-warning); + line-height: 1.5; + margin: 0 0 12px; + text-align: center; +} + +.dt-pickup-page { + padding-bottom: 8px; +} + +.dt-animate-fade-in { + animation: dt-fade-in 0.45s ease; +} + +.dt-animate-scale-in { + animation: dt-scale-in 0.5s ease 0.1s both; +} + +@keyframes dt-fade-in { + from { opacity: 0; transform: translateY(8px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes dt-scale-in { + from { opacity: 0; transform: scale(0.92); } + to { opacity: 1; transform: scale(1); } +} + +.dt-qr-card-header { + text-align: center; + margin-bottom: 12px; +} + +.dt-qr-status-hint { + font-size: 12px; + color: var(--dt-text-secondary); + margin: 8px 0 0; + line-height: 1.5; +} + +.dt-qr-frame { + position: relative; + display: flex; + justify-content: center; + margin-bottom: 8px; +} + +.dt-numeric-code-row { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + margin-bottom: 4px; +} + +.dt-btn-copy-code { + height: 32px; + padding: 0 12px; + border: 1px solid var(--dt-primary); + border-radius: 8px; + background: var(--dt-card); + color: var(--dt-primary); + font-size: 13px; + cursor: pointer; +} + +.dt-btn-copy-code:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.dt-numeric-code-hint { + font-size: 12px; + color: var(--dt-text-secondary); + margin: 0 0 12px; + text-align: center; +} + +.driver-training-app--embedded .dt-footer { + padding-bottom: calc(12px + var(--dt-safe-bottom)); +} + +/* 手写签名 - e签宝风格横屏全屏 */ +.driver-training-app--signature-immersive { + position: relative; +} + +.driver-training-app--signature-immersive .dt-page { + min-height: 100%; + height: 100%; + display: flex; + flex-direction: column; +} + +.driver-training-app--signature-immersive .dt-content--signature-immersive { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + padding: 0; + overflow: hidden; +} + +.dt-esign-root { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +/* 竖屏预览:顶部说明 + 下方横屏签名页效果 */ +.dt-esign-root--portrait-preview { + background: #f5f6f8; +} + +.dt-esign-preview-banner { + flex: 0 0 auto; + display: flex; + gap: 12px; + align-items: flex-start; + margin: 0 0 8px; + padding: 12px 14px; + border-radius: 12px; + background: linear-gradient(135deg, #2b2f36 0%, #1a1d24 100%); + color: #fff; +} + +.dt-esign-preview-banner-icon { + flex: 0 0 auto; + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: rgba(122, 185, 41, 0.2); + color: var(--dt-primary); + font-size: 20px; + animation: dt-esign-rotate-hint 1.8s ease-in-out infinite; +} + +.dt-esign-preview-banner-text { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; + line-height: 1.55; + color: rgba(255, 255, 255, 0.78); +} + +.dt-esign-preview-banner-text strong { + font-size: 14px; + color: #fff; +} + +.dt-esign-root--portrait-preview .dt-esign-landscape { + flex: 1; + min-height: 0; + border: 2px solid var(--dt-primary); + border-radius: 12px; + overflow: hidden; + box-shadow: 0 8px 24px rgba(122, 185, 41, 0.12); +} + +.dt-esign-root--portrait-preview .dt-esign-canvas-area { + margin: 6px; + min-height: 200px; +} + +.dt-esign-root--landscape .dt-esign-preview-banner { + display: none; +} + +/* 竖屏:仅旋转引导(已废弃,保留样式供参考) */ +.dt-esign-portrait { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + min-height: 0; + background: linear-gradient(180deg, #2b2f36 0%, #1a1d24 100%); + color: #fff; + padding: 24px 20px; +} + +.dt-esign-portrait-inner { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + max-width: 280px; +} + +.dt-esign-portrait-phone { + position: relative; + width: 72px; + height: 120px; + margin-bottom: 28px; +} + +.dt-esign-portrait-phone-body { + width: 100%; + height: 100%; + border: 3px solid rgba(255, 255, 255, 0.85); + border-radius: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} + +.dt-esign-portrait-phone-arrow { + position: absolute; + right: -36px; + top: 50%; + transform: translateY(-50%); + font-size: 28px; + color: var(--dt-primary); + animation: dt-esign-rotate-hint 1.8s ease-in-out infinite; +} + +@keyframes dt-esign-rotate-hint { + 0%, 100% { transform: translateY(-50%) rotate(0deg); opacity: 0.6; } + 50% { transform: translateY(-50%) rotate(90deg); opacity: 1; } +} + +.dt-esign-portrait-title { + margin: 0 0 10px; + font-size: 20px; + font-weight: 600; + letter-spacing: 0.5px; +} + +.dt-esign-portrait-desc { + margin: 0 0 28px; + font-size: 14px; + line-height: 1.65; + color: rgba(255, 255, 255, 0.72); +} + +.dt-esign-portrait-back { + height: 44px; + padding: 0 28px; + border: 1px solid rgba(255, 255, 255, 0.35); + border-radius: 22px; + background: transparent; + color: #fff; + font-size: 15px; + cursor: pointer; +} + +.dt-esign-portrait-back:active { + background: rgba(255, 255, 255, 0.08); +} + +/* 横屏:e签宝全屏签名页 */ +.dt-esign-landscape { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; + background: #f5f6f8; +} + +.dt-esign-topbar { + flex: 0 0 auto; + display: grid; + grid-template-columns: 44px 1fr 44px; + align-items: center; + height: 48px; + padding: 0 4px; + background: #fff; + border-bottom: 1px solid var(--dt-border); +} + +.dt-esign-topbar-back { + width: 44px; + height: 44px; + border: none; + background: transparent; + font-size: 28px; + line-height: 1; + color: var(--dt-title); + cursor: pointer; +} + +.dt-esign-topbar-title { + margin: 0; + font-size: 17px; + font-weight: 600; + text-align: center; + color: var(--dt-title); +} + +.dt-esign-topbar-slot { + width: 44px; +} + +.dt-esign-signer { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px 16px; + font-size: 13px; + color: var(--dt-text-secondary); + background: #fff; +} + +.dt-esign-signer-dot { + opacity: 0.5; +} + +.dt-esign-canvas-area { + flex: 1; + min-height: 0; + margin: 8px; + display: flex; + flex-direction: column; +} + +.dt-signature-wrap--esign { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; +} + +.dt-signature-pad--esign { + flex: 1; + min-height: 0; + border: none; + border-radius: 4px; + background: #fff; + box-shadow: inset 0 0 0 1px #e8eaed; + touch-action: none; +} + +.dt-signature-pad--esign.dt-signature-pad--signed { + box-shadow: inset 0 0 0 1px var(--dt-primary); +} + +.dt-signature-watermark { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: clamp(28px, 8vw, 48px); + font-weight: 500; + color: rgba(134, 144, 156, 0.18); + pointer-events: none; + z-index: 1; + letter-spacing: 4px; + user-select: none; +} + +.dt-signature-baseline--esign { + left: 10%; + right: 10%; + bottom: 28%; + height: 1px; + background: rgba(134, 144, 156, 0.35); +} + +.dt-signature-canvas--esign { + position: relative; + z-index: 2; + width: 100%; + height: 100%; + min-height: 0; +} + +.dt-esign-toolbar { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px calc(10px + var(--dt-safe-bottom)); + background: #fff; + border-top: 1px solid var(--dt-border); +} + +.dt-esign-tool-btn { + flex: 0 0 auto; + height: 40px; + padding: 0 16px; + border: none; + border-radius: 8px; + background: #f2f3f5; + color: var(--dt-text); + font-size: 15px; + cursor: pointer; +} + +.dt-esign-tool-btn:active { + background: #e8eaed; +} + +.dt-esign-tool-done { + flex: 1; + height: 44px; + margin-left: auto; + border: none; + border-radius: 8px; + background: #c9cdd4; + color: #fff; + font-size: 16px; + font-weight: 600; + cursor: not-allowed; +} + +.dt-esign-tool-done--ready { + background: var(--dt-primary); + cursor: pointer; + box-shadow: 0 4px 12px rgba(122, 185, 41, 0.35); +} + +.dt-esign-tool-done--ready:active { + filter: brightness(0.95); +} diff --git a/src/prototypes/xll-miniapp/driver-training/utils/formatDateTime.js b/src/prototypes/xll-miniapp/driver-training/utils/formatDateTime.js new file mode 100644 index 0000000..cac4aed --- /dev/null +++ b/src/prototypes/xll-miniapp/driver-training/utils/formatDateTime.js @@ -0,0 +1,11 @@ +export function formatDateTime(iso) { + if (!iso) return '-'; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return '-'; + const pad = (n) => String(n).padStart(2, '0'); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; +} + +export function formatNow() { + return formatDateTime(new Date().toISOString()); +} diff --git a/src/prototypes/xll-miniapp/index.tsx b/src/prototypes/xll-miniapp/index.tsx index 256795d..d7ef071 100644 --- a/src/prototypes/xll-miniapp/index.tsx +++ b/src/prototypes/xll-miniapp/index.tsx @@ -11,6 +11,7 @@ import { type AnnotationViewerOptions, } from '@axhub/annotation'; import annotationSourceDocument from './annotation-source.json'; +import DriverTrainingModule from './driver-training/DriverTrainingModule'; import { defineHashPageRoute, useHashPage } from '../../common/useHashPage'; import './style.css'; @@ -86,10 +87,11 @@ const XLL_PAGE_ROUTE = defineHashPageRoute([ { id: 'delivery', title: '交车' }, { id: 'third-return', title: '三方退车' }, { id: 'replace', title: '替换车' }, + { id: 'driver-training', title: '司机安全培训' }, ], { defaultPageId: 'todo' }); const MAIN_TAB_PAGE_IDS = new Set(['todo', 'business', 'map', 'mine']); -const MODULE_PAGE_IDS = new Set(['audit', 'inspection', 'vehicle', 'delivery', 'third-return', 'replace']); +const MODULE_PAGE_IDS = new Set(['audit', 'inspection', 'vehicle', 'delivery', 'third-return', 'replace', 'driver-training']); const BIZ_ENTRY_PAGE_MAP = { audit: 'audit', @@ -98,6 +100,7 @@ const BIZ_ENTRY_PAGE_MAP = { delivery: 'delivery', thirdReturn: 'third-return', replace: 'replace', + training: 'driver-training', }; const MODULE_NAV_TITLES = { @@ -107,6 +110,7 @@ const MODULE_NAV_TITLES = { delivery: '交车', 'third-return': '三方退车', replace: '替换车', + 'driver-training': '司机安全培训', }; const ANNOTATION_ROUTE_TO_PAGE = { @@ -122,6 +126,8 @@ const ANNOTATION_ROUTE_TO_PAGE = { 'third-return': 'third-return', replace: 'replace', 'audit-return': 'audit-return', + training: 'driver-training', + 'driver-training': 'driver-training', }; const TASK_THEME = { @@ -12195,6 +12201,9 @@ export default function XiaoLingLingMiniApp() { if (page === 'replace') { return ; } + if (page === 'driver-training') { + return ; + } if (todoDetail) { return (