sync driver training module integration on V1.3.2 base
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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 <div className="dt-footer-stack">{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dt-footer-stack">
|
||||
<div className="dt-footer-row">
|
||||
<button type="button" className="dt-btn-prev" onClick={() => goToStep(currentStep - 1)}>
|
||||
上一步
|
||||
</button>
|
||||
<div className="dt-footer-main">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<>
|
||||
<DriverTrainingLayout
|
||||
currentStep={currentStep}
|
||||
totalSteps={TOTAL_STEPS}
|
||||
stepNames={stepNames}
|
||||
maxCompletedStep={maxCompletedStep}
|
||||
stepDetailHint={stepDetailHint}
|
||||
embedded
|
||||
immersive={isSignatureStep}
|
||||
footer={
|
||||
currentStep < TOTAL_STEPS && currentStep !== SIGNATURE_STEP ? (
|
||||
<StepFooterBar currentStep={currentStep} goToStep={goToStep}>
|
||||
<StepComponent {...stepProps} renderPart="footer" />
|
||||
</StepFooterBar>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<StepComponent {...stepProps} renderPart="content" />
|
||||
</DriverTrainingLayout>
|
||||
<PrototypeDebugPanel />
|
||||
<Toast message={message} onClose={clear} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DriverTrainingModule({ onRegisterBack }) {
|
||||
const registerBack = useCallback(
|
||||
(handler) => {
|
||||
if (typeof onRegisterBack === 'function') {
|
||||
onRegisterBack(handler);
|
||||
}
|
||||
},
|
||||
[onRegisterBack],
|
||||
);
|
||||
|
||||
return (
|
||||
<TrainingProvider>
|
||||
<div className="dt-module-root" data-annotation-id="page-driver-training">
|
||||
<DriverTrainingContent onRegisterBack={registerBack} />
|
||||
</div>
|
||||
</TrainingProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
|
||||
const SAMPLE_PORTRAIT =
|
||||
'data:image/svg+xml,' +
|
||||
encodeURIComponent(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="300" viewBox="0 0 240 300">
|
||||
<rect fill="#E8FFEA" width="240" height="300"/>
|
||||
<circle cx="120" cy="110" r="50" fill="#C9CDD4"/>
|
||||
<ellipse cx="120" cy="240" rx="70" ry="55" fill="#C9CDD4"/>
|
||||
<text x="120" y="290" text-anchor="middle" fill="#86909C" font-size="12">示例照片</text>
|
||||
</svg>`
|
||||
);
|
||||
|
||||
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 (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<img src={photoDataUrl} alt="本人照片" className="dt-portrait-preview" />
|
||||
<div className="dt-success-banner" style={{ marginTop: 12 }}>
|
||||
<span>✓</span>
|
||||
<span>本人照片已确认</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="dt-field-hint" style={{ marginBottom: 12 }}>
|
||||
请由司机本人使用前置摄像头拍摄正面照片,用于本次培训和现场交车核验。
|
||||
</p>
|
||||
<ul className="dt-camera-tips">
|
||||
<li>请正对摄像头</li>
|
||||
<li>保持光线充足</li>
|
||||
<li>请勿佩戴口罩或遮挡面部</li>
|
||||
</ul>
|
||||
|
||||
{!preview ? (
|
||||
<div className="dt-camera-container">
|
||||
<video ref={videoRef} className="dt-camera-video" playsInline muted />
|
||||
<div className="dt-camera-guide">
|
||||
<div className="dt-camera-oval" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<img src={preview} alt="预览" className="dt-portrait-preview" style={{ width: '100%', maxWidth: 280, height: 'auto' }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="dt-field-error">{error}</div>}
|
||||
|
||||
<div style={{ display: 'flex', gap: 10, marginTop: 16 }}>
|
||||
{!preview ? (
|
||||
<button type="button" className="dt-btn-primary" onClick={handleCapture} disabled={permissionDenied}>
|
||||
拍摄
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<button type="button" className="dt-btn-outline" onClick={handleRetake}>
|
||||
重新拍摄
|
||||
</button>
|
||||
<button type="button" className="dt-btn-primary" style={{ flex: 1 }} onClick={() => onConfirm?.()}>
|
||||
确认使用
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showDevFallback && permissionDenied && (
|
||||
<button type="button" className="dt-btn-secondary" style={{ marginTop: 10 }} onClick={handleUseSample}>
|
||||
使用示例照片(开发模式)
|
||||
</button>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
visible={showPermissionDialog}
|
||||
title="摄像头权限"
|
||||
content="无法调用摄像头,请在浏览器设置中允许摄像头权限后重试。"
|
||||
cancelText="关闭"
|
||||
confirmText="重试"
|
||||
onCancel={() => setShowPermissionDialog(false)}
|
||||
onConfirm={() => {
|
||||
setShowPermissionDialog(false);
|
||||
startCamera();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={`dt-cert-card ${isDone ? 'done' : ''} ${isFailed ? 'error' : ''}`}
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onClick?.()}
|
||||
>
|
||||
<div className="dt-cert-card-header">
|
||||
<span className="dt-cert-card-title">{label}</span>
|
||||
{required ? (
|
||||
<span className="dt-cert-badge">必填</span>
|
||||
) : (
|
||||
<span className="dt-cert-badge optional">选填</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="dt-cert-status">{STATUS_TEXT[status] || '待上传'}</div>
|
||||
{imageUrl && (
|
||||
<div className="dt-cert-preview">
|
||||
<img src={imageUrl} alt={label} className="dt-cert-thumb" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="dt-modal-overlay" onClick={onCancel}>
|
||||
<div className="dt-modal dt-modal--summary" onClick={(e) => e.stopPropagation()}>
|
||||
<h3 className="dt-modal-title">{title}</h3>
|
||||
{summary ? <div className="dt-modal-summary">{summary}</div> : null}
|
||||
{note ? <p className="dt-modal-note">{note}</p> : null}
|
||||
{content ? <p className="dt-modal-content">{content}</p> : null}
|
||||
<div className="dt-modal-actions">
|
||||
<button type="button" className="dt-btn-secondary" onClick={onCancel}>
|
||||
{cancelText}
|
||||
</button>
|
||||
<button type="button" className="dt-btn-primary" onClick={onConfirm}>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="dt-phase-bar" role="list" aria-label="培训阶段">
|
||||
{TRAINING_PHASES.map((phase, index) => {
|
||||
const status = getPhaseStatus(phase, currentStep);
|
||||
return (
|
||||
<React.Fragment key={phase.id}>
|
||||
{index > 0 && (
|
||||
<div
|
||||
className={`dt-phase-connector ${status === 'done' || status === 'active' ? 'done' : ''}`}
|
||||
/>
|
||||
)}
|
||||
<div className={`dt-phase-item dt-phase-item--${status}`} role="listitem">
|
||||
<span className="dt-phase-icon">
|
||||
{status === 'done' ? '✓' : index + 1}
|
||||
</span>
|
||||
<span className="dt-phase-label">{phase.label}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TrainingStepHeader({
|
||||
currentStep,
|
||||
totalSteps = 8,
|
||||
stepNames,
|
||||
stepDetailHint = '',
|
||||
}) {
|
||||
const isComplete = currentStep >= totalSteps;
|
||||
const progressPercent = (currentStep / totalSteps) * 100;
|
||||
|
||||
return (
|
||||
<div className={`dt-step-header ${isComplete ? 'dt-step-header--complete' : ''}`}>
|
||||
<div className="dt-step-indicator">
|
||||
<span className="dt-step-text">{isComplete ? '培训已完成' : '培训进度'}</span>
|
||||
<span className="dt-step-count">
|
||||
步骤 {currentStep}/{totalSteps}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<TrainingPhaseBar currentStep={currentStep} />
|
||||
|
||||
<div className="dt-progress-bar" aria-hidden="true">
|
||||
<div
|
||||
className="dt-progress-fill"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="dt-step-meta">
|
||||
<span className="dt-step-name">{stepNames[currentStep - 1]}</span>
|
||||
{stepDetailHint ? (
|
||||
<span className="dt-step-detail-hint">{stepDetailHint}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DriverTrainingLayout({
|
||||
currentStep,
|
||||
totalSteps = 8,
|
||||
stepNames,
|
||||
stepDetailHint,
|
||||
children,
|
||||
footer,
|
||||
embedded = false,
|
||||
immersive = false,
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`driver-training-app${embedded ? ' driver-training-app--embedded' : ''}${immersive ? ' driver-training-app--signature-immersive' : ''}`}
|
||||
>
|
||||
<div className="dt-page">
|
||||
{!embedded && (
|
||||
<header className="dt-header">
|
||||
<div className="dt-header-bar">
|
||||
<h1 className="dt-header-title">司机安全培训</h1>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
<div className={`dt-content${immersive ? ' dt-content--signature-immersive' : ''}`}>
|
||||
{!immersive ? (
|
||||
<TrainingStepHeader
|
||||
currentStep={currentStep}
|
||||
totalSteps={totalSteps}
|
||||
stepNames={stepNames}
|
||||
stepDetailHint={stepDetailHint}
|
||||
/>
|
||||
) : null}
|
||||
{children}
|
||||
</div>
|
||||
{footer && <div className={`dt-footer${immersive ? ' dt-footer--signature-immersive' : ''}`}>{footer}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="dt-ocr-form">
|
||||
{fields.map((f) => (
|
||||
<div className="dt-form-group" key={f.key}>
|
||||
<label className="dt-label">
|
||||
{f.label}
|
||||
{f.required && <span style={{ color: '#F53F3F' }}> *</span>}
|
||||
</label>
|
||||
{f.inputType === 'select' ? (
|
||||
<select
|
||||
className={`dt-input dt-select ${errors[f.key] ? 'error' : ''}`}
|
||||
value={data[f.key] || ''}
|
||||
onChange={(e) => onChange(f.key, e.target.value)}
|
||||
>
|
||||
<option value="">请选择{f.label}</option>
|
||||
{(f.options || []).map((code) => (
|
||||
<option key={code} value={code}>
|
||||
{code}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className={`dt-input ${errors[f.key] ? 'error' : ''}`}
|
||||
value={data[f.key] || ''}
|
||||
onChange={(e) => onChange(f.key, e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{errors[f.key] && <div className="dt-field-error">{errors[f.key]}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ManualCertificateForm({ certType, data, onChange, errors = {} }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="dt-manual-tip">请根据证件原件准确填写,相关信息将用于本次交车核验。</div>
|
||||
<OCRResultForm certType={certType} data={data} onChange={onChange} errors={errors} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="dt-success-banner">
|
||||
<span>✓</span>
|
||||
<span>手机号验证成功</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="dt-form-group">
|
||||
<label className="dt-label">手机号</label>
|
||||
<input
|
||||
className={`dt-input ${phoneError ? 'error' : ''}`}
|
||||
type="tel"
|
||||
maxLength={11}
|
||||
placeholder="请输入手机号"
|
||||
value={phone}
|
||||
onChange={(e) => {
|
||||
patch({ phone: e.target.value.replace(/\D/g, '') });
|
||||
setPhoneError('');
|
||||
}}
|
||||
/>
|
||||
{phoneError && <div className="dt-field-error">{phoneError}</div>}
|
||||
</div>
|
||||
<div className="dt-form-group">
|
||||
<label className="dt-label">验证码</label>
|
||||
<div className="dt-code-row">
|
||||
<input
|
||||
className={`dt-input ${codeError ? 'error' : ''}`}
|
||||
type="text"
|
||||
maxLength={6}
|
||||
placeholder="请输入验证码"
|
||||
value={code}
|
||||
onChange={(e) => {
|
||||
patch({ code: e.target.value.replace(/\D/g, '') });
|
||||
setCodeError('');
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="dt-code-btn"
|
||||
disabled={countdown > 0 || sending}
|
||||
onClick={handleSendCode}
|
||||
>
|
||||
{sending ? '发送中...' : countdown > 0 ? `${countdown}s` : '获取验证码'}
|
||||
</button>
|
||||
</div>
|
||||
{codeError && <div className="dt-field-error">{codeError}</div>}
|
||||
</div>
|
||||
<p className="dt-field-hint">
|
||||
手机号用于绑定本次培训记录和接收培训状态通知。
|
||||
<br />
|
||||
演示环境已预填手机号与验证码({MOCK_SMS_CODE}),可直接验证继续。
|
||||
</p>
|
||||
{showActions && (
|
||||
<button type="button" className="dt-btn-primary" style={{ marginTop: 16 }} onClick={handleVerify}>
|
||||
验证并继续
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="dt-pickup-page">
|
||||
<div className="dt-pickup-success dt-animate-fade-in">
|
||||
<div className="dt-pickup-icon">✓</div>
|
||||
<h2 className="dt-pickup-title">安全培训已完成</h2>
|
||||
<p className="dt-pickup-subtitle">请向运维人员出示下方提车码</p>
|
||||
</div>
|
||||
|
||||
<div className="dt-card dt-qr-card dt-animate-scale-in">
|
||||
<div className="dt-qr-card-header">
|
||||
<StatusTag status={step7.status} />
|
||||
{hint ? <p className="dt-qr-status-hint">{hint}</p> : null}
|
||||
</div>
|
||||
<div className="dt-qr-frame">
|
||||
<canvas ref={canvasRef} className={`dt-qr-code ${isDisabled ? 'disabled' : ''}`} />
|
||||
{isDisabled && <div className="dt-qr-overlay">已失效</div>}
|
||||
</div>
|
||||
|
||||
<div className="dt-warning-tip">提车码仅限本次交车使用,请勿转发给他人。</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="dt-debug-panel">
|
||||
{open && (
|
||||
<div className="dt-debug-body">
|
||||
<div className="dt-debug-section">
|
||||
<label>车辆吨位</label>
|
||||
<select
|
||||
value={debug.vehicleTonnage}
|
||||
onChange={(e) => applyDebugSettings({ vehicleTonnage: Number(e.target.value) })}
|
||||
>
|
||||
<option value={4.5}>4.5 吨</option>
|
||||
<option value={18}>18 吨</option>
|
||||
<option value={49}>49 吨</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="dt-debug-section">
|
||||
<label>OCR 结果</label>
|
||||
<select
|
||||
value={debug.ocrMode}
|
||||
onChange={(e) => applyDebugSettings({ ocrMode: e.target.value })}
|
||||
>
|
||||
<option value="success">识别成功</option>
|
||||
<option value="fail">识别失败</option>
|
||||
<option value="mismatch">字段不一致</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="dt-debug-section">
|
||||
<label>摄像头权限</label>
|
||||
<select
|
||||
value={debug.cameraPermission}
|
||||
onChange={(e) => applyDebugSettings({ cameraPermission: e.target.value })}
|
||||
>
|
||||
<option value="allow">允许</option>
|
||||
<option value="deny">拒绝</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="dt-debug-section">
|
||||
<label>视频状态</label>
|
||||
<select
|
||||
value={debug.videoStatus}
|
||||
onChange={(e) => applyDebugSettings({ videoStatus: e.target.value })}
|
||||
>
|
||||
<option value="incomplete">未完成</option>
|
||||
<option value="completed">已完成</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="dt-debug-section">
|
||||
<label>提车状态</label>
|
||||
<select
|
||||
value={debug.pickupStatus}
|
||||
onChange={(e) => applyDebugSettings({ pickupStatus: e.target.value })}
|
||||
>
|
||||
<option value="pending">待核验</option>
|
||||
<option value="locked">已锁定</option>
|
||||
<option value="single_sign">单方签字</option>
|
||||
<option value="both_signed">双方签字完成</option>
|
||||
<option value="revoked">任务撤销</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" className="dt-debug-reset" onClick={resetAll}>
|
||||
重置全部培训数据
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="dt-debug-toggle" onClick={() => setOpen(!open)} title="原型控制台">
|
||||
{open ? '×' : '调试'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div
|
||||
className={`dt-signature-wrap ${isEsign ? 'dt-signature-wrap--esign' : ''} ${isFullscreen && !isEsign ? 'dt-signature-wrap--fullscreen' : ''}`}
|
||||
>
|
||||
<div
|
||||
ref={wrapRef}
|
||||
className={`dt-signature-pad ${hasStroke ? 'dt-signature-pad--signed' : ''} ${isEsign ? 'dt-signature-pad--esign' : ''} ${isFullscreen && !isEsign ? 'dt-signature-pad--fullscreen' : ''}`}
|
||||
>
|
||||
{!hasStroke && !isEsign && (
|
||||
<span className="dt-signature-placeholder" aria-hidden="true">
|
||||
{placeholderText}
|
||||
</span>
|
||||
)}
|
||||
{isEsign && !hasStroke && (
|
||||
<span className="dt-signature-watermark" aria-hidden="true">
|
||||
{watermark || '请在此处签名'}
|
||||
</span>
|
||||
)}
|
||||
<div className={`dt-signature-baseline ${isEsign ? 'dt-signature-baseline--esign' : ''}`} aria-hidden="true" />
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className={`dt-signature-canvas ${isEsign ? 'dt-signature-canvas--esign' : ''} ${isFullscreen && !isEsign ? 'dt-signature-canvas--fullscreen' : ''}`}
|
||||
onMouseDown={startDraw}
|
||||
onMouseMove={draw}
|
||||
onMouseUp={endDraw}
|
||||
onMouseLeave={endDraw}
|
||||
onTouchStart={startDraw}
|
||||
onTouchMove={draw}
|
||||
onTouchEnd={endDraw}
|
||||
/>
|
||||
</div>
|
||||
{!hideActions && (
|
||||
<div className={`dt-signature-actions ${isFullscreen ? 'dt-signature-actions--fullscreen' : ''}`}>
|
||||
<button type="button" className="dt-btn-secondary dt-signature-clear" onClick={handleClear}>
|
||||
清除重签
|
||||
</button>
|
||||
{hasStroke && (
|
||||
<span className="dt-signature-done-badge">
|
||||
<span aria-hidden="true">✓</span> 已签名
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default SignatureCanvas;
|
||||
@@ -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 <span className={`dt-status-tag ${cls}`}>{label}</span>;
|
||||
}
|
||||
@@ -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 <div className="dt-toast">{message}</div>;
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
const [message, setMessage] = useState('');
|
||||
const show = (msg) => setMessage(msg);
|
||||
const clear = () => setMessage('');
|
||||
return { message, show, clear };
|
||||
}
|
||||
@@ -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 (
|
||||
<div ref={ref} className="dt-document-viewer" onScroll={handleScroll}>
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
const DURATION = 30;
|
||||
|
||||
function IconPlay() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconPause() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="dt-video-player">
|
||||
<div className="dt-video-screen">
|
||||
<div className="dt-video-overlay">
|
||||
<div style={{ fontSize: 18, fontWeight: 600 }}>氢能重卡安全驾驶培训</div>
|
||||
<div style={{ fontSize: 13, opacity: 0.8 }}>预计时长 30 秒</div>
|
||||
{!playing && !completed && (
|
||||
<button type="button" className="dt-video-play-btn" onClick={togglePlay} style={{ width: 56, height: 56, fontSize: 24 }}>
|
||||
<IconPlay />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="dt-video-controls">
|
||||
<button type="button" className="dt-video-play-btn" onClick={togglePlay} disabled={completed}>
|
||||
{playing ? <IconPause /> : <IconPlay />}
|
||||
</button>
|
||||
<div className="dt-video-progress">
|
||||
<div className="dt-video-progress-fill" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<span style={{ color: '#fff', fontSize: 12, flexShrink: 0 }}>
|
||||
{formatTime(current)}/{formatTime(DURATION)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="dt-sheet-overlay" onClick={onCancel}>
|
||||
<div className="dt-sheet" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="dt-sheet-title">上传{label}</div>
|
||||
<p className="dt-sheet-desc">请选择获取证件照片的方式</p>
|
||||
<button type="button" className="dt-sheet-option" onClick={onCamera}>
|
||||
<span className="dt-sheet-option-icon" aria-hidden>📷</span>
|
||||
<span className="dt-sheet-option-text">
|
||||
<strong>拍照上传</strong>
|
||||
<small>调用摄像头拍摄证件</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="dt-sheet-option" onClick={onAlbum}>
|
||||
<span className="dt-sheet-option-icon" aria-hidden>🖼</span>
|
||||
<span className="dt-sheet-option-text">
|
||||
<strong>从相册选择</strong>
|
||||
<small>选择本地已保存的证件照片</small>
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="dt-sheet-cancel" onClick={onCancel}>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <TrainingContext.Provider value={value}>{children}</TrainingContext.Provider>;
|
||||
}
|
||||
|
||||
export function useTraining() {
|
||||
const ctx = useContext(TrainingContext);
|
||||
if (!ctx) throw new Error('useTraining must be used within TrainingProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export { createInitialState };
|
||||
146
src/prototypes/xll-miniapp/driver-training/data/constants.js
vendored
Normal file
146
src/prototypes/xll-miniapp/driver-training/data/constants.js
vendored
Normal file
@@ -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'];
|
||||
106
src/prototypes/xll-miniapp/driver-training/data/storage.js
vendored
Normal file
106
src/prototypes/xll-miniapp/driver-training/data/storage.js
vendored
Normal file
@@ -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));
|
||||
}
|
||||
@@ -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 = (
|
||||
<div className="dt-card">
|
||||
<h2 className="dt-card-title">安全培训须知</h2>
|
||||
<TrainingDocumentViewer
|
||||
content={TRAINING_DOCUMENT}
|
||||
onProgress={(p) => {
|
||||
updateState((prev) => ({ ...prev, step1: { ...prev.step1, readProgress: p } }));
|
||||
}}
|
||||
onReachBottom={handleReachBottom}
|
||||
onStart={handleStart}
|
||||
/>
|
||||
<div className="dt-read-progress">
|
||||
<div className="dt-read-progress-bar">
|
||||
<div className="dt-read-progress-fill" style={{ width: `${readProgress}%` }} />
|
||||
</div>
|
||||
<span>已阅读 {readProgress}%</span>
|
||||
</div>
|
||||
<label className="dt-checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={!scrolledToBottom}
|
||||
onChange={(e) =>
|
||||
updateState((prev) => ({
|
||||
...prev,
|
||||
step1: { ...prev.step1, checked: e.target.checked },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<span>本人已完整阅读并知晓以上内容</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<div className="dt-footer-hint">
|
||||
{!scrolledToBottom ? '请完整阅读培训文档后继续。' : !checked ? '请勾选确认后继续。' : ''}
|
||||
</div>
|
||||
<button type="button" className="dt-btn-primary" disabled={!canProceed} onClick={handleNext}>
|
||||
我已知晓,进入下一步
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
if (renderPart === 'content') return content;
|
||||
if (renderPart === 'footer') return footer;
|
||||
return (
|
||||
<>
|
||||
{content}
|
||||
{footer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
105
src/prototypes/xll-miniapp/driver-training/steps/Step2Phone.jsx
Normal file
105
src/prototypes/xll-miniapp/driver-training/steps/Step2Phone.jsx
Normal file
@@ -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 = (
|
||||
<div className="dt-card">
|
||||
<div className="dt-success-banner">
|
||||
<span>✓</span>
|
||||
<span>手机号 {state.step2.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')} 已验证</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const footer = (
|
||||
<button type="button" className="dt-btn-primary" onClick={() => completeStep(2)}>
|
||||
进入下一步
|
||||
</button>
|
||||
);
|
||||
if (renderPart === 'content') return content;
|
||||
if (renderPart === 'footer') return footer;
|
||||
return (
|
||||
<>
|
||||
{content}
|
||||
{footer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const content = (
|
||||
<div className="dt-card">
|
||||
<h2 className="dt-card-title">手机号验证</h2>
|
||||
<PhoneVerificationForm
|
||||
value={state.step2}
|
||||
showActions={false}
|
||||
onChange={(data) => updateState((prev) => ({ ...prev, step2: { ...prev.step2, ...data } }))}
|
||||
/>
|
||||
{errors.phoneError && <div className="dt-field-error">{errors.phoneError}</div>}
|
||||
{errors.codeError && <div className="dt-field-error">{errors.codeError}</div>}
|
||||
</div>
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
<button type="button" className="dt-btn-primary" onClick={handleVerify}>
|
||||
验证并继续
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="dt-btn-secondary"
|
||||
style={{ marginTop: 8 }}
|
||||
onClick={handleDemoSkip}
|
||||
>
|
||||
演示:跳过验证
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
if (renderPart === 'content') return content;
|
||||
if (renderPart === 'footer') return footer;
|
||||
return (
|
||||
<>
|
||||
{content}
|
||||
{footer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 = (
|
||||
<>
|
||||
<UploadSourceSheet
|
||||
visible={!!uploadSheetCert}
|
||||
certType={uploadSheetCert}
|
||||
onCamera={pickCamera}
|
||||
onAlbum={pickAlbum}
|
||||
onCancel={() => setUploadSheetCert(null)}
|
||||
/>
|
||||
{modal?.type === 'ocr_fail' && (
|
||||
<div className="dt-modal-overlay" onClick={() => setModal(null)}>
|
||||
<div className="dt-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="dt-error-card" style={{ margin: 0 }}>
|
||||
<p>证件信息识别失败,可以重新拍摄或手动填写。</p>
|
||||
<div className="dt-error-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="dt-btn-outline"
|
||||
onClick={() => {
|
||||
setModal(null);
|
||||
showUploadOptions(modal.certType);
|
||||
}}
|
||||
>
|
||||
重新上传
|
||||
</button>
|
||||
<button type="button" className="dt-btn-primary" style={{ flex: 1 }} onClick={startManual}>
|
||||
手动填写
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{(modal?.type === 'confirm' || modal?.type === 'manual' || modal?.type === 'view') && (
|
||||
<div className="dt-modal-overlay" onClick={() => setModal(null)}>
|
||||
<div className="dt-modal" onClick={(e) => e.stopPropagation()} style={{ maxHeight: '80vh', overflow: 'auto' }}>
|
||||
<h3 className="dt-modal-title">
|
||||
{modal.type === 'view' ? '查看证照' : modal.type === 'manual' ? '手动填写' : '确认识别结果'}
|
||||
</h3>
|
||||
{modal.imageUrl && <img src={modal.imageUrl} alt="" style={{ width: '100%', borderRadius: 8, marginBottom: 12 }} />}
|
||||
{(modal.type === 'confirm' || modal.type === 'manual') && OCR_CERT_TYPES.includes(modal.certType) && (
|
||||
<ManualCertificateForm
|
||||
certType={modal.certType}
|
||||
data={formData}
|
||||
onChange={(key, val) => setFormData((d) => ({ ...d, [key]: val }))}
|
||||
errors={formErrors}
|
||||
/>
|
||||
)}
|
||||
{modal.type === 'view' && modal.fields && Object.keys(modal.fields).length > 0 && (
|
||||
<div className="dt-info-list">
|
||||
{Object.entries(modal.fields)
|
||||
.filter(([k]) => k !== 'fileNumber')
|
||||
.map(([k, v]) => (
|
||||
<div className="dt-info-item" key={k}>
|
||||
<span className="label">{getCertFieldLabel(k)}</span>
|
||||
<span>{v}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="dt-modal-actions" style={{ marginTop: 16 }}>
|
||||
<button type="button" className="dt-btn-secondary" onClick={() => setModal(null)}>关闭</button>
|
||||
{modal.type !== 'view' && (
|
||||
<button type="button" className="dt-btn-primary" onClick={confirmCert}>确认</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<>
|
||||
<input
|
||||
ref={cameraRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFile}
|
||||
/>
|
||||
<input
|
||||
ref={albumRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFile}
|
||||
/>
|
||||
<div className="dt-card">
|
||||
<h2 className="dt-card-title">司机证照</h2>
|
||||
<p className="dt-field-hint" style={{ marginBottom: 12 }}>
|
||||
{isQualificationRequired(tonnage)
|
||||
? `当前车辆为 ${tonnage} 吨,从业资格证为必填项。`
|
||||
: `当前车辆为 ${tonnage} 吨,从业资格证为选填项。`}
|
||||
点击证件卡片可选择拍照或从相册上传。
|
||||
</p>
|
||||
{allCertTypes.map((t) => (
|
||||
<CertificateUploadCard
|
||||
key={t}
|
||||
certType={t}
|
||||
required={requiredTypes.includes(t)}
|
||||
status={certs[t]?.status || 'pending'}
|
||||
imageUrl={certs[t]?.imageUrl}
|
||||
onClick={() => openCert(t)}
|
||||
/>
|
||||
))}
|
||||
{allDone && (
|
||||
<div className="dt-success-banner">
|
||||
<span>✓</span>
|
||||
<span>证照信息已完成</span>
|
||||
</div>
|
||||
)}
|
||||
{validationError && <div className="dt-field-error" style={{ marginTop: 8 }}>{validationError}</div>}
|
||||
</div>
|
||||
{modals}
|
||||
</>
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
{!allDone && (
|
||||
<div className="dt-footer-hint">
|
||||
请完成全部必填证照上传({requiredDoneCount}/{requiredTypes.length})
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="dt-btn-primary" disabled={!allDone} onClick={() => validateAll() && completeStep(3)}>
|
||||
下一步
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
if (renderPart === 'content') return content;
|
||||
if (renderPart === 'footer') return footer;
|
||||
return (
|
||||
<>
|
||||
{content}
|
||||
{footer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 = (
|
||||
<div className="dt-card">
|
||||
<h2 className="dt-card-title">安全培训视频</h2>
|
||||
<p className="dt-field-hint" style={{ marginBottom: 12 }}>
|
||||
氢能重卡安全驾驶培训 · 预计时长 30 秒
|
||||
</p>
|
||||
<TrainingVideoPlayer
|
||||
completed={completed}
|
||||
watchedSeconds={step4.watchedSeconds}
|
||||
onProgress={(s) =>
|
||||
updateState((prev) => ({ ...prev, step4: { ...prev.step4, watchedSeconds: s } }))
|
||||
}
|
||||
onComplete={handleComplete}
|
||||
debugComplete={debug.videoStatus === 'completed' && !completed}
|
||||
/>
|
||||
{completed && (
|
||||
<div className="dt-success-banner" style={{ marginTop: 12 }}>
|
||||
<span>✓</span>
|
||||
<span>视频观看完成</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
{!completed && <div className="dt-footer-hint">请完整观看安全培训视频</div>}
|
||||
<button type="button" className="dt-btn-primary" disabled={!completed} onClick={() => completeStep(4)}>
|
||||
下一步
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
if (renderPart === 'content') return content;
|
||||
if (renderPart === 'footer') return footer;
|
||||
return (
|
||||
<>
|
||||
{content}
|
||||
{footer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 = (
|
||||
<div className="dt-card">
|
||||
<h2 className="dt-card-title">本人照片</h2>
|
||||
<CameraCapture
|
||||
photoDataUrl={step5.photoDataUrl}
|
||||
confirmed={step5.confirmed}
|
||||
cameraPermission={debug.cameraPermission}
|
||||
showDevFallback
|
||||
onCapture={(url) =>
|
||||
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 },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
{!step5.confirmed && (
|
||||
<div className="dt-footer-hint">请拍照并确认使用本人照片</div>
|
||||
)}
|
||||
<button type="button" className="dt-btn-primary" disabled={!step5.confirmed} onClick={() => completeStep(5)}>
|
||||
下一步
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
if (renderPart === 'content') return content;
|
||||
if (renderPart === 'footer') return footer;
|
||||
return (
|
||||
<>
|
||||
{content}
|
||||
{footer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 = (
|
||||
<div className="dt-card dt-step6-card">
|
||||
<h2 className="dt-card-title">签字确认</h2>
|
||||
|
||||
<section className="dt-step6-section">
|
||||
<h3 className="dt-step6-section-title">① 阅读声明</h3>
|
||||
<div className="dt-step6-declaration">{DECLARATION}</div>
|
||||
</section>
|
||||
|
||||
<section className="dt-step6-section">
|
||||
<h3 className="dt-step6-section-title">② 核对信息</h3>
|
||||
<div className="dt-info-list dt-step6-info">
|
||||
<div className="dt-info-item dt-info-item--highlight">
|
||||
<span className="label">司机姓名</span>
|
||||
<span>{driverName}</span>
|
||||
</div>
|
||||
<div className="dt-info-item dt-info-item--highlight">
|
||||
<span className="label">当前车辆</span>
|
||||
<span>{trainingTask.vehiclePlate}</span>
|
||||
</div>
|
||||
<div className="dt-info-item">
|
||||
<span className="label">身份证后四位</span>
|
||||
<span>{idLast4}</span>
|
||||
</div>
|
||||
<div className="dt-info-item">
|
||||
<span className="label">驾驶证准驾车型</span>
|
||||
<span>{vehicleClass}</span>
|
||||
</div>
|
||||
<div className="dt-info-item">
|
||||
<span className="label">培训完成时间</span>
|
||||
<span>{formatNow()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<label className="dt-checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
updateState((prev) => ({ ...prev, step6: { ...prev.step6, checked: e.target.checked } }));
|
||||
}}
|
||||
/>
|
||||
<span>本人确认以上信息及签名真实有效。</span>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
|
||||
const footer = (
|
||||
<>
|
||||
{!checked ? <div className="dt-footer-hint">请勾选「本人确认以上信息及签名真实有效」</div> : null}
|
||||
<button
|
||||
type="button"
|
||||
className={`dt-btn-primary ${checked ? 'dt-btn-primary--ready' : ''}`}
|
||||
disabled={!checked}
|
||||
onClick={() => completeStep(6)}
|
||||
>
|
||||
下一步:手写签名
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
|
||||
if (renderPart === 'content') return content;
|
||||
if (renderPart === 'footer') return footer;
|
||||
return (
|
||||
<>
|
||||
{content}
|
||||
{footer}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 = <PickupCodeCard step7={step7} />;
|
||||
|
||||
if (renderPart === 'footer') return null;
|
||||
return content;
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="dt-card">
|
||||
<div className="dt-success-banner">
|
||||
<span>✓</span>
|
||||
<span>培训记录已提交</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (renderPart === 'footer') return null;
|
||||
|
||||
const dialogSummary = (
|
||||
<>
|
||||
<div className="dt-modal-summary-row">
|
||||
<span className="label">司机</span>
|
||||
<span>{driverName}</span>
|
||||
</div>
|
||||
<div className="dt-modal-summary-row">
|
||||
<span className="label">车辆</span>
|
||||
<span>{trainingTask.vehiclePlate}</span>
|
||||
</div>
|
||||
<div className="dt-modal-summary-row">
|
||||
<span className="label">培训完成时间</span>
|
||||
<span>{formatNow()}</span>
|
||||
</div>
|
||||
{hasSignature && (
|
||||
<div className="dt-modal-signature-preview">
|
||||
<span className="label">签名预览</span>
|
||||
<img src={step6.signatureDataUrl} alt="签名预览" />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const submitDialog = (
|
||||
<ConfirmDialog
|
||||
visible={showSubmitDialog}
|
||||
title="确认提交培训并生成提车码"
|
||||
note="提交后不可修改,提车码将出示给运维扫码。"
|
||||
summary={dialogSummary}
|
||||
cancelText="返回检查"
|
||||
confirmText="确认提交"
|
||||
onCancel={() => setShowSubmitDialog(false)}
|
||||
onConfirm={handleSubmit}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
className={`dt-esign-root ${isLandscape ? 'dt-esign-root--landscape' : 'dt-esign-root--portrait-preview'}`}
|
||||
>
|
||||
{!isLandscape && (
|
||||
<div className="dt-esign-preview-banner">
|
||||
<div className="dt-esign-preview-banner-icon" aria-hidden="true">
|
||||
↻
|
||||
</div>
|
||||
<div className="dt-esign-preview-banner-text">
|
||||
<strong>横屏签字预览</strong>
|
||||
<span>正式使用时请旋转手机至横屏;下方为横屏全屏签名页效果,可直接体验签字并完成提交。</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="dt-esign-landscape">
|
||||
<header className="dt-esign-topbar">
|
||||
<button type="button" className="dt-esign-topbar-back" onClick={() => goToStep(6)} aria-label="返回">
|
||||
‹
|
||||
</button>
|
||||
<h1 className="dt-esign-topbar-title">手写签名</h1>
|
||||
<span className="dt-esign-topbar-slot" />
|
||||
</header>
|
||||
|
||||
<div className="dt-esign-signer">
|
||||
<span>{driverName}</span>
|
||||
<span className="dt-esign-signer-dot">·</span>
|
||||
<span>{trainingTask.vehiclePlate}</span>
|
||||
</div>
|
||||
|
||||
<div className="dt-esign-canvas-area">
|
||||
<SignatureCanvas
|
||||
ref={canvasRef}
|
||||
variant="esign"
|
||||
hideActions
|
||||
watermark="请在此处签名"
|
||||
value={step6.signatureDataUrl}
|
||||
onChange={(url) =>
|
||||
updateState((prev) => ({ ...prev, step6: { ...prev.step6, signatureDataUrl: url } }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<footer className="dt-esign-toolbar">
|
||||
<button type="button" className="dt-esign-tool-btn" onClick={() => canvasRef.current?.clear()}>
|
||||
清除重写
|
||||
</button>
|
||||
<button type="button" className="dt-esign-tool-btn" onClick={() => goToStep(6)}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`dt-esign-tool-done ${hasSignature ? 'dt-esign-tool-done--ready' : ''}`}
|
||||
disabled={!hasSignature}
|
||||
onClick={() => setShowSubmitDialog(true)}
|
||||
>
|
||||
完成
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
{submitDialog}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
11
src/prototypes/xll-miniapp/driver-training/utils/formatDateTime.js
vendored
Normal file
11
src/prototypes/xll-miniapp/driver-training/utils/formatDateTime.js
vendored
Normal file
@@ -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());
|
||||
}
|
||||
@@ -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 <ReplaceModule onRegisterBack={registerModuleBack} />;
|
||||
}
|
||||
if (page === 'driver-training') {
|
||||
return <DriverTrainingModule onRegisterBack={registerModuleBack} />;
|
||||
}
|
||||
if (todoDetail) {
|
||||
return (
|
||||
<TodoDetailPage
|
||||
|
||||
Reference in New Issue
Block a user