sync driver training module integration on V1.3.2 base

This commit is contained in:
王冕
2026-07-21 17:47:42 +08:00
parent f86b1ab269
commit ad8fbfae1b
31 changed files with 4677 additions and 13 deletions

View File

@@ -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",

View File

@@ -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": [

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 };
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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;

View File

@@ -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>;
}

View File

@@ -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 };
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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 };

View 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'];

View 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));
}

View File

@@ -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}
</>
);
}

View 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}
</>
);
}

View File

@@ -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}
</>
);
}

View File

@@ -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}
</>
);
}

View File

@@ -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}
</>
);
}

View File

@@ -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}
</>
);
}

View File

@@ -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;
}

View File

@@ -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

View 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());
}

View File

@@ -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