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

+
+ ✓
+ 本人照片已确认
+
+
+ );
+ }
+
+ return (
+
+
+ 请由司机本人使用前置摄像头拍摄正面照片,用于本次培训和现场交车核验。
+
+
+ - 请正对摄像头
+ - 保持光线充足
+ - 请勿佩戴口罩或遮挡面部
+
+
+ {!preview ? (
+
+ ) : (
+
+

+
+ )}
+
+ {error &&
{error}
}
+
+
+ {!preview ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+
+ {showDevFallback && permissionDenied && (
+
+ )}
+
+
setShowPermissionDialog(false)}
+ onConfirm={() => {
+ setShowPermissionDialog(false);
+ startCamera();
+ }}
+ />
+
+ );
+}
diff --git a/src/prototypes/xll-miniapp/driver-training/components/CertificateUploadCard.jsx b/src/prototypes/xll-miniapp/driver-training/components/CertificateUploadCard.jsx
new file mode 100644
index 0000000..0dbd5b9
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/components/CertificateUploadCard.jsx
@@ -0,0 +1,42 @@
+import React from 'react';
+import { CERT_LABELS } from '../data/constants';
+
+const STATUS_TEXT = {
+ pending: '待上传',
+ uploading: '上传中',
+ recognizing: '识别中',
+ confirm: '待确认',
+ done: '已完成',
+ failed: '识别失败',
+};
+
+export default function CertificateUploadCard({ certType, required, status, onClick, imageUrl }) {
+ const label = CERT_LABELS[certType];
+ const isDone = status === 'done';
+ const isFailed = status === 'failed';
+
+ return (
+ e.key === 'Enter' && onClick?.()}
+ >
+
+ {label}
+ {required ? (
+ 必填
+ ) : (
+ 选填
+ )}
+
+
{STATUS_TEXT[status] || '待上传'}
+ {imageUrl && (
+
+

+
+ )}
+
+ );
+}
diff --git a/src/prototypes/xll-miniapp/driver-training/components/ConfirmDialog.jsx b/src/prototypes/xll-miniapp/driver-training/components/ConfirmDialog.jsx
new file mode 100644
index 0000000..d31df4d
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/components/ConfirmDialog.jsx
@@ -0,0 +1,33 @@
+import React from 'react';
+
+export default function ConfirmDialog({
+ visible,
+ title,
+ content,
+ note,
+ summary,
+ onCancel,
+ onConfirm,
+ cancelText = '取消',
+ confirmText = '确认',
+}) {
+ if (!visible) return null;
+ return (
+
+
e.stopPropagation()}>
+
{title}
+ {summary ?
{summary}
: null}
+ {note ?
{note}
: null}
+ {content ?
{content}
: null}
+
+
+
+
+
+
+ );
+}
diff --git a/src/prototypes/xll-miniapp/driver-training/components/DriverTrainingLayout.jsx b/src/prototypes/xll-miniapp/driver-training/components/DriverTrainingLayout.jsx
new file mode 100644
index 0000000..14fad1c
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/components/DriverTrainingLayout.jsx
@@ -0,0 +1,116 @@
+import React from 'react';
+
+const TRAINING_PHASES = [
+ { id: 'prep', label: '身份与须知', steps: [1, 2] },
+ { id: 'verify', label: '资质与培训', steps: [3, 4, 5] },
+ { id: 'confirm', label: '签署与提车', steps: [6, 7, 8] },
+];
+
+function getPhaseStatus(phase, currentStep) {
+ const maxStep = Math.max(...phase.steps);
+ const minStep = Math.min(...phase.steps);
+ if (currentStep > maxStep) return 'done';
+ if (currentStep >= minStep && currentStep <= maxStep) return 'active';
+ return 'pending';
+}
+
+function TrainingPhaseBar({ currentStep }) {
+ return (
+
+ {TRAINING_PHASES.map((phase, index) => {
+ const status = getPhaseStatus(phase, currentStep);
+ return (
+
+ {index > 0 && (
+
+ )}
+
+
+ {status === 'done' ? '✓' : index + 1}
+
+ {phase.label}
+
+
+ );
+ })}
+
+ );
+}
+
+export function TrainingStepHeader({
+ currentStep,
+ totalSteps = 8,
+ stepNames,
+ stepDetailHint = '',
+}) {
+ const isComplete = currentStep >= totalSteps;
+ const progressPercent = (currentStep / totalSteps) * 100;
+
+ return (
+
+
+ {isComplete ? '培训已完成' : '培训进度'}
+
+ 步骤 {currentStep}/{totalSteps}
+
+
+
+
+
+
+
+
+ {stepNames[currentStep - 1]}
+ {stepDetailHint ? (
+ {stepDetailHint}
+ ) : null}
+
+
+ );
+}
+
+export function DriverTrainingLayout({
+ currentStep,
+ totalSteps = 8,
+ stepNames,
+ stepDetailHint,
+ children,
+ footer,
+ embedded = false,
+ immersive = false,
+}) {
+ return (
+
+
+ {!embedded && (
+
+ )}
+
+ {!immersive ? (
+
+ ) : null}
+ {children}
+
+ {footer &&
{footer}
}
+
+
+ );
+}
diff --git a/src/prototypes/xll-miniapp/driver-training/components/OCRResultForm.jsx b/src/prototypes/xll-miniapp/driver-training/components/OCRResultForm.jsx
new file mode 100644
index 0000000..f0e880a
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/components/OCRResultForm.jsx
@@ -0,0 +1,87 @@
+import React from 'react';
+import { VEHICLE_CLASS_OPTIONS } from '../data/constants';
+
+const FIELD_CONFIG = {
+ idFront: [
+ { key: 'name', label: '姓名', required: true },
+ { key: 'idNumber', label: '身份证号', required: true },
+ ],
+ idBack: [{ key: 'validPeriod', label: '有效期限', required: true }],
+ licenseFront: [
+ { key: 'name', label: '姓名', required: true },
+ { key: 'licenseNumber', label: '证号', required: true },
+ { key: 'vehicleClass', label: '准驾车型', required: true, inputType: 'select', options: VEHICLE_CLASS_OPTIONS },
+ { key: 'validPeriod', label: '有效期限', required: true },
+ ],
+ qualification: [
+ { key: 'name', label: '姓名', required: true },
+ { key: 'gender', label: '性别', required: false },
+ { key: 'birthDate', label: '出生日期', required: false },
+ { key: 'nationality', label: '国籍', required: false },
+ { key: 'address', label: '住址', required: false },
+ { key: 'certNumber', label: '证号', required: true },
+ { key: 'vehicleClass', label: '准驾车型', required: true },
+ { key: 'category', label: '从业资格类别', required: true },
+ { key: 'initialIssueDate', label: '初次领证日期', required: true },
+ { key: 'validFrom', label: '有效起始日期', required: true },
+ { key: 'validPeriod', label: '有效期限', required: true },
+ ],
+};
+
+export const CERT_FIELD_LABELS = Object.fromEntries(
+ Object.values(FIELD_CONFIG)
+ .flat()
+ .map((f) => [f.key, f.label]),
+);
+
+export function getCertFieldLabel(key) {
+ return CERT_FIELD_LABELS[key] || key;
+}
+
+export default function OCRResultForm({ certType, data, onChange, errors = {} }) {
+ const fields = FIELD_CONFIG[certType];
+ if (!fields) return null;
+
+ return (
+
+ {fields.map((f) => (
+
+
+ {f.inputType === 'select' ? (
+
+ ) : (
+
onChange(f.key, e.target.value)}
+ />
+ )}
+ {errors[f.key] &&
{errors[f.key]}
}
+
+ ))}
+
+ );
+}
+
+export function ManualCertificateForm({ certType, data, onChange, errors = {} }) {
+ return (
+
+
请根据证件原件准确填写,相关信息将用于本次交车核验。
+
+
+ );
+}
diff --git a/src/prototypes/xll-miniapp/driver-training/components/PhoneVerificationForm.jsx b/src/prototypes/xll-miniapp/driver-training/components/PhoneVerificationForm.jsx
new file mode 100644
index 0000000..005da1f
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/components/PhoneVerificationForm.jsx
@@ -0,0 +1,137 @@
+import React, { useEffect, useState } from 'react';
+import { DEMO_PHONE, MOCK_SMS_CODE } from '../data/constants';
+
+const PHONE_REG = /^1[3-9]\d{9}$/;
+
+export default function PhoneVerificationForm({
+ value,
+ onChange,
+ onVerified,
+ showActions = true,
+}) {
+ const phone = value.phone || DEMO_PHONE;
+ const code = value.code || MOCK_SMS_CODE;
+ const [phoneError, setPhoneError] = useState('');
+ const [codeError, setCodeError] = useState('');
+ const [countdown, setCountdown] = useState(0);
+ const [sending, setSending] = useState(false);
+ const [success, setSuccess] = useState(false);
+
+ useEffect(() => {
+ if (countdown <= 0) return undefined;
+ const t = setTimeout(() => setCountdown((c) => c - 1), 1000);
+ return () => clearTimeout(t);
+ }, [countdown]);
+
+ const patch = (data) => onChange?.({ phone, code, ...value, ...data });
+
+ const handleSendCode = () => {
+ if (!PHONE_REG.test(phone)) {
+ setPhoneError('请输入正确的11位手机号');
+ return;
+ }
+ setPhoneError('');
+ setSending(true);
+ setTimeout(() => {
+ setSending(false);
+ setCountdown(60);
+ }, 500);
+ };
+
+ const handleVerify = () => {
+ if (!PHONE_REG.test(phone)) {
+ setPhoneError('请输入正确的11位手机号');
+ return;
+ }
+ setPhoneError('');
+ if (code !== MOCK_SMS_CODE) {
+ setCodeError('验证码错误,请重新输入。');
+ return;
+ }
+ setCodeError('');
+ setSuccess(true);
+ onChange?.({
+ phone,
+ code,
+ verified: true,
+ verifiedAt: new Date().toISOString(),
+ });
+ setTimeout(() => onVerified?.(), 800);
+ };
+
+ if (success) {
+ return (
+
+ ✓
+ 手机号验证成功
+
+ );
+ }
+
+ return (
+
+
+
+
{
+ patch({ phone: e.target.value.replace(/\D/g, '') });
+ setPhoneError('');
+ }}
+ />
+ {phoneError &&
{phoneError}
}
+
+
+
+
+ {
+ patch({ code: e.target.value.replace(/\D/g, '') });
+ setCodeError('');
+ }}
+ />
+
+
+ {codeError &&
{codeError}
}
+
+
+ 手机号用于绑定本次培训记录和接收培训状态通知。
+
+ 演示环境已预填手机号与验证码({MOCK_SMS_CODE}),可直接验证继续。
+
+ {showActions && (
+
+ )}
+
+ );
+}
+
+export function verifyPhoneStep(step2) {
+ const phone = step2.phone || DEMO_PHONE;
+ const code = step2.code || MOCK_SMS_CODE;
+ if (!PHONE_REG.test(phone)) {
+ return { ok: false, phoneError: '请输入正确的11位手机号' };
+ }
+ if (code !== MOCK_SMS_CODE) {
+ return { ok: false, codeError: '验证码错误,请重新输入。' };
+ }
+ return { ok: true, phone, code };
+}
diff --git a/src/prototypes/xll-miniapp/driver-training/components/PickupCodeCard.jsx b/src/prototypes/xll-miniapp/driver-training/components/PickupCodeCard.jsx
new file mode 100644
index 0000000..91defb6
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/components/PickupCodeCard.jsx
@@ -0,0 +1,89 @@
+import React, { useEffect, useRef } from 'react';
+
+import StatusTag from './StatusTag';
+
+import { PICKUP_STATUS } from '../data/constants';
+
+function drawQRCode(canvas, text) {
+ const ctx = canvas.getContext('2d');
+ const size = 160;
+ canvas.width = size;
+ canvas.height = size;
+ ctx.fillStyle = '#fff';
+ ctx.fillRect(0, 0, size, size);
+
+ const hash = text.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
+ const cell = 8;
+ const cells = size / cell;
+
+ for (let y = 0; y < cells; y++) {
+ for (let x = 0; x < cells; x++) {
+ const inCorner =
+ (x < 9 && y < 9) || (x > cells - 10 && y < 9) || (x < 9 && y > cells - 10);
+ const val = (hash * (x + 1) * (y + 1) + x * y) % 7;
+ if (inCorner) {
+ if (
+ (x === 1 || x === 7 || x === cells - 8 || x === cells - 2) &&
+ (y === 1 || y === 7)
+ ) {
+ ctx.fillStyle = '#1D2129';
+ ctx.fillRect(x * cell, y * cell, cell, cell);
+ } else if (x >= 2 && x <= 6 && y >= 2 && y <= 6) {
+ ctx.fillStyle = '#1D2129';
+ ctx.fillRect(x * cell, y * cell, cell, cell);
+ }
+ } else if (val < 3) {
+ ctx.fillStyle = '#1D2129';
+ ctx.fillRect(x * cell, y * cell, cell, cell);
+ }
+ }
+ }
+}
+
+const STATUS_HINTS = {
+ [PICKUP_STATUS.PENDING]: '请向运维人员出示二维码',
+ [PICKUP_STATUS.LOCKED]: '运维已核验,正在办理交车',
+ [PICKUP_STATUS.PROCESSING]: '等待双方完成电子签署',
+ [PICKUP_STATUS.COMPLETED]: '交车已完成,提车码已失效',
+ [PICKUP_STATUS.REVOKED]: '交车任务已取消,提车码不可使用',
+ [PICKUP_STATUS.EXPIRED]: '提车码已失效',
+};
+
+export default function PickupCodeCard({ step7 }) {
+ const canvasRef = useRef(null);
+ const isDisabled =
+ step7.status === PICKUP_STATUS.COMPLETED ||
+ step7.status === PICKUP_STATUS.REVOKED ||
+ step7.status === PICKUP_STATUS.EXPIRED;
+
+ useEffect(() => {
+ if (canvasRef.current && step7.pickupCode) {
+ drawQRCode(canvasRef.current, step7.pickupCode);
+ }
+ }, [step7.pickupCode]);
+
+ const hint = STATUS_HINTS[step7.status];
+
+ return (
+
+
+
✓
+
安全培训已完成
+
请向运维人员出示下方提车码
+
+
+
+
+
+ {hint ?
{hint}
: null}
+
+
+
+ {isDisabled &&
已失效
}
+
+
+
提车码仅限本次交车使用,请勿转发给他人。
+
+
+ );
+}
diff --git a/src/prototypes/xll-miniapp/driver-training/components/PrototypeDebugPanel.jsx b/src/prototypes/xll-miniapp/driver-training/components/PrototypeDebugPanel.jsx
new file mode 100644
index 0000000..3b1021a
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/components/PrototypeDebugPanel.jsx
@@ -0,0 +1,78 @@
+import React, { useState } from 'react';
+import { useTraining } from '../context/TrainingContext';
+
+export default function PrototypeDebugPanel() {
+ const [open, setOpen] = useState(false);
+ const { state, applyDebugSettings, resetAll } = useTraining();
+ const { debug } = state;
+
+ return (
+
+ {open && (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ );
+}
diff --git a/src/prototypes/xll-miniapp/driver-training/components/SignatureCanvas.jsx b/src/prototypes/xll-miniapp/driver-training/components/SignatureCanvas.jsx
new file mode 100644
index 0000000..a6f1ee6
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/components/SignatureCanvas.jsx
@@ -0,0 +1,165 @@
+import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
+
+const SignatureCanvas = forwardRef(function SignatureCanvas(
+ { value, onChange, onClear, variant = 'default', hideActions = false, watermark = '' },
+ ref,
+) {
+ const canvasRef = useRef(null);
+ const wrapRef = useRef(null);
+ const drawing = useRef(false);
+ const [hasStroke, setHasStroke] = useState(!!value);
+ const isEsign = variant === 'esign';
+ const isFullscreen = variant === 'fullscreen' || isEsign;
+
+ const valueRef = useRef(value);
+ valueRef.current = value;
+
+ const setupCanvas = useCallback(() => {
+ const canvas = canvasRef.current;
+ const wrap = wrapRef.current;
+ if (!canvas || !wrap) return;
+
+ const rect = wrap.getBoundingClientRect();
+ if (rect.width <= 0 || rect.height <= 0) return;
+
+ const savedValue = valueRef.current;
+ canvas.width = rect.width * 2;
+ canvas.height = rect.height * 2;
+ const ctx = canvas.getContext('2d');
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
+ ctx.scale(2, 2);
+ ctx.strokeStyle = '#1D2129';
+ ctx.lineWidth = isEsign ? 3 : isFullscreen ? 2.5 : 2;
+ ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+ ctx.clearRect(0, 0, rect.width, rect.height);
+
+ if (savedValue) {
+ const img = new Image();
+ img.onload = () => ctx.drawImage(img, 0, 0, rect.width, rect.height);
+ img.src = savedValue;
+ }
+ }, [isEsign, isFullscreen]);
+
+ const handleClear = useCallback(() => {
+ const canvas = canvasRef.current;
+ const wrap = wrapRef.current;
+ if (!canvas || !wrap) return;
+ const ctx = canvas.getContext('2d');
+ const rect = wrap.getBoundingClientRect();
+ ctx.clearRect(0, 0, rect.width, rect.height);
+ setHasStroke(false);
+ onChange?.(null);
+ onClear?.();
+ }, [onChange, onClear]);
+
+ useImperativeHandle(ref, () => ({
+ clear: handleClear,
+ hasStroke: () => hasStroke,
+ }));
+
+ useEffect(() => {
+ setupCanvas();
+ const wrap = wrapRef.current;
+ if (!wrap || typeof ResizeObserver === 'undefined') return undefined;
+
+ const observer = new ResizeObserver(() => setupCanvas());
+ observer.observe(wrap);
+ return () => observer.disconnect();
+ }, [setupCanvas]);
+
+ useEffect(() => {
+ if (!drawing.current) {
+ setupCanvas();
+ }
+ }, [value, setupCanvas]);
+
+ useEffect(() => {
+ setHasStroke(!!value);
+ }, [value]);
+
+ const getPos = (e, canvas) => {
+ const rect = canvas.getBoundingClientRect();
+ const clientX = e.touches ? e.touches[0].clientX : e.clientX;
+ const clientY = e.touches ? e.touches[0].clientY : e.clientY;
+ return { x: clientX - rect.left, y: clientY - rect.top };
+ };
+
+ const startDraw = (e) => {
+ e.preventDefault();
+ drawing.current = true;
+ const canvas = canvasRef.current;
+ const ctx = canvas.getContext('2d');
+ const { x, y } = getPos(e, canvas);
+ ctx.beginPath();
+ ctx.moveTo(x, y);
+ };
+
+ const draw = (e) => {
+ if (!drawing.current) return;
+ e.preventDefault();
+ const canvas = canvasRef.current;
+ const ctx = canvas.getContext('2d');
+ const { x, y } = getPos(e, canvas);
+ ctx.lineTo(x, y);
+ ctx.stroke();
+ setHasStroke(true);
+ };
+
+ const endDraw = () => {
+ if (!drawing.current) return;
+ drawing.current = false;
+ const canvas = canvasRef.current;
+ onChange?.(canvas.toDataURL('image/png'));
+ };
+
+ const placeholderText = isEsign ? watermark || '请在此处签名' : isFullscreen ? '请横屏在此处签名' : '请在此处签名';
+
+ return (
+
+
+ {!hasStroke && !isEsign && (
+
+ {placeholderText}
+
+ )}
+ {isEsign && !hasStroke && (
+
+ {watermark || '请在此处签名'}
+
+ )}
+
+
+
+ {!hideActions && (
+
+
+ {hasStroke && (
+
+ ✓ 已签名
+
+ )}
+
+ )}
+
+ );
+});
+
+export default SignatureCanvas;
diff --git a/src/prototypes/xll-miniapp/driver-training/components/StatusTag.jsx b/src/prototypes/xll-miniapp/driver-training/components/StatusTag.jsx
new file mode 100644
index 0000000..cbfd23d
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/components/StatusTag.jsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import { PICKUP_STATUS, PICKUP_STATUS_LABELS } from '../data/constants';
+
+const STATUS_CLASS = {
+ [PICKUP_STATUS.PENDING]: 'pending',
+ [PICKUP_STATUS.LOCKED]: 'locked',
+ [PICKUP_STATUS.PROCESSING]: 'processing',
+ [PICKUP_STATUS.COMPLETED]: 'completed',
+ [PICKUP_STATUS.EXPIRED]: 'expired',
+ [PICKUP_STATUS.REVOKED]: 'revoked',
+};
+
+export default function StatusTag({ status }) {
+ const cls = STATUS_CLASS[status] || 'pending';
+ const label = PICKUP_STATUS_LABELS[status] || status;
+ return {label};
+}
diff --git a/src/prototypes/xll-miniapp/driver-training/components/Toast.jsx b/src/prototypes/xll-miniapp/driver-training/components/Toast.jsx
new file mode 100644
index 0000000..6baae37
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/components/Toast.jsx
@@ -0,0 +1,28 @@
+import React, { useEffect, useState } from 'react';
+
+let toastTimer = null;
+
+export default function Toast({ message, onClose }) {
+ const [visible, setVisible] = useState(!!message);
+
+ useEffect(() => {
+ if (!message) return undefined;
+ setVisible(true);
+ if (toastTimer) clearTimeout(toastTimer);
+ toastTimer = setTimeout(() => {
+ setVisible(false);
+ onClose?.();
+ }, 2500);
+ return () => clearTimeout(toastTimer);
+ }, [message, onClose]);
+
+ if (!visible || !message) return null;
+ return {message}
;
+}
+
+export function useToast() {
+ const [message, setMessage] = useState('');
+ const show = (msg) => setMessage(msg);
+ const clear = () => setMessage('');
+ return { message, show, clear };
+}
diff --git a/src/prototypes/xll-miniapp/driver-training/components/TrainingDocumentViewer.jsx b/src/prototypes/xll-miniapp/driver-training/components/TrainingDocumentViewer.jsx
new file mode 100644
index 0000000..62d18f9
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/components/TrainingDocumentViewer.jsx
@@ -0,0 +1,31 @@
+import React, { useCallback, useEffect, useRef } from 'react';
+
+export default function TrainingDocumentViewer({ content, onProgress, onReachBottom, startTime, onStart }) {
+ const ref = useRef(null);
+ const started = useRef(false);
+
+ const handleScroll = useCallback(() => {
+ const el = ref.current;
+ if (!el) return;
+ if (!started.current) {
+ started.current = true;
+ onStart?.();
+ }
+ const { scrollTop, scrollHeight, clientHeight } = el;
+ const progress = Math.min(100, Math.round((scrollTop / (scrollHeight - clientHeight || 1)) * 100));
+ onProgress?.(progress);
+ if (scrollTop + clientHeight >= scrollHeight - 8) {
+ onReachBottom?.();
+ }
+ }, [onProgress, onReachBottom, onStart]);
+
+ useEffect(() => {
+ handleScroll();
+ }, [handleScroll, content]);
+
+ return (
+
+ {content}
+
+ );
+}
diff --git a/src/prototypes/xll-miniapp/driver-training/components/TrainingVideoPlayer.jsx b/src/prototypes/xll-miniapp/driver-training/components/TrainingVideoPlayer.jsx
new file mode 100644
index 0000000..88ecfbd
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/components/TrainingVideoPlayer.jsx
@@ -0,0 +1,106 @@
+import React, { useCallback, useEffect, useRef, useState } from 'react';
+
+const DURATION = 30;
+
+function IconPlay() {
+ return (
+
+ );
+}
+
+function IconPause() {
+ return (
+
+ );
+}
+
+export default function TrainingVideoPlayer({ completed, watchedSeconds, onProgress, onComplete, debugComplete }) {
+ const [playing, setPlaying] = useState(false);
+ const [current, setCurrent] = useState(watchedSeconds || 0);
+ const timerRef = useRef(null);
+
+ const stopTimer = useCallback(() => {
+ if (timerRef.current) {
+ clearInterval(timerRef.current);
+ timerRef.current = null;
+ }
+ }, []);
+
+ useEffect(() => {
+ const handleVisibility = () => {
+ if (document.hidden) setPlaying(false);
+ };
+ document.addEventListener('visibilitychange', handleVisibility);
+ return () => document.removeEventListener('visibilitychange', handleVisibility);
+ }, []);
+
+ useEffect(() => {
+ if (!playing || completed) {
+ stopTimer();
+ return undefined;
+ }
+ timerRef.current = setInterval(() => {
+ setCurrent((prev) => {
+ const next = Math.min(prev + 1, DURATION);
+ onProgress?.(next);
+ if (next >= DURATION) {
+ setPlaying(false);
+ onComplete?.();
+ }
+ return next;
+ });
+ }, 1000);
+ return () => stopTimer();
+ }, [playing, completed, onProgress, onComplete, stopTimer]);
+
+ useEffect(() => {
+ if (debugComplete && !completed) {
+ setCurrent(DURATION);
+ onProgress?.(DURATION);
+ onComplete?.();
+ }
+ }, [debugComplete, completed, onProgress, onComplete]);
+
+ useEffect(() => {
+ if (completed) setCurrent(DURATION);
+ }, [completed]);
+
+ const togglePlay = () => {
+ if (completed) return;
+ setPlaying((p) => !p);
+ };
+
+ const progress = (current / DURATION) * 100;
+ const formatTime = (s) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
+
+ return (
+
+
+
+
氢能重卡安全驾驶培训
+
预计时长 30 秒
+ {!playing && !completed && (
+
+ )}
+
+
+
+
+
+
+ {formatTime(current)}/{formatTime(DURATION)}
+
+
+
+ );
+}
diff --git a/src/prototypes/xll-miniapp/driver-training/components/UploadSourceSheet.jsx b/src/prototypes/xll-miniapp/driver-training/components/UploadSourceSheet.jsx
new file mode 100644
index 0000000..160ccbb
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/components/UploadSourceSheet.jsx
@@ -0,0 +1,33 @@
+import React from 'react';
+import { CERT_LABELS } from '../data/constants';
+
+export default function UploadSourceSheet({ visible, certType, onCamera, onAlbum, onCancel }) {
+ if (!visible || !certType) return null;
+ const label = CERT_LABELS[certType] || '证件';
+
+ return (
+
+
e.stopPropagation()}>
+
上传{label}
+
请选择获取证件照片的方式
+
+
+
+
+
+ );
+}
diff --git a/src/prototypes/xll-miniapp/driver-training/context/TrainingContext.jsx b/src/prototypes/xll-miniapp/driver-training/context/TrainingContext.jsx
new file mode 100644
index 0000000..20f6642
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/context/TrainingContext.jsx
@@ -0,0 +1,227 @@
+import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
+import {
+ DOCUMENT_VERSION,
+ PICKUP_STATUS,
+ STEP_NAMES,
+ TOTAL_STEPS,
+} from '../data/constants';
+import {
+ createInitialState,
+ generatePickupCode,
+ loadTrainingState,
+ resetTrainingState,
+ saveTrainingState,
+} from '../data/storage';
+
+const TrainingContext = createContext(null);
+
+function isQualificationRequired(tonnage) {
+ return tonnage > 18;
+}
+
+function getRequiredCertTypes(tonnage) {
+ const base = ['idFront', 'idBack', 'licenseFront', 'licenseBack'];
+ if (isQualificationRequired(tonnage)) base.push('qualification');
+ return base;
+}
+
+export { isQualificationRequired, getRequiredCertTypes };
+
+export function TrainingProvider({ children }) {
+ const [state, setState] = useState(() => loadTrainingState());
+
+ useEffect(() => {
+ saveTrainingState(state);
+ }, [state]);
+
+ const updateState = useCallback((updater) => {
+ setState((prev) => {
+ const next = typeof updater === 'function' ? updater(prev) : { ...prev, ...updater };
+ return next;
+ });
+ }, []);
+
+ const invalidateAfterStep3 = useCallback((prev) => ({
+ ...prev,
+ step6: {
+ signatureDataUrl: null,
+ checked: false,
+ submitted: false,
+ submittedAt: null,
+ },
+ step7: {
+ pickupCode: null,
+ numericCode: null,
+ status: PICKUP_STATUS.PENDING,
+ generatedAt: null,
+ completedAt: null,
+ lockedAt: null,
+ },
+ maxCompletedStep: Math.min(prev.maxCompletedStep, 5),
+ currentStep: Math.min(prev.currentStep, 6),
+ }), []);
+
+ const goToStep = useCallback((step) => {
+ setState((prev) => {
+ if (step > prev.maxCompletedStep + 1) return prev;
+ return { ...prev, currentStep: step };
+ });
+ }, []);
+
+ const completeStep = useCallback((step) => {
+ setState((prev) => ({
+ ...prev,
+ maxCompletedStep: Math.max(prev.maxCompletedStep, step),
+ currentStep: Math.min(step + 1, TOTAL_STEPS),
+ }));
+ }, []);
+
+ const resetAll = useCallback(() => {
+ const fresh = resetTrainingState();
+ setState(fresh);
+ return fresh;
+ }, []);
+
+ const applyDebugSettings = useCallback((debugPatch) => {
+ setState((prev) => {
+ let next = {
+ ...prev,
+ debug: { ...prev.debug, ...debugPatch },
+ };
+
+ if (debugPatch.vehicleTonnage !== undefined) {
+ next.trainingTask = {
+ ...next.trainingTask,
+ vehicleTonnage: debugPatch.vehicleTonnage,
+ vehicleType:
+ debugPatch.vehicleTonnage >= 49
+ ? '49吨氢能重卡'
+ : debugPatch.vehicleTonnage >= 18
+ ? '18吨氢能轻卡'
+ : '4.5吨氢能物流车',
+ };
+ if (prev.step7.pickupCode) {
+ next = invalidateAfterStep3(next);
+ next.step7 = { ...next.step7, status: PICKUP_STATUS.REVOKED };
+ }
+ }
+
+ if (debugPatch.pickupStatus !== undefined) {
+ const statusMap = {
+ pending: PICKUP_STATUS.PENDING,
+ locked: PICKUP_STATUS.LOCKED,
+ single_sign: PICKUP_STATUS.PROCESSING,
+ both_signed: PICKUP_STATUS.COMPLETED,
+ revoked: PICKUP_STATUS.REVOKED,
+ };
+ const status = statusMap[debugPatch.pickupStatus] || PICKUP_STATUS.PENDING;
+ next.step7 = {
+ ...next.step7,
+ status,
+ pickupCode: next.step7.pickupCode || `PK-${next.trainingTask.taskId}`,
+ numericCode: next.step7.numericCode || generatePickupCode(),
+ generatedAt: next.step7.generatedAt || new Date().toISOString(),
+ lockedAt: status !== PICKUP_STATUS.PENDING ? new Date().toISOString() : null,
+ completedAt: status === PICKUP_STATUS.COMPLETED ? new Date().toISOString() : null,
+ };
+ if (status === PICKUP_STATUS.COMPLETED || status === PICKUP_STATUS.REVOKED) {
+ next.step7.status = status;
+ }
+ }
+
+ if (debugPatch.videoStatus === 'completed') {
+ next.step4 = {
+ completed: true,
+ watchedSeconds: 30,
+ completedAt: new Date().toISOString(),
+ };
+ } else if (debugPatch.videoStatus === 'incomplete') {
+ next.step4 = { completed: false, watchedSeconds: 0, completedAt: null };
+ next.maxCompletedStep = Math.min(next.maxCompletedStep, 3);
+ }
+
+ return next;
+ });
+ }, [invalidateAfterStep3]);
+
+ const updateCertificate = useCallback((certType, data) => {
+ setState((prev) => {
+ let next = {
+ ...prev,
+ step3: {
+ ...prev.step3,
+ certificates: {
+ ...prev.step3.certificates,
+ [certType]: { ...prev.step3.certificates[certType], ...data },
+ },
+ },
+ };
+ if (prev.step6.submitted || prev.step7.pickupCode) {
+ next = invalidateAfterStep3(next);
+ }
+ return next;
+ });
+ }, [invalidateAfterStep3]);
+
+ const submitTraining = useCallback(() => {
+ setState((prev) => {
+ const numericCode = generatePickupCode();
+ const now = new Date().toISOString();
+ return {
+ ...prev,
+ step6: {
+ ...prev.step6,
+ submitted: true,
+ submittedAt: now,
+ },
+ step7: {
+ pickupCode: `PK-${prev.trainingTask.taskId}-${numericCode}`,
+ numericCode,
+ status: PICKUP_STATUS.PENDING,
+ generatedAt: now,
+ completedAt: null,
+ lockedAt: null,
+ },
+ maxCompletedStep: TOTAL_STEPS,
+ currentStep: TOTAL_STEPS,
+ };
+ });
+ }, []);
+
+ const value = useMemo(
+ () => ({
+ state,
+ updateState,
+ goToStep,
+ completeStep,
+ resetAll,
+ applyDebugSettings,
+ updateCertificate,
+ submitTraining,
+ isQualificationRequired,
+ getRequiredCertTypes,
+ stepNames: STEP_NAMES,
+ documentVersion: DOCUMENT_VERSION,
+ }),
+ [
+ state,
+ updateState,
+ goToStep,
+ completeStep,
+ resetAll,
+ applyDebugSettings,
+ updateCertificate,
+ submitTraining,
+ ]
+ );
+
+ return {children};
+}
+
+export function useTraining() {
+ const ctx = useContext(TrainingContext);
+ if (!ctx) throw new Error('useTraining must be used within TrainingProvider');
+ return ctx;
+}
+
+export { createInitialState };
diff --git a/src/prototypes/xll-miniapp/driver-training/data/constants.js b/src/prototypes/xll-miniapp/driver-training/data/constants.js
new file mode 100644
index 0000000..fdb2496
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/data/constants.js
@@ -0,0 +1,146 @@
+export const THEME = {
+ primary: '#7AB929',
+ primaryLight: 'rgba(122, 185, 41, 0.14)',
+ bg: '#F7F8FA',
+ card: '#FFFFFF',
+ title: '#1D2129',
+ text: '#4E5969',
+ textSecondary: '#86909C',
+ border: '#E5E6EB',
+ danger: '#F53F3F',
+ warning: '#FF7D00',
+};
+
+export const STORAGE_KEY = 'xll_driver_training_state_v1';
+
+export const DOCUMENT_VERSION = 'V2.1.0-20260701';
+
+export const MOCK_SMS_CODE = '123456';
+
+/** 原型演示用手机号(虚构脱敏) */
+export const DEMO_PHONE = '13812345678';
+
+export const STEP_NAMES = [
+ '阅读培训文档',
+ '手机号验证',
+ '司机证照',
+ '安全培训视频',
+ '本人照片',
+ '信息确认',
+ '手写签名',
+ '生成提车码',
+];
+
+export const TOTAL_STEPS = STEP_NAMES.length;
+
+export const CERT_TYPES = {
+ ID_FRONT: 'idFront',
+ ID_BACK: 'idBack',
+ LICENSE_FRONT: 'licenseFront',
+ LICENSE_BACK: 'licenseBack',
+ QUALIFICATION: 'qualification',
+};
+
+export const CERT_LABELS = {
+ [CERT_TYPES.ID_FRONT]: '身份证正面',
+ [CERT_TYPES.ID_BACK]: '身份证反面',
+ [CERT_TYPES.LICENSE_FRONT]: '驾驶证正页',
+ [CERT_TYPES.LICENSE_BACK]: '驾驶证副页',
+ [CERT_TYPES.QUALIFICATION]: '从业资格证',
+};
+
+export const PICKUP_STATUS = {
+ PENDING: 'pending',
+ LOCKED: 'locked',
+ PROCESSING: 'processing',
+ COMPLETED: 'completed',
+ EXPIRED: 'expired',
+ REVOKED: 'revoked',
+};
+
+export const PICKUP_STATUS_LABELS = {
+ [PICKUP_STATUS.PENDING]: '待核验',
+ [PICKUP_STATUS.LOCKED]: '已锁定',
+ [PICKUP_STATUS.PROCESSING]: '交车处理中',
+ [PICKUP_STATUS.COMPLETED]: '已完成',
+ [PICKUP_STATUS.EXPIRED]: '已失效',
+ [PICKUP_STATUS.REVOKED]: '已撤销',
+};
+
+export const OCR_MOCK = {
+ idFront: {
+ name: '张建国',
+ idNumber: '3304**********1234',
+ },
+ idBack: {
+ validPeriod: '2024.01.01—2044.01.01',
+ },
+ licenseFront: {
+ name: '张建国',
+ licenseNumber: '3304**********1234',
+ vehicleClass: 'A2',
+ validPeriod: '2024.01.01—2034.01.01',
+ },
+ licenseBack: {},
+ qualification: {
+ name: '张建国',
+ gender: '男',
+ birthDate: '1985-03-27',
+ nationality: '中国',
+ address: '浙江省嘉兴市南湖区',
+ certNumber: '3304********5678',
+ vehicleClass: 'A1A2',
+ category: '经营性道路货物运输驾驶员',
+ initialIssueDate: '2021年04月22日',
+ validFrom: '2021年04月22日',
+ validPeriod: '2027年04月21日止',
+ },
+};
+
+export const TRAINING_DOCUMENT = `一、驾驶员安全责任
+
+1. 驾驶员须严格遵守国家道路交通安全法律法规及公司安全管理制度。
+2. 出车前须确认本人身体状况良好,严禁疲劳驾驶、酒后驾驶或服用影响驾驶安全的药物后驾车。
+3. 驾驶员对车辆运行安全、货物安全及氢能系统安全负有直接责任。
+
+二、出车前检查要求
+
+1. 检查轮胎气压、制动系统、转向系统、灯光信号是否正常。
+2. 检查氢气储存系统、管路接头、阀门及泄漏报警装置。
+3. 检查仪表盘各项指示,确认无故障报警后方可出车。
+4. 检查随车安全设施:灭火器、三角警示牌、反光背心等。
+
+三、氢能车辆使用注意事项
+
+1. 加氢须在指定加氢站进行,严禁私自改装氢气系统。
+2. 发现氢气泄漏气味或报警时,立即停车熄火,撤离至安全区域并上报。
+3. 禁止在加氢站、隧道、密闭空间等危险区域吸烟或使用明火。
+4. 氢气瓶区域严禁敲击、碰撞,避免高温环境长时间停放。
+5. 车辆停放时应选择通风良好区域,远离火源和人员密集场所。
+
+四、突发事件处理要求
+
+1. 发生交通事故时,立即报警并保护现场,按规定上报公司安全部门。
+2. 发生氢气泄漏时,切断电源,疏散人员,设置警戒区,等待专业人员处置。
+3. 发生火情时,使用车载灭火器进行初期扑救,无法控制时立即撤离并报警。
+4. 所有突发事件须在30分钟内完成首次上报。
+
+五、禁止事项
+
+1. 严禁无证驾驶、准驾车型不符驾驶。
+2. 严禁超速、超载、超员行驶。
+3. 严禁行车中使用手机或其他分散注意力的行为。
+4. 严禁擅自拆卸、改装车辆安全及氢能系统。
+5. 严禁将车辆交予未经培训和授权的人员驾驶。
+
+六、个人信息和证件使用说明
+
+1. 本次培训采集的身份证、驾驶证、从业资格证及本人照片仅用于本次交车核验和安全培训存档。
+2. 证件信息须真实有效,如有虚假将承担相应法律责任。
+3. 培训记录与交车任务绑定,提车码仅限本人本次交车使用。
+4. 如对证件采集有异议,请联系现场运维人员协助处理。`;
+
+export const REQUIRED_VEHICLE_CLASS = 'A2';
+
+/** 现行机动车驾驶证准驾车型代号(本流程可选范围) */
+export const VEHICLE_CLASS_OPTIONS = ['A1', 'A2', 'A3', 'B1', 'B2', 'C1', 'C2'];
diff --git a/src/prototypes/xll-miniapp/driver-training/data/storage.js b/src/prototypes/xll-miniapp/driver-training/data/storage.js
new file mode 100644
index 0000000..8b94379
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/data/storage.js
@@ -0,0 +1,106 @@
+import { STORAGE_KEY, DEMO_PHONE, MOCK_SMS_CODE } from './constants';
+
+export function createInitialState(overrides = {}) {
+ const now = new Date().toISOString();
+ return {
+ trainingTask: {
+ taskId: 'JC202607170023',
+ projectName: '上海示范运营项目',
+ vehiclePlate: '浙F·****8',
+ vehicleType: '49吨氢能重卡',
+ vehicleTonnage: 49,
+ plannedDate: '2026-07-20',
+ requiredVehicleClass: 'A2',
+ ...overrides.trainingTask,
+ },
+ currentStep: 1,
+ maxCompletedStep: 0,
+ step1: {
+ readProgress: 0,
+ scrolledToBottom: false,
+ confirmed: false,
+ checked: false,
+ startTime: null,
+ endTime: null,
+ confirmTime: null,
+ documentVersion: null,
+ },
+ step2: {
+ phone: DEMO_PHONE,
+ code: MOCK_SMS_CODE,
+ verified: false,
+ verifiedAt: null,
+ },
+ step3: {
+ certificates: {},
+ },
+ step4: {
+ completed: false,
+ watchedSeconds: 0,
+ completedAt: null,
+ },
+ step5: {
+ photoDataUrl: null,
+ confirmed: false,
+ capturedAt: null,
+ },
+ step6: {
+ signatureDataUrl: null,
+ checked: false,
+ submitted: false,
+ submittedAt: null,
+ },
+ step7: {
+ pickupCode: null,
+ numericCode: null,
+ status: 'pending',
+ generatedAt: null,
+ completedAt: null,
+ lockedAt: null,
+ },
+ debug: {
+ vehicleTonnage: 49,
+ ocrMode: 'success',
+ cameraPermission: 'allow',
+ videoStatus: 'incomplete',
+ pickupStatus: 'pending',
+ },
+ createdAt: now,
+ updatedAt: now,
+ };
+}
+
+export function loadTrainingState() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return createInitialState();
+ const parsed = JSON.parse(raw);
+ const merged = { ...createInitialState(), ...parsed };
+ merged.trainingTask.vehicleTonnage = merged.debug?.vehicleTonnage ?? merged.trainingTask.vehicleTonnage;
+ if (merged.step6?.submitted && merged.step7?.pickupCode && merged.currentStep < 8) {
+ merged.currentStep = 8;
+ merged.maxCompletedStep = Math.max(merged.maxCompletedStep || 0, 8);
+ }
+ if (merged.currentStep === 7 && merged.step7?.pickupCode && !merged.step6?.signatureDataUrl) {
+ merged.currentStep = 8;
+ }
+ return merged;
+ } catch {
+ return createInitialState();
+ }
+}
+
+export function saveTrainingState(state) {
+ const toSave = { ...state, updatedAt: new Date().toISOString() };
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
+ return toSave;
+}
+
+export function resetTrainingState() {
+ localStorage.removeItem(STORAGE_KEY);
+ return createInitialState();
+}
+
+export function generatePickupCode() {
+ return String(Math.floor(100000 + Math.random() * 900000));
+}
diff --git a/src/prototypes/xll-miniapp/driver-training/steps/Step1Document.jsx b/src/prototypes/xll-miniapp/driver-training/steps/Step1Document.jsx
new file mode 100644
index 0000000..7f5017f
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/steps/Step1Document.jsx
@@ -0,0 +1,104 @@
+import React from 'react';
+import { DOCUMENT_VERSION, TRAINING_DOCUMENT } from '../data/constants';
+import TrainingDocumentViewer from '../components/TrainingDocumentViewer';
+
+export default function Step1Document({ state, updateState, completeStep, renderPart = 'all' }) {
+ const { step1 } = state;
+ const readProgress = step1.readProgress || 0;
+ const scrolledToBottom = step1.scrolledToBottom;
+ const checked = step1.checked;
+
+ const handleStart = () => {
+ if (!step1.startTime) {
+ updateState((prev) => ({
+ ...prev,
+ step1: { ...prev.step1, startTime: new Date().toISOString() },
+ }));
+ }
+ };
+
+ const handleReachBottom = () => {
+ updateState((prev) => ({
+ ...prev,
+ step1: {
+ ...prev.step1,
+ scrolledToBottom: true,
+ readProgress: 100,
+ endTime: prev.step1.endTime || new Date().toISOString(),
+ },
+ }));
+ };
+
+ const handleNext = () => {
+ const now = new Date().toISOString();
+ updateState((prev) => ({
+ ...prev,
+ step1: {
+ ...prev.step1,
+ confirmed: true,
+ checked: true,
+ confirmTime: now,
+ documentVersion: DOCUMENT_VERSION,
+ readProgress: 100,
+ scrolledToBottom: true,
+ },
+ }));
+ completeStep(1);
+ };
+
+ const canProceed = scrolledToBottom && checked;
+
+ const content = (
+
+
安全培训须知
+
{
+ updateState((prev) => ({ ...prev, step1: { ...prev.step1, readProgress: p } }));
+ }}
+ onReachBottom={handleReachBottom}
+ onStart={handleStart}
+ />
+
+
+
已阅读 {readProgress}%
+
+
+
+ );
+
+ const footer = (
+ <>
+
+ {!scrolledToBottom ? '请完整阅读培训文档后继续。' : !checked ? '请勾选确认后继续。' : ''}
+
+
+ >
+ );
+
+ if (renderPart === 'content') return content;
+ if (renderPart === 'footer') return footer;
+ return (
+ <>
+ {content}
+ {footer}
+ >
+ );
+}
diff --git a/src/prototypes/xll-miniapp/driver-training/steps/Step2Phone.jsx b/src/prototypes/xll-miniapp/driver-training/steps/Step2Phone.jsx
new file mode 100644
index 0000000..f863658
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/steps/Step2Phone.jsx
@@ -0,0 +1,105 @@
+import React, { useState } from 'react';
+import PhoneVerificationForm, { verifyPhoneStep } from '../components/PhoneVerificationForm';
+import { DEMO_PHONE, MOCK_SMS_CODE } from '../data/constants';
+
+export default function Step2Phone({ state, updateState, completeStep, renderPart = 'all' }) {
+ const [errors, setErrors] = useState({});
+
+ const handleVerify = () => {
+ const result = verifyPhoneStep(state.step2);
+ if (!result.ok) {
+ setErrors({
+ phoneError: result.phoneError,
+ codeError: result.codeError,
+ });
+ return;
+ }
+ setErrors({});
+ updateState((prev) => ({
+ ...prev,
+ step2: {
+ ...prev.step2,
+ phone: result.phone,
+ code: result.code,
+ verified: true,
+ verifiedAt: new Date().toISOString(),
+ },
+ }));
+ completeStep(2);
+ };
+
+ const handleDemoSkip = () => {
+ updateState((prev) => ({
+ ...prev,
+ step2: {
+ phone: DEMO_PHONE,
+ code: MOCK_SMS_CODE,
+ verified: true,
+ verifiedAt: new Date().toISOString(),
+ },
+ }));
+ completeStep(2);
+ };
+
+ if (state.step2.verified) {
+ const content = (
+
+
+ ✓
+ 手机号 {state.step2.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')} 已验证
+
+
+ );
+ const footer = (
+
+ );
+ if (renderPart === 'content') return content;
+ if (renderPart === 'footer') return footer;
+ return (
+ <>
+ {content}
+ {footer}
+ >
+ );
+ }
+
+ const content = (
+
+
手机号验证
+
updateState((prev) => ({ ...prev, step2: { ...prev.step2, ...data } }))}
+ />
+ {errors.phoneError && {errors.phoneError}
}
+ {errors.codeError && {errors.codeError}
}
+
+ );
+
+ const footer = (
+ <>
+
+
+ >
+ );
+
+ if (renderPart === 'content') return content;
+ if (renderPart === 'footer') return footer;
+ return (
+ <>
+ {content}
+ {footer}
+ >
+ );
+}
diff --git a/src/prototypes/xll-miniapp/driver-training/steps/Step3Certificates.jsx b/src/prototypes/xll-miniapp/driver-training/steps/Step3Certificates.jsx
new file mode 100644
index 0000000..e3c5771
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/steps/Step3Certificates.jsx
@@ -0,0 +1,334 @@
+import React, { useRef, useState } from 'react';
+import CertificateUploadCard from '../components/CertificateUploadCard';
+import { ManualCertificateForm, getCertFieldLabel } from '../components/OCRResultForm';
+import UploadSourceSheet from '../components/UploadSourceSheet';
+import { CERT_TYPES, OCR_MOCK } from '../data/constants';
+import { isQualificationRequired, getRequiredCertTypes } from '../context/TrainingContext';
+
+const OCR_CERT_TYPES = [CERT_TYPES.ID_FRONT, CERT_TYPES.LICENSE_FRONT, CERT_TYPES.QUALIFICATION];
+const BACK_ONLY = [CERT_TYPES.ID_BACK, CERT_TYPES.LICENSE_BACK];
+
+function parseValidEnd(period) {
+ if (!period) return null;
+ const parts = period.split(/[—\-~]/);
+ const end = parts[parts.length - 1]?.trim().replace(/\./g, '-');
+ return end ? new Date(end) : null;
+}
+
+export default function Step3Certificates({ state, updateCertificate, completeStep, renderPart = 'all' }) {
+ const tonnage = state.trainingTask.vehicleTonnage;
+ const requiredTypes = getRequiredCertTypes(tonnage);
+ const showOptionalQual = !isQualificationRequired(tonnage);
+ const certs = state.step3.certificates;
+ const requiredDoneCount = requiredTypes.filter((t) => certs[t]?.status === 'done').length;
+ const allDone = requiredDoneCount === requiredTypes.length;
+ const cameraRef = useRef(null);
+ const albumRef = useRef(null);
+ const [uploadSheetCert, setUploadSheetCert] = useState(null);
+ const [activeCert, setActiveCert] = useState(null);
+ const [modal, setModal] = useState(null);
+ const [formData, setFormData] = useState({});
+ const [formErrors, setFormErrors] = useState({});
+ const [validationError, setValidationError] = useState('');
+ const ocrMode = state.debug.ocrMode;
+
+ const allCertTypes = showOptionalQual
+ ? [...requiredTypes, CERT_TYPES.QUALIFICATION]
+ : requiredTypes;
+
+ const showUploadOptions = (certType) => {
+ setUploadSheetCert(certType);
+ };
+
+ const pickCamera = () => {
+ if (!uploadSheetCert) return;
+ setActiveCert(uploadSheetCert);
+ setUploadSheetCert(null);
+ setTimeout(() => cameraRef.current?.click(), 0);
+ };
+
+ const pickAlbum = () => {
+ if (!uploadSheetCert) return;
+ setActiveCert(uploadSheetCert);
+ setUploadSheetCert(null);
+ setTimeout(() => albumRef.current?.click(), 0);
+ };
+
+ const runOCR = (certType, imageUrl) => {
+ updateCertificate(certType, { status: 'recognizing', imageUrl });
+ setTimeout(() => {
+ if (ocrMode === 'fail') {
+ updateCertificate(certType, { status: 'failed', imageUrl, mode: 'ocr' });
+ setModal({ type: 'ocr_fail', certType, imageUrl });
+ return;
+ }
+ let mock = { ...OCR_MOCK[certType] };
+ if (ocrMode === 'mismatch' && certType === CERT_TYPES.LICENSE_FRONT) {
+ mock = { ...mock, name: '李晓明', licenseNumber: '3304**********9999' };
+ }
+ updateCertificate(certType, { status: 'confirm', imageUrl, mode: 'ocr', fields: mock, ocrData: mock });
+ setModal({ type: 'confirm', certType, imageUrl, fields: mock });
+ setFormData(mock);
+ }, 1000);
+ };
+
+ const handleFile = (e) => {
+ const file = e.target.files?.[0];
+ if (!file || !activeCert) return;
+ const reader = new FileReader();
+ updateCertificate(activeCert, { status: 'uploading' });
+ reader.onload = () => {
+ if (BACK_ONLY.includes(activeCert)) {
+ setTimeout(() => {
+ updateCertificate(activeCert, {
+ status: 'done',
+ imageUrl: reader.result,
+ fields: OCR_MOCK[activeCert] || {},
+ completedAt: new Date().toISOString(),
+ });
+ }, 800);
+ } else {
+ runOCR(activeCert, reader.result);
+ }
+ };
+ reader.readAsDataURL(file);
+ e.target.value = '';
+ };
+
+ const openCert = (certType) => {
+ const cert = certs[certType];
+ if (cert?.status === 'done') {
+ setModal({ type: 'view', certType, ...cert });
+ setFormData(cert.fields || {});
+ return;
+ }
+ if (cert?.status === 'confirm') {
+ setModal({ type: 'confirm', certType, imageUrl: cert.imageUrl, fields: cert.fields });
+ setFormData(cert.fields || {});
+ return;
+ }
+ if (cert?.status === 'failed') {
+ setModal({ type: 'ocr_fail', certType, imageUrl: cert.imageUrl });
+ return;
+ }
+ showUploadOptions(certType);
+ };
+
+ const confirmCert = () => {
+ const { certType } = modal;
+ const errors = {};
+ const fields = formData;
+ if (OCR_CERT_TYPES.includes(certType)) {
+ if (!fields.name) errors.name = '请填写姓名';
+ if (certType === CERT_TYPES.ID_FRONT && !fields.idNumber) errors.idNumber = '请填写身份证号';
+ if (certType === CERT_TYPES.LICENSE_FRONT) {
+ if (!fields.licenseNumber) errors.licenseNumber = '请填写证号';
+ if (!fields.vehicleClass) errors.vehicleClass = '请选择准驾车型';
+ if (!fields.validPeriod) errors.validPeriod = '请填写有效期限';
+ }
+ if (certType === CERT_TYPES.QUALIFICATION) {
+ if (!fields.certNumber) errors.certNumber = '请填写证号';
+ if (!fields.vehicleClass) errors.vehicleClass = '请填写准驾车型';
+ if (!fields.category) errors.category = '请填写从业资格类别';
+ if (!fields.initialIssueDate) errors.initialIssueDate = '请填写初次领证日期';
+ if (!fields.validFrom) errors.validFrom = '请填写有效起始日期';
+ if (!fields.validPeriod) errors.validPeriod = '请填写有效期限';
+ }
+ }
+ setFormErrors(errors);
+ if (Object.keys(errors).length) return;
+
+ updateCertificate(certType, {
+ status: 'done',
+ fields,
+ imageUrl: modal.imageUrl || certs[certType]?.imageUrl,
+ mode: modal.mode || certs[certType]?.mode || 'ocr',
+ completedAt: new Date().toISOString(),
+ });
+ setModal(null);
+ setFormErrors({});
+ };
+
+ const startManual = () => {
+ const { certType, imageUrl } = modal;
+ updateCertificate(certType, { status: 'confirm', mode: 'manual', imageUrl, fields: {} });
+ setModal({ type: 'manual', certType, imageUrl, mode: 'manual' });
+ setFormData({});
+ };
+
+ const validateAll = () => {
+ const idFront = certs[CERT_TYPES.ID_FRONT]?.fields;
+ const license = certs[CERT_TYPES.LICENSE_FRONT]?.fields;
+
+ for (const t of requiredTypes) {
+ if (!certs[t] || certs[t].status !== 'done') {
+ setValidationError('请完成所有必填证照上传');
+ return false;
+ }
+ }
+ if (!idFront || !license) {
+ setValidationError('请完成身份证和驾驶证信息确认');
+ return false;
+ }
+ if (idFront.name !== license.name) {
+ setValidationError('身份证姓名与驾驶证姓名不一致');
+ return false;
+ }
+ if (idFront.idNumber !== license.licenseNumber) {
+ setValidationError('身份证号码与驾驶证证号不一致');
+ return false;
+ }
+ const endDate = parseValidEnd(license.validPeriod);
+ if (endDate && endDate < new Date()) {
+ setValidationError('驾驶证已过期,请更新证件');
+ return false;
+ }
+ if (license.vehicleClass !== state.trainingTask.requiredVehicleClass) {
+ setValidationError(`准驾车型不满足要求,当前车辆需要 ${state.trainingTask.requiredVehicleClass} 驾照`);
+ return false;
+ }
+ setValidationError('');
+ return true;
+ };
+
+ const modals = (
+ <>
+ setUploadSheetCert(null)}
+ />
+ {modal?.type === 'ocr_fail' && (
+ setModal(null)}>
+
e.stopPropagation()}>
+
+
证件信息识别失败,可以重新拍摄或手动填写。
+
+
+
+
+
+
+
+ )}
+ {(modal?.type === 'confirm' || modal?.type === 'manual' || modal?.type === 'view') && (
+ setModal(null)}>
+
e.stopPropagation()} style={{ maxHeight: '80vh', overflow: 'auto' }}>
+
+ {modal.type === 'view' ? '查看证照' : modal.type === 'manual' ? '手动填写' : '确认识别结果'}
+
+ {modal.imageUrl &&

}
+ {(modal.type === 'confirm' || modal.type === 'manual') && OCR_CERT_TYPES.includes(modal.certType) && (
+
setFormData((d) => ({ ...d, [key]: val }))}
+ errors={formErrors}
+ />
+ )}
+ {modal.type === 'view' && modal.fields && Object.keys(modal.fields).length > 0 && (
+
+ {Object.entries(modal.fields)
+ .filter(([k]) => k !== 'fileNumber')
+ .map(([k, v]) => (
+
+ {getCertFieldLabel(k)}
+ {v}
+
+ ))}
+
+ )}
+
+
+ {modal.type !== 'view' && (
+
+ )}
+
+
+
+ )}
+ >
+ );
+
+ const content = (
+ <>
+
+
+
+
司机证照
+
+ {isQualificationRequired(tonnage)
+ ? `当前车辆为 ${tonnage} 吨,从业资格证为必填项。`
+ : `当前车辆为 ${tonnage} 吨,从业资格证为选填项。`}
+ 点击证件卡片可选择拍照或从相册上传。
+
+ {allCertTypes.map((t) => (
+
openCert(t)}
+ />
+ ))}
+ {allDone && (
+
+ ✓
+ 证照信息已完成
+
+ )}
+ {validationError && {validationError}
}
+
+ {modals}
+ >
+ );
+
+ const footer = (
+ <>
+ {!allDone && (
+
+ 请完成全部必填证照上传({requiredDoneCount}/{requiredTypes.length})
+
+ )}
+
+ >
+ );
+
+ if (renderPart === 'content') return content;
+ if (renderPart === 'footer') return footer;
+ return (
+ <>
+ {content}
+ {footer}
+ >
+ );
+}
diff --git a/src/prototypes/xll-miniapp/driver-training/steps/Step4Video.jsx b/src/prototypes/xll-miniapp/driver-training/steps/Step4Video.jsx
new file mode 100644
index 0000000..4a5abc4
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/steps/Step4Video.jsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import TrainingVideoPlayer from '../components/TrainingVideoPlayer';
+
+export default function Step4Video({ state, updateState, completeStep, renderPart = 'all' }) {
+ const { step4, debug } = state;
+ const completed = step4.completed;
+
+ const handleComplete = () => {
+ updateState((prev) => ({
+ ...prev,
+ step4: {
+ completed: true,
+ watchedSeconds: 30,
+ completedAt: new Date().toISOString(),
+ },
+ }));
+ };
+
+ const content = (
+
+
安全培训视频
+
+ 氢能重卡安全驾驶培训 · 预计时长 30 秒
+
+
+ updateState((prev) => ({ ...prev, step4: { ...prev.step4, watchedSeconds: s } }))
+ }
+ onComplete={handleComplete}
+ debugComplete={debug.videoStatus === 'completed' && !completed}
+ />
+ {completed && (
+
+ ✓
+ 视频观看完成
+
+ )}
+
+ );
+
+ const footer = (
+ <>
+ {!completed && 请完整观看安全培训视频
}
+
+ >
+ );
+
+ if (renderPart === 'content') return content;
+ if (renderPart === 'footer') return footer;
+ return (
+ <>
+ {content}
+ {footer}
+ >
+ );
+}
diff --git a/src/prototypes/xll-miniapp/driver-training/steps/Step5Portrait.jsx b/src/prototypes/xll-miniapp/driver-training/steps/Step5Portrait.jsx
new file mode 100644
index 0000000..5e44131
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/steps/Step5Portrait.jsx
@@ -0,0 +1,60 @@
+import React from 'react';
+import CameraCapture from '../components/CameraCapture';
+
+export default function Step5Portrait({ state, updateState, completeStep, renderPart = 'all' }) {
+ const { step5, debug } = state;
+
+ const content = (
+
+
本人照片
+
+ updateState((prev) => ({
+ ...prev,
+ step5: { ...prev.step5, photoDataUrl: url, confirmed: false },
+ }))
+ }
+ onConfirm={() =>
+ updateState((prev) => ({
+ ...prev,
+ step5: {
+ ...prev.step5,
+ confirmed: true,
+ capturedAt: new Date().toISOString(),
+ },
+ }))
+ }
+ onRetake={() =>
+ updateState((prev) => ({
+ ...prev,
+ step5: { photoDataUrl: null, confirmed: false, capturedAt: null },
+ }))
+ }
+ />
+
+ );
+
+ const footer = (
+ <>
+ {!step5.confirmed && (
+ 请拍照并确认使用本人照片
+ )}
+
+ >
+ );
+
+ if (renderPart === 'content') return content;
+ if (renderPart === 'footer') return footer;
+ return (
+ <>
+ {content}
+ {footer}
+ >
+ );
+}
diff --git a/src/prototypes/xll-miniapp/driver-training/steps/Step6Signature.jsx b/src/prototypes/xll-miniapp/driver-training/steps/Step6Signature.jsx
new file mode 100644
index 0000000..a5f87ef
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/steps/Step6Signature.jsx
@@ -0,0 +1,86 @@
+import React from 'react';
+
+import { formatNow } from '../utils/formatDateTime';
+
+const DECLARATION =
+ '本人确认所提交的身份及驾驶证件信息真实有效,已完整阅读安全培训文档并观看安全培训视频,已知悉氢能车辆驾驶及应急处置要求,并确认由本人执行本次提车。';
+
+export default function Step6Signature({ state, updateState, completeStep, renderPart = 'all' }) {
+ const { step6, step3, trainingTask } = state;
+ const checked = step6.checked;
+
+ const driverName = step3.certificates?.idFront?.fields?.name || '—';
+ const idLast4 = (step3.certificates?.idFront?.fields?.idNumber || '****').slice(-4);
+ const vehicleClass = step3.certificates?.licenseFront?.fields?.vehicleClass || '—';
+
+ const content = (
+
+
签字确认
+
+
+ ① 阅读声明
+ {DECLARATION}
+
+
+
+ ② 核对信息
+
+
+ 司机姓名
+ {driverName}
+
+
+ 当前车辆
+ {trainingTask.vehiclePlate}
+
+
+ 身份证后四位
+ {idLast4}
+
+
+ 驾驶证准驾车型
+ {vehicleClass}
+
+
+ 培训完成时间
+ {formatNow()}
+
+
+
+
+
+
+ );
+
+ const footer = (
+ <>
+ {!checked ? 请勾选「本人确认以上信息及签名真实有效」
: null}
+
+ >
+ );
+
+ if (renderPart === 'content') return content;
+ if (renderPart === 'footer') return footer;
+ return (
+ <>
+ {content}
+ {footer}
+ >
+ );
+}
diff --git a/src/prototypes/xll-miniapp/driver-training/steps/Step7PickupCode.jsx b/src/prototypes/xll-miniapp/driver-training/steps/Step7PickupCode.jsx
new file mode 100644
index 0000000..66cb218
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/steps/Step7PickupCode.jsx
@@ -0,0 +1,11 @@
+import React from 'react';
+import PickupCodeCard from '../components/PickupCodeCard';
+
+export default function Step7PickupCode({ state, renderPart = 'all' }) {
+ const { step7 } = state;
+
+ const content = ;
+
+ if (renderPart === 'footer') return null;
+ return content;
+}
diff --git a/src/prototypes/xll-miniapp/driver-training/steps/Step7Signature.jsx b/src/prototypes/xll-miniapp/driver-training/steps/Step7Signature.jsx
new file mode 100644
index 0000000..ae1a3c0
--- /dev/null
+++ b/src/prototypes/xll-miniapp/driver-training/steps/Step7Signature.jsx
@@ -0,0 +1,169 @@
+import React, { useEffect, useRef, useState } from 'react';
+
+import { formatNow } from '../utils/formatDateTime';
+
+import SignatureCanvas from '../components/SignatureCanvas';
+import ConfirmDialog from '../components/ConfirmDialog';
+
+function useContainerLandscape(rootRef) {
+ const [isLandscape, setIsLandscape] = useState(false);
+
+ useEffect(() => {
+ const root = rootRef.current?.closest('.driver-training-app') || rootRef.current;
+ if (!root) return undefined;
+
+ const check = () => {
+ const { width, height } = root.getBoundingClientRect();
+ setIsLandscape(width > height * 1.05);
+ };
+
+ check();
+ const observer = new ResizeObserver(check);
+ observer.observe(root);
+ window.addEventListener('orientationchange', check);
+ return () => {
+ observer.disconnect();
+ window.removeEventListener('orientationchange', check);
+ };
+ }, [rootRef]);
+
+ return isLandscape;
+}
+
+export default function Step7Signature({
+ state,
+ updateState,
+ submitTraining,
+ goToStep,
+ renderPart = 'all',
+}) {
+ const { step6, step3, trainingTask } = state;
+ const [showSubmitDialog, setShowSubmitDialog] = useState(false);
+ const rootRef = useRef(null);
+ const canvasRef = useRef(null);
+ const isLandscape = useContainerLandscape(rootRef);
+ const hasSignature = !!step6.signatureDataUrl;
+
+ const driverName = step3.certificates?.idFront?.fields?.name || '—';
+
+ const handleSubmit = () => {
+ submitTraining();
+ setShowSubmitDialog(false);
+ };
+
+ if (step6.submitted) {
+ if (renderPart === 'footer') return null;
+ return (
+
+ );
+ }
+
+ if (renderPart === 'footer') return null;
+
+ const dialogSummary = (
+ <>
+
+ 司机
+ {driverName}
+
+
+ 车辆
+ {trainingTask.vehiclePlate}
+
+
+ 培训完成时间
+ {formatNow()}
+
+ {hasSignature && (
+
+
签名预览
+

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