Compare commits

..

4 Commits

38 changed files with 6017 additions and 228 deletions

View File

@@ -4,7 +4,6 @@
"repository": "https://github.com/lintendo/Axhub-Make/tree/main/client",
"templateUrl": "https://github.com/lintendo/Axhub-Make/releases/download/make-client-template-v0.1.4/axhub-make-client-template.zip",
"templateVersion": "0.1.4",
"makeVersion": "0.6.4",
"project": {
"id": "make-project-20260629",
"name": "小羚羚「小程序」"

View File

@@ -2,12 +2,19 @@
"version": 1,
"updatedAt": "2026-06-30T02:16:40.884Z",
"prototypes": [
{
"id": "folder-prototypes-xll-miniapp",
"kind": "folder",
"title": "小羚羚小程序",
"children": [
{
"id": "item:prototypes:xll-miniapp",
"kind": "item",
"title": "小程序",
"itemKey": "prototypes/xll-miniapp"
}
]
}
],
"docs": [],
"themesTree": [

3
.gitignore vendored
View File

@@ -121,6 +121,9 @@ storybook-static/
tmp/
temp/
# Local agent/tool cache (not part of project deliverables)
.local/
# Git version management temporary files
.git-versions/

View File

@@ -0,0 +1,56 @@
# 交车 · 被授权人空态
**模块**:小羚羚小程序 / 交车(`#page=delivery`
**代码路径**`src/prototypes/xll-miniapp/index.tsx``dvGetAuthorizedPersons` / `dvFormatEmptyAuthorizedHint` / `renderAuthorizedPersonBlock` / `validateDeliveryBeforeSign`
**数据说明**:原型本地种子;正式环境被授权人列表与业管姓名来自关联租赁合同,未接真实 API。
---
## 1. 判定优先级
| 顺序 | 条件 | 用户可见结果 | 是否可发送签章 |
| --- | --- | --- | --- |
| 1 | 关联合同被授权人列表长度为 0 | 选择区展示:`无授权人,请联系{业管姓名}补充授权人信息`;点「发送签章文件 / 重新发起签章」时 Toast 同文案 | 否 |
| 2 | 列表非空,但未选中任何人 | 展示候选人卡片Toast「请选择被授权人」 | 否 |
| 3 | 列表非空且已选中 | 选中卡片高亮,可继续签章流程 | 是 |
---
## 2. 前置条件
- 仅在交车办理页「拍摄照片」步骤的「选择被授权人」区块生效。
- 只读查看态(待客户签章 / 客户已签章)若已落库被授权人姓名,仍展示只读卡片;若无姓名且列表为空,同样展示空态提示。
- 待重新签章:列表非空时可重选;列表为空时展示空态并阻断重新发起。
---
## 3. 数据源
| 字段 | 来源(原型) | 说明 |
| --- | --- | --- |
| 被授权人列表 | 交车订单 `authorizedPersons` | 按订单挂载;`[]` 表示合同无被授权人 |
| 业管姓名 | 交车订单 `businessOwner` | 合同业务负责人;为空时文案回退为「业管」 |
| 已选被授权人 | 单车草稿 `authorizedPersonId/Name/Phone` | 发送签章前必填(列表非空时) |
演示种子:
- `o1`(桐乡韵达):`authorizedPersons: []`,业管「刘念念」——用于验收空态。
- `o4` / `o5`:有候选人列表——用于验收正常选择。
---
## 4. 文案规则
```text
无授权人,请联系{businessOwner}补充授权人信息
```
示例:`无授权人,请联系刘念念补充授权人信息`
---
## 5. 验收要点
- [ ] 打开桐乡韵达「未开始」交车单,到「选择被授权人」可见橙色空态提示,姓名为「刘念念」
- [ ] 点「发送签章文件」弹出同文案 Toast不进入 E签宝
- [ ] 聚德订单(有授权人)仍可点选卡片并发送签章

View File

@@ -0,0 +1,12 @@
{
"schemaVersion": 1,
"kind": "prototype-edit-comments",
"resource": {
"id": "xll-miniapp",
"targetPath": "prototypes/xll-miniapp",
"filePath": "src/prototypes/xll-miniapp/.spec/prototype-comments.json"
},
"comments": [],
"tasks": {},
"images": []
}

File diff suppressed because one or more lines are too long

View File

@@ -11,7 +11,7 @@
"folderId": "directory-modules",
"folderTitle": "业务模块",
"defaultExpanded": true,
"pageIds": ["audit", "inspection", "vehicle", "delivery", "third-return", "replace"]
"pageIds": ["audit", "inspection", "vehicle", "delivery", "third-return", "replace", "driver-training"]
}
],
"extraRoutes": [

View File

@@ -0,0 +1,136 @@
import React, { useCallback, useEffect } from 'react';
import { TrainingProvider, useTraining } from './context/TrainingContext';
import { TOTAL_STEPS } from './data/constants';
import { DriverTrainingLayout } from './components/DriverTrainingLayout';
import PrototypeDebugPanel from './components/PrototypeDebugPanel';
import Toast, { useToast } from './components/Toast';
import Step1Document from './steps/Step1Document';
import Step2Phone from './steps/Step2Phone';
import Step3Certificates from './steps/Step3Certificates';
import Step4Video from './steps/Step4Video';
import Step5Portrait from './steps/Step5Portrait';
import Step6Signature from './steps/Step6Signature';
import Step7Signature from './steps/Step7Signature';
import Step7PickupCode from './steps/Step7PickupCode';
import './styles/driver-training.css';
const STEPS = [
Step1Document,
Step2Phone,
Step3Certificates,
Step4Video,
Step5Portrait,
Step6Signature,
Step7Signature,
Step7PickupCode,
];
const SIGNATURE_STEP = 7;
function StepFooterBar({ currentStep, goToStep, children }) {
if (currentStep >= TOTAL_STEPS) return null;
if (currentStep <= 1) {
return <div className="dt-footer-stack">{children}</div>;
}
return (
<div className="dt-footer-stack">
<div className="dt-footer-row">
<button type="button" className="dt-btn-prev" onClick={() => goToStep(currentStep - 1)}>
上一步
</button>
<div className="dt-footer-main">{children}</div>
</div>
</div>
);
}
function getStepDetailHint(state) {
const { currentStep, step1, step4 } = state;
if (currentStep === 1 && step1.readProgress > 0 && step1.readProgress < 100) {
return `阅读 ${step1.readProgress}%`;
}
if (currentStep === 4 && !step4.completed && step4.watchedSeconds > 0) {
return `已观看 ${step4.watchedSeconds}s`;
}
if (currentStep === SIGNATURE_STEP) {
return '请横屏签字';
}
return '';
}
function DriverTrainingContent({ onRegisterBack }) {
const { state, updateState, goToStep, completeStep, stepNames, updateCertificate, submitTraining } = useTraining();
const { message, show, clear } = useToast();
const { currentStep, maxCompletedStep } = state;
const stepDetailHint = getStepDetailHint(state);
const isSignatureStep = currentStep === SIGNATURE_STEP;
useEffect(() => {
if (!onRegisterBack) return undefined;
onRegisterBack(() => {
if (currentStep > 1) {
goToStep(currentStep - 1);
return true;
}
return false;
});
return () => onRegisterBack(null);
}, [onRegisterBack, currentStep, goToStep]);
const StepComponent = STEPS[currentStep - 1] || STEPS[0];
const stepProps = {
state,
updateState,
completeStep,
goToStep,
showToast: show,
updateCertificate,
submitTraining,
};
return (
<>
<DriverTrainingLayout
currentStep={currentStep}
totalSteps={TOTAL_STEPS}
stepNames={stepNames}
maxCompletedStep={maxCompletedStep}
stepDetailHint={stepDetailHint}
embedded
immersive={isSignatureStep}
footer={
currentStep < TOTAL_STEPS && currentStep !== SIGNATURE_STEP ? (
<StepFooterBar currentStep={currentStep} goToStep={goToStep}>
<StepComponent {...stepProps} renderPart="footer" />
</StepFooterBar>
) : null
}
>
<StepComponent {...stepProps} renderPart="content" />
</DriverTrainingLayout>
<PrototypeDebugPanel />
<Toast message={message} onClose={clear} />
</>
);
}
export default function DriverTrainingModule({ onRegisterBack }) {
const registerBack = useCallback(
(handler) => {
if (typeof onRegisterBack === 'function') {
onRegisterBack(handler);
}
},
[onRegisterBack],
);
return (
<TrainingProvider>
<div className="dt-module-root" data-annotation-id="page-driver-training">
<DriverTrainingContent onRegisterBack={registerBack} />
</div>
</TrainingProvider>
);
}

View File

@@ -0,0 +1,169 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import ConfirmDialog from './ConfirmDialog';
const SAMPLE_PORTRAIT =
'data:image/svg+xml,' +
encodeURIComponent(
`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="300" viewBox="0 0 240 300">
<rect fill="#E8FFEA" width="240" height="300"/>
<circle cx="120" cy="110" r="50" fill="#C9CDD4"/>
<ellipse cx="120" cy="240" rx="70" ry="55" fill="#C9CDD4"/>
<text x="120" y="290" text-anchor="middle" fill="#86909C" font-size="12">示例照片</text>
</svg>`
);
export default function CameraCapture({
photoDataUrl,
confirmed,
onCapture,
onConfirm,
onRetake,
cameraPermission,
showDevFallback,
}) {
const videoRef = useRef(null);
const streamRef = useRef(null);
const [preview, setPreview] = useState(photoDataUrl);
const [error, setError] = useState('');
const [permissionDenied, setPermissionDenied] = useState(false);
const [showPermissionDialog, setShowPermissionDialog] = useState(false);
const stopStream = useCallback(() => {
if (streamRef.current) {
streamRef.current.getTracks().forEach((t) => t.stop());
streamRef.current = null;
}
}, []);
const startCamera = useCallback(async () => {
if (cameraPermission === 'deny') {
setPermissionDenied(true);
setShowPermissionDialog(true);
return;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } },
audio: false,
});
streamRef.current = stream;
if (videoRef.current) {
videoRef.current.srcObject = stream;
await videoRef.current.play();
}
setError('');
setPermissionDenied(false);
} catch {
setPermissionDenied(true);
setShowPermissionDialog(true);
}
}, [cameraPermission]);
useEffect(() => {
if (!preview && !confirmed) startCamera();
return () => stopStream();
}, [preview, confirmed, startCamera, stopStream]);
const handleCapture = () => {
const video = videoRef.current;
if (!video) return;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth || 640;
canvas.height = video.videoHeight || 480;
const ctx = canvas.getContext('2d');
ctx.translate(canvas.width, 0);
ctx.scale(-1, 1);
ctx.drawImage(video, 0, 0);
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
setPreview(dataUrl);
stopStream();
onCapture?.(dataUrl);
};
const handleRetake = () => {
setPreview(null);
onRetake?.();
startCamera();
};
const handleUseSample = () => {
setPreview(SAMPLE_PORTRAIT);
onCapture?.(SAMPLE_PORTRAIT);
};
if (confirmed && photoDataUrl) {
return (
<div style={{ textAlign: 'center' }}>
<img src={photoDataUrl} alt="本人照片" className="dt-portrait-preview" />
<div className="dt-success-banner" style={{ marginTop: 12 }}>
<span></span>
<span>本人照片已确认</span>
</div>
</div>
);
}
return (
<div>
<p className="dt-field-hint" style={{ marginBottom: 12 }}>
请由司机本人使用前置摄像头拍摄正面照片用于本次培训和现场交车核验
</p>
<ul className="dt-camera-tips">
<li>请正对摄像头</li>
<li>保持光线充足</li>
<li>请勿佩戴口罩或遮挡面部</li>
</ul>
{!preview ? (
<div className="dt-camera-container">
<video ref={videoRef} className="dt-camera-video" playsInline muted />
<div className="dt-camera-guide">
<div className="dt-camera-oval" />
</div>
</div>
) : (
<div style={{ textAlign: 'center' }}>
<img src={preview} alt="预览" className="dt-portrait-preview" style={{ width: '100%', maxWidth: 280, height: 'auto' }} />
</div>
)}
{error && <div className="dt-field-error">{error}</div>}
<div style={{ display: 'flex', gap: 10, marginTop: 16 }}>
{!preview ? (
<button type="button" className="dt-btn-primary" onClick={handleCapture} disabled={permissionDenied}>
拍摄
</button>
) : (
<>
<button type="button" className="dt-btn-outline" onClick={handleRetake}>
重新拍摄
</button>
<button type="button" className="dt-btn-primary" style={{ flex: 1 }} onClick={() => onConfirm?.()}>
确认使用
</button>
</>
)}
</div>
{showDevFallback && permissionDenied && (
<button type="button" className="dt-btn-secondary" style={{ marginTop: 10 }} onClick={handleUseSample}>
使用示例照片开发模式
</button>
)}
<ConfirmDialog
visible={showPermissionDialog}
title="摄像头权限"
content="无法调用摄像头,请在浏览器设置中允许摄像头权限后重试。"
cancelText="关闭"
confirmText="重试"
onCancel={() => setShowPermissionDialog(false)}
onConfirm={() => {
setShowPermissionDialog(false);
startCamera();
}}
/>
</div>
);
}

View File

@@ -0,0 +1,42 @@
import React from 'react';
import { CERT_LABELS } from '../data/constants';
const STATUS_TEXT = {
pending: '待上传',
uploading: '上传中',
recognizing: '识别中',
confirm: '待确认',
done: '已完成',
failed: '识别失败',
};
export default function CertificateUploadCard({ certType, required, status, onClick, imageUrl }) {
const label = CERT_LABELS[certType];
const isDone = status === 'done';
const isFailed = status === 'failed';
return (
<div
className={`dt-cert-card ${isDone ? 'done' : ''} ${isFailed ? 'error' : ''}`}
onClick={onClick}
role="button"
tabIndex={0}
onKeyDown={(e) => e.key === 'Enter' && onClick?.()}
>
<div className="dt-cert-card-header">
<span className="dt-cert-card-title">{label}</span>
{required ? (
<span className="dt-cert-badge">必填</span>
) : (
<span className="dt-cert-badge optional">选填</span>
)}
</div>
<div className="dt-cert-status">{STATUS_TEXT[status] || '待上传'}</div>
{imageUrl && (
<div className="dt-cert-preview">
<img src={imageUrl} alt={label} className="dt-cert-thumb" />
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,33 @@
import React from 'react';
export default function ConfirmDialog({
visible,
title,
content,
note,
summary,
onCancel,
onConfirm,
cancelText = '取消',
confirmText = '确认',
}) {
if (!visible) return null;
return (
<div className="dt-modal-overlay" onClick={onCancel}>
<div className="dt-modal dt-modal--summary" onClick={(e) => e.stopPropagation()}>
<h3 className="dt-modal-title">{title}</h3>
{summary ? <div className="dt-modal-summary">{summary}</div> : null}
{note ? <p className="dt-modal-note">{note}</p> : null}
{content ? <p className="dt-modal-content">{content}</p> : null}
<div className="dt-modal-actions">
<button type="button" className="dt-btn-secondary" onClick={onCancel}>
{cancelText}
</button>
<button type="button" className="dt-btn-primary" onClick={onConfirm}>
{confirmText}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,116 @@
import React from 'react';
const TRAINING_PHASES = [
{ id: 'prep', label: '身份与须知', steps: [1, 2] },
{ id: 'verify', label: '资质与培训', steps: [3, 4, 5] },
{ id: 'confirm', label: '签署与提车', steps: [6, 7, 8] },
];
function getPhaseStatus(phase, currentStep) {
const maxStep = Math.max(...phase.steps);
const minStep = Math.min(...phase.steps);
if (currentStep > maxStep) return 'done';
if (currentStep >= minStep && currentStep <= maxStep) return 'active';
return 'pending';
}
function TrainingPhaseBar({ currentStep }) {
return (
<div className="dt-phase-bar" role="list" aria-label="培训阶段">
{TRAINING_PHASES.map((phase, index) => {
const status = getPhaseStatus(phase, currentStep);
return (
<React.Fragment key={phase.id}>
{index > 0 && (
<div
className={`dt-phase-connector ${status === 'done' || status === 'active' ? 'done' : ''}`}
/>
)}
<div className={`dt-phase-item dt-phase-item--${status}`} role="listitem">
<span className="dt-phase-icon">
{status === 'done' ? '✓' : index + 1}
</span>
<span className="dt-phase-label">{phase.label}</span>
</div>
</React.Fragment>
);
})}
</div>
);
}
export function TrainingStepHeader({
currentStep,
totalSteps = 8,
stepNames,
stepDetailHint = '',
}) {
const isComplete = currentStep >= totalSteps;
const progressPercent = (currentStep / totalSteps) * 100;
return (
<div className={`dt-step-header ${isComplete ? 'dt-step-header--complete' : ''}`}>
<div className="dt-step-indicator">
<span className="dt-step-text">{isComplete ? '培训已完成' : '培训进度'}</span>
<span className="dt-step-count">
步骤 {currentStep}/{totalSteps}
</span>
</div>
<TrainingPhaseBar currentStep={currentStep} />
<div className="dt-progress-bar" aria-hidden="true">
<div
className="dt-progress-fill"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="dt-step-meta">
<span className="dt-step-name">{stepNames[currentStep - 1]}</span>
{stepDetailHint ? (
<span className="dt-step-detail-hint">{stepDetailHint}</span>
) : null}
</div>
</div>
);
}
export function DriverTrainingLayout({
currentStep,
totalSteps = 8,
stepNames,
stepDetailHint,
children,
footer,
embedded = false,
immersive = false,
}) {
return (
<div
className={`driver-training-app${embedded ? ' driver-training-app--embedded' : ''}${immersive ? ' driver-training-app--signature-immersive' : ''}`}
>
<div className="dt-page">
{!embedded && (
<header className="dt-header">
<div className="dt-header-bar">
<h1 className="dt-header-title">司机安全培训</h1>
</div>
</header>
)}
<div className={`dt-content${immersive ? ' dt-content--signature-immersive' : ''}`}>
{!immersive ? (
<TrainingStepHeader
currentStep={currentStep}
totalSteps={totalSteps}
stepNames={stepNames}
stepDetailHint={stepDetailHint}
/>
) : null}
{children}
</div>
{footer && <div className={`dt-footer${immersive ? ' dt-footer--signature-immersive' : ''}`}>{footer}</div>}
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import React from 'react';
import { VEHICLE_CLASS_OPTIONS } from '../data/constants';
const FIELD_CONFIG = {
idFront: [
{ key: 'name', label: '姓名', required: true },
{ key: 'idNumber', label: '身份证号', required: true },
],
idBack: [{ key: 'validPeriod', label: '有效期限', required: true }],
licenseFront: [
{ key: 'name', label: '姓名', required: true },
{ key: 'licenseNumber', label: '证号', required: true },
{ key: 'vehicleClass', label: '准驾车型', required: true, inputType: 'select', options: VEHICLE_CLASS_OPTIONS },
{ key: 'validPeriod', label: '有效期限', required: true },
],
qualification: [
{ key: 'name', label: '姓名', required: true },
{ key: 'gender', label: '性别', required: false },
{ key: 'birthDate', label: '出生日期', required: false },
{ key: 'nationality', label: '国籍', required: false },
{ key: 'address', label: '住址', required: false },
{ key: 'certNumber', label: '证号', required: true },
{ key: 'vehicleClass', label: '准驾车型', required: true },
{ key: 'category', label: '从业资格类别', required: true },
{ key: 'initialIssueDate', label: '初次领证日期', required: true },
{ key: 'validFrom', label: '有效起始日期', required: true },
{ key: 'validPeriod', label: '有效期限', required: true },
],
};
export const CERT_FIELD_LABELS = Object.fromEntries(
Object.values(FIELD_CONFIG)
.flat()
.map((f) => [f.key, f.label]),
);
export function getCertFieldLabel(key) {
return CERT_FIELD_LABELS[key] || key;
}
export default function OCRResultForm({ certType, data, onChange, errors = {} }) {
const fields = FIELD_CONFIG[certType];
if (!fields) return null;
return (
<div className="dt-ocr-form">
{fields.map((f) => (
<div className="dt-form-group" key={f.key}>
<label className="dt-label">
{f.label}
{f.required && <span style={{ color: '#F53F3F' }}> *</span>}
</label>
{f.inputType === 'select' ? (
<select
className={`dt-input dt-select ${errors[f.key] ? 'error' : ''}`}
value={data[f.key] || ''}
onChange={(e) => onChange(f.key, e.target.value)}
>
<option value="">请选择{f.label}</option>
{(f.options || []).map((code) => (
<option key={code} value={code}>
{code}
</option>
))}
</select>
) : (
<input
className={`dt-input ${errors[f.key] ? 'error' : ''}`}
value={data[f.key] || ''}
onChange={(e) => onChange(f.key, e.target.value)}
/>
)}
{errors[f.key] && <div className="dt-field-error">{errors[f.key]}</div>}
</div>
))}
</div>
);
}
export function ManualCertificateForm({ certType, data, onChange, errors = {} }) {
return (
<div>
<div className="dt-manual-tip">请根据证件原件准确填写相关信息将用于本次交车核验</div>
<OCRResultForm certType={certType} data={data} onChange={onChange} errors={errors} />
</div>
);
}

View File

@@ -0,0 +1,137 @@
import React, { useEffect, useState } from 'react';
import { DEMO_PHONE, MOCK_SMS_CODE } from '../data/constants';
const PHONE_REG = /^1[3-9]\d{9}$/;
export default function PhoneVerificationForm({
value,
onChange,
onVerified,
showActions = true,
}) {
const phone = value.phone || DEMO_PHONE;
const code = value.code || MOCK_SMS_CODE;
const [phoneError, setPhoneError] = useState('');
const [codeError, setCodeError] = useState('');
const [countdown, setCountdown] = useState(0);
const [sending, setSending] = useState(false);
const [success, setSuccess] = useState(false);
useEffect(() => {
if (countdown <= 0) return undefined;
const t = setTimeout(() => setCountdown((c) => c - 1), 1000);
return () => clearTimeout(t);
}, [countdown]);
const patch = (data) => onChange?.({ phone, code, ...value, ...data });
const handleSendCode = () => {
if (!PHONE_REG.test(phone)) {
setPhoneError('请输入正确的11位手机号');
return;
}
setPhoneError('');
setSending(true);
setTimeout(() => {
setSending(false);
setCountdown(60);
}, 500);
};
const handleVerify = () => {
if (!PHONE_REG.test(phone)) {
setPhoneError('请输入正确的11位手机号');
return;
}
setPhoneError('');
if (code !== MOCK_SMS_CODE) {
setCodeError('验证码错误,请重新输入。');
return;
}
setCodeError('');
setSuccess(true);
onChange?.({
phone,
code,
verified: true,
verifiedAt: new Date().toISOString(),
});
setTimeout(() => onVerified?.(), 800);
};
if (success) {
return (
<div className="dt-success-banner">
<span></span>
<span>手机号验证成功</span>
</div>
);
}
return (
<div>
<div className="dt-form-group">
<label className="dt-label">手机号</label>
<input
className={`dt-input ${phoneError ? 'error' : ''}`}
type="tel"
maxLength={11}
placeholder="请输入手机号"
value={phone}
onChange={(e) => {
patch({ phone: e.target.value.replace(/\D/g, '') });
setPhoneError('');
}}
/>
{phoneError && <div className="dt-field-error">{phoneError}</div>}
</div>
<div className="dt-form-group">
<label className="dt-label">验证码</label>
<div className="dt-code-row">
<input
className={`dt-input ${codeError ? 'error' : ''}`}
type="text"
maxLength={6}
placeholder="请输入验证码"
value={code}
onChange={(e) => {
patch({ code: e.target.value.replace(/\D/g, '') });
setCodeError('');
}}
/>
<button
type="button"
className="dt-code-btn"
disabled={countdown > 0 || sending}
onClick={handleSendCode}
>
{sending ? '发送中...' : countdown > 0 ? `${countdown}s` : '获取验证码'}
</button>
</div>
{codeError && <div className="dt-field-error">{codeError}</div>}
</div>
<p className="dt-field-hint">
手机号用于绑定本次培训记录和接收培训状态通知
<br />
演示环境已预填手机号与验证码{MOCK_SMS_CODE}可直接验证继续
</p>
{showActions && (
<button type="button" className="dt-btn-primary" style={{ marginTop: 16 }} onClick={handleVerify}>
验证并继续
</button>
)}
</div>
);
}
export function verifyPhoneStep(step2) {
const phone = step2.phone || DEMO_PHONE;
const code = step2.code || MOCK_SMS_CODE;
if (!PHONE_REG.test(phone)) {
return { ok: false, phoneError: '请输入正确的11位手机号' };
}
if (code !== MOCK_SMS_CODE) {
return { ok: false, codeError: '验证码错误,请重新输入。' };
}
return { ok: true, phone, code };
}

View File

@@ -0,0 +1,89 @@
import React, { useEffect, useRef } from 'react';
import StatusTag from './StatusTag';
import { PICKUP_STATUS } from '../data/constants';
function drawQRCode(canvas, text) {
const ctx = canvas.getContext('2d');
const size = 160;
canvas.width = size;
canvas.height = size;
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, size, size);
const hash = text.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
const cell = 8;
const cells = size / cell;
for (let y = 0; y < cells; y++) {
for (let x = 0; x < cells; x++) {
const inCorner =
(x < 9 && y < 9) || (x > cells - 10 && y < 9) || (x < 9 && y > cells - 10);
const val = (hash * (x + 1) * (y + 1) + x * y) % 7;
if (inCorner) {
if (
(x === 1 || x === 7 || x === cells - 8 || x === cells - 2) &&
(y === 1 || y === 7)
) {
ctx.fillStyle = '#1D2129';
ctx.fillRect(x * cell, y * cell, cell, cell);
} else if (x >= 2 && x <= 6 && y >= 2 && y <= 6) {
ctx.fillStyle = '#1D2129';
ctx.fillRect(x * cell, y * cell, cell, cell);
}
} else if (val < 3) {
ctx.fillStyle = '#1D2129';
ctx.fillRect(x * cell, y * cell, cell, cell);
}
}
}
}
const STATUS_HINTS = {
[PICKUP_STATUS.PENDING]: '请向运维人员出示二维码',
[PICKUP_STATUS.LOCKED]: '运维已核验,正在办理交车',
[PICKUP_STATUS.PROCESSING]: '等待双方完成电子签署',
[PICKUP_STATUS.COMPLETED]: '交车已完成,提车码已失效',
[PICKUP_STATUS.REVOKED]: '交车任务已取消,提车码不可使用',
[PICKUP_STATUS.EXPIRED]: '提车码已失效',
};
export default function PickupCodeCard({ step7 }) {
const canvasRef = useRef(null);
const isDisabled =
step7.status === PICKUP_STATUS.COMPLETED ||
step7.status === PICKUP_STATUS.REVOKED ||
step7.status === PICKUP_STATUS.EXPIRED;
useEffect(() => {
if (canvasRef.current && step7.pickupCode) {
drawQRCode(canvasRef.current, step7.pickupCode);
}
}, [step7.pickupCode]);
const hint = STATUS_HINTS[step7.status];
return (
<div className="dt-pickup-page">
<div className="dt-pickup-success dt-animate-fade-in">
<div className="dt-pickup-icon"></div>
<h2 className="dt-pickup-title">安全培训已完成</h2>
<p className="dt-pickup-subtitle">请向运维人员出示下方提车码</p>
</div>
<div className="dt-card dt-qr-card dt-animate-scale-in">
<div className="dt-qr-card-header">
<StatusTag status={step7.status} />
{hint ? <p className="dt-qr-status-hint">{hint}</p> : null}
</div>
<div className="dt-qr-frame">
<canvas ref={canvasRef} className={`dt-qr-code ${isDisabled ? 'disabled' : ''}`} />
{isDisabled && <div className="dt-qr-overlay">已失效</div>}
</div>
<div className="dt-warning-tip">提车码仅限本次交车使用请勿转发给他人</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,78 @@
import React, { useState } from 'react';
import { useTraining } from '../context/TrainingContext';
export default function PrototypeDebugPanel() {
const [open, setOpen] = useState(false);
const { state, applyDebugSettings, resetAll } = useTraining();
const { debug } = state;
return (
<div className="dt-debug-panel">
{open && (
<div className="dt-debug-body">
<div className="dt-debug-section">
<label>车辆吨位</label>
<select
value={debug.vehicleTonnage}
onChange={(e) => applyDebugSettings({ vehicleTonnage: Number(e.target.value) })}
>
<option value={4.5}>4.5 </option>
<option value={18}>18 </option>
<option value={49}>49 </option>
</select>
</div>
<div className="dt-debug-section">
<label>OCR 结果</label>
<select
value={debug.ocrMode}
onChange={(e) => applyDebugSettings({ ocrMode: e.target.value })}
>
<option value="success">识别成功</option>
<option value="fail">识别失败</option>
<option value="mismatch">字段不一致</option>
</select>
</div>
<div className="dt-debug-section">
<label>摄像头权限</label>
<select
value={debug.cameraPermission}
onChange={(e) => applyDebugSettings({ cameraPermission: e.target.value })}
>
<option value="allow">允许</option>
<option value="deny">拒绝</option>
</select>
</div>
<div className="dt-debug-section">
<label>视频状态</label>
<select
value={debug.videoStatus}
onChange={(e) => applyDebugSettings({ videoStatus: e.target.value })}
>
<option value="incomplete">未完成</option>
<option value="completed">已完成</option>
</select>
</div>
<div className="dt-debug-section">
<label>提车状态</label>
<select
value={debug.pickupStatus}
onChange={(e) => applyDebugSettings({ pickupStatus: e.target.value })}
>
<option value="pending">待核验</option>
<option value="locked">已锁定</option>
<option value="single_sign">单方签字</option>
<option value="both_signed">双方签字完成</option>
<option value="revoked">任务撤销</option>
</select>
</div>
<button type="button" className="dt-debug-reset" onClick={resetAll}>
重置全部培训数据
</button>
</div>
)}
<button type="button" className="dt-debug-toggle" onClick={() => setOpen(!open)} title="原型控制台">
{open ? '×' : '调试'}
</button>
</div>
);
}

View File

@@ -0,0 +1,165 @@
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
const SignatureCanvas = forwardRef(function SignatureCanvas(
{ value, onChange, onClear, variant = 'default', hideActions = false, watermark = '' },
ref,
) {
const canvasRef = useRef(null);
const wrapRef = useRef(null);
const drawing = useRef(false);
const [hasStroke, setHasStroke] = useState(!!value);
const isEsign = variant === 'esign';
const isFullscreen = variant === 'fullscreen' || isEsign;
const valueRef = useRef(value);
valueRef.current = value;
const setupCanvas = useCallback(() => {
const canvas = canvasRef.current;
const wrap = wrapRef.current;
if (!canvas || !wrap) return;
const rect = wrap.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const savedValue = valueRef.current;
canvas.width = rect.width * 2;
canvas.height = rect.height * 2;
const ctx = canvas.getContext('2d');
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.scale(2, 2);
ctx.strokeStyle = '#1D2129';
ctx.lineWidth = isEsign ? 3 : isFullscreen ? 2.5 : 2;
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.clearRect(0, 0, rect.width, rect.height);
if (savedValue) {
const img = new Image();
img.onload = () => ctx.drawImage(img, 0, 0, rect.width, rect.height);
img.src = savedValue;
}
}, [isEsign, isFullscreen]);
const handleClear = useCallback(() => {
const canvas = canvasRef.current;
const wrap = wrapRef.current;
if (!canvas || !wrap) return;
const ctx = canvas.getContext('2d');
const rect = wrap.getBoundingClientRect();
ctx.clearRect(0, 0, rect.width, rect.height);
setHasStroke(false);
onChange?.(null);
onClear?.();
}, [onChange, onClear]);
useImperativeHandle(ref, () => ({
clear: handleClear,
hasStroke: () => hasStroke,
}));
useEffect(() => {
setupCanvas();
const wrap = wrapRef.current;
if (!wrap || typeof ResizeObserver === 'undefined') return undefined;
const observer = new ResizeObserver(() => setupCanvas());
observer.observe(wrap);
return () => observer.disconnect();
}, [setupCanvas]);
useEffect(() => {
if (!drawing.current) {
setupCanvas();
}
}, [value, setupCanvas]);
useEffect(() => {
setHasStroke(!!value);
}, [value]);
const getPos = (e, canvas) => {
const rect = canvas.getBoundingClientRect();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
return { x: clientX - rect.left, y: clientY - rect.top };
};
const startDraw = (e) => {
e.preventDefault();
drawing.current = true;
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const { x, y } = getPos(e, canvas);
ctx.beginPath();
ctx.moveTo(x, y);
};
const draw = (e) => {
if (!drawing.current) return;
e.preventDefault();
const canvas = canvasRef.current;
const ctx = canvas.getContext('2d');
const { x, y } = getPos(e, canvas);
ctx.lineTo(x, y);
ctx.stroke();
setHasStroke(true);
};
const endDraw = () => {
if (!drawing.current) return;
drawing.current = false;
const canvas = canvasRef.current;
onChange?.(canvas.toDataURL('image/png'));
};
const placeholderText = isEsign ? watermark || '请在此处签名' : isFullscreen ? '请横屏在此处签名' : '请在此处签名';
return (
<div
className={`dt-signature-wrap ${isEsign ? 'dt-signature-wrap--esign' : ''} ${isFullscreen && !isEsign ? 'dt-signature-wrap--fullscreen' : ''}`}
>
<div
ref={wrapRef}
className={`dt-signature-pad ${hasStroke ? 'dt-signature-pad--signed' : ''} ${isEsign ? 'dt-signature-pad--esign' : ''} ${isFullscreen && !isEsign ? 'dt-signature-pad--fullscreen' : ''}`}
>
{!hasStroke && !isEsign && (
<span className="dt-signature-placeholder" aria-hidden="true">
{placeholderText}
</span>
)}
{isEsign && !hasStroke && (
<span className="dt-signature-watermark" aria-hidden="true">
{watermark || '请在此处签名'}
</span>
)}
<div className={`dt-signature-baseline ${isEsign ? 'dt-signature-baseline--esign' : ''}`} aria-hidden="true" />
<canvas
ref={canvasRef}
className={`dt-signature-canvas ${isEsign ? 'dt-signature-canvas--esign' : ''} ${isFullscreen && !isEsign ? 'dt-signature-canvas--fullscreen' : ''}`}
onMouseDown={startDraw}
onMouseMove={draw}
onMouseUp={endDraw}
onMouseLeave={endDraw}
onTouchStart={startDraw}
onTouchMove={draw}
onTouchEnd={endDraw}
/>
</div>
{!hideActions && (
<div className={`dt-signature-actions ${isFullscreen ? 'dt-signature-actions--fullscreen' : ''}`}>
<button type="button" className="dt-btn-secondary dt-signature-clear" onClick={handleClear}>
清除重签
</button>
{hasStroke && (
<span className="dt-signature-done-badge">
<span aria-hidden="true"></span> 已签名
</span>
)}
</div>
)}
</div>
);
});
export default SignatureCanvas;

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { PICKUP_STATUS, PICKUP_STATUS_LABELS } from '../data/constants';
const STATUS_CLASS = {
[PICKUP_STATUS.PENDING]: 'pending',
[PICKUP_STATUS.LOCKED]: 'locked',
[PICKUP_STATUS.PROCESSING]: 'processing',
[PICKUP_STATUS.COMPLETED]: 'completed',
[PICKUP_STATUS.EXPIRED]: 'expired',
[PICKUP_STATUS.REVOKED]: 'revoked',
};
export default function StatusTag({ status }) {
const cls = STATUS_CLASS[status] || 'pending';
const label = PICKUP_STATUS_LABELS[status] || status;
return <span className={`dt-status-tag ${cls}`}>{label}</span>;
}

View File

@@ -0,0 +1,28 @@
import React, { useEffect, useState } from 'react';
let toastTimer = null;
export default function Toast({ message, onClose }) {
const [visible, setVisible] = useState(!!message);
useEffect(() => {
if (!message) return undefined;
setVisible(true);
if (toastTimer) clearTimeout(toastTimer);
toastTimer = setTimeout(() => {
setVisible(false);
onClose?.();
}, 2500);
return () => clearTimeout(toastTimer);
}, [message, onClose]);
if (!visible || !message) return null;
return <div className="dt-toast">{message}</div>;
}
export function useToast() {
const [message, setMessage] = useState('');
const show = (msg) => setMessage(msg);
const clear = () => setMessage('');
return { message, show, clear };
}

View File

@@ -0,0 +1,31 @@
import React, { useCallback, useEffect, useRef } from 'react';
export default function TrainingDocumentViewer({ content, onProgress, onReachBottom, startTime, onStart }) {
const ref = useRef(null);
const started = useRef(false);
const handleScroll = useCallback(() => {
const el = ref.current;
if (!el) return;
if (!started.current) {
started.current = true;
onStart?.();
}
const { scrollTop, scrollHeight, clientHeight } = el;
const progress = Math.min(100, Math.round((scrollTop / (scrollHeight - clientHeight || 1)) * 100));
onProgress?.(progress);
if (scrollTop + clientHeight >= scrollHeight - 8) {
onReachBottom?.();
}
}, [onProgress, onReachBottom, onStart]);
useEffect(() => {
handleScroll();
}, [handleScroll, content]);
return (
<div ref={ref} className="dt-document-viewer" onScroll={handleScroll}>
{content}
</div>
);
}

View File

@@ -0,0 +1,106 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
const DURATION = 30;
function IconPlay() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M8 5v14l11-7z" />
</svg>
);
}
function IconPause() {
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
</svg>
);
}
export default function TrainingVideoPlayer({ completed, watchedSeconds, onProgress, onComplete, debugComplete }) {
const [playing, setPlaying] = useState(false);
const [current, setCurrent] = useState(watchedSeconds || 0);
const timerRef = useRef(null);
const stopTimer = useCallback(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
}, []);
useEffect(() => {
const handleVisibility = () => {
if (document.hidden) setPlaying(false);
};
document.addEventListener('visibilitychange', handleVisibility);
return () => document.removeEventListener('visibilitychange', handleVisibility);
}, []);
useEffect(() => {
if (!playing || completed) {
stopTimer();
return undefined;
}
timerRef.current = setInterval(() => {
setCurrent((prev) => {
const next = Math.min(prev + 1, DURATION);
onProgress?.(next);
if (next >= DURATION) {
setPlaying(false);
onComplete?.();
}
return next;
});
}, 1000);
return () => stopTimer();
}, [playing, completed, onProgress, onComplete, stopTimer]);
useEffect(() => {
if (debugComplete && !completed) {
setCurrent(DURATION);
onProgress?.(DURATION);
onComplete?.();
}
}, [debugComplete, completed, onProgress, onComplete]);
useEffect(() => {
if (completed) setCurrent(DURATION);
}, [completed]);
const togglePlay = () => {
if (completed) return;
setPlaying((p) => !p);
};
const progress = (current / DURATION) * 100;
const formatTime = (s) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
return (
<div className="dt-video-player">
<div className="dt-video-screen">
<div className="dt-video-overlay">
<div style={{ fontSize: 18, fontWeight: 600 }}>氢能重卡安全驾驶培训</div>
<div style={{ fontSize: 13, opacity: 0.8 }}>预计时长 30 </div>
{!playing && !completed && (
<button type="button" className="dt-video-play-btn" onClick={togglePlay} style={{ width: 56, height: 56, fontSize: 24 }}>
<IconPlay />
</button>
)}
</div>
</div>
<div className="dt-video-controls">
<button type="button" className="dt-video-play-btn" onClick={togglePlay} disabled={completed}>
{playing ? <IconPause /> : <IconPlay />}
</button>
<div className="dt-video-progress">
<div className="dt-video-progress-fill" style={{ width: `${progress}%` }} />
</div>
<span style={{ color: '#fff', fontSize: 12, flexShrink: 0 }}>
{formatTime(current)}/{formatTime(DURATION)}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import React from 'react';
import { CERT_LABELS } from '../data/constants';
export default function UploadSourceSheet({ visible, certType, onCamera, onAlbum, onCancel }) {
if (!visible || !certType) return null;
const label = CERT_LABELS[certType] || '证件';
return (
<div className="dt-sheet-overlay" onClick={onCancel}>
<div className="dt-sheet" onClick={(e) => e.stopPropagation()}>
<div className="dt-sheet-title">上传{label}</div>
<p className="dt-sheet-desc">请选择获取证件照片的方式</p>
<button type="button" className="dt-sheet-option" onClick={onCamera}>
<span className="dt-sheet-option-icon" aria-hidden>📷</span>
<span className="dt-sheet-option-text">
<strong>拍照上传</strong>
<small>调用摄像头拍摄证件</small>
</span>
</button>
<button type="button" className="dt-sheet-option" onClick={onAlbum}>
<span className="dt-sheet-option-icon" aria-hidden>🖼</span>
<span className="dt-sheet-option-text">
<strong>从相册选择</strong>
<small>选择本地已保存的证件照片</small>
</span>
</button>
<button type="button" className="dt-sheet-cancel" onClick={onCancel}>
取消
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,227 @@
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import {
DOCUMENT_VERSION,
PICKUP_STATUS,
STEP_NAMES,
TOTAL_STEPS,
} from '../data/constants';
import {
createInitialState,
generatePickupCode,
loadTrainingState,
resetTrainingState,
saveTrainingState,
} from '../data/storage';
const TrainingContext = createContext(null);
function isQualificationRequired(tonnage) {
return tonnage > 18;
}
function getRequiredCertTypes(tonnage) {
const base = ['idFront', 'idBack', 'licenseFront', 'licenseBack'];
if (isQualificationRequired(tonnage)) base.push('qualification');
return base;
}
export { isQualificationRequired, getRequiredCertTypes };
export function TrainingProvider({ children }) {
const [state, setState] = useState(() => loadTrainingState());
useEffect(() => {
saveTrainingState(state);
}, [state]);
const updateState = useCallback((updater) => {
setState((prev) => {
const next = typeof updater === 'function' ? updater(prev) : { ...prev, ...updater };
return next;
});
}, []);
const invalidateAfterStep3 = useCallback((prev) => ({
...prev,
step6: {
signatureDataUrl: null,
checked: false,
submitted: false,
submittedAt: null,
},
step7: {
pickupCode: null,
numericCode: null,
status: PICKUP_STATUS.PENDING,
generatedAt: null,
completedAt: null,
lockedAt: null,
},
maxCompletedStep: Math.min(prev.maxCompletedStep, 5),
currentStep: Math.min(prev.currentStep, 6),
}), []);
const goToStep = useCallback((step) => {
setState((prev) => {
if (step > prev.maxCompletedStep + 1) return prev;
return { ...prev, currentStep: step };
});
}, []);
const completeStep = useCallback((step) => {
setState((prev) => ({
...prev,
maxCompletedStep: Math.max(prev.maxCompletedStep, step),
currentStep: Math.min(step + 1, TOTAL_STEPS),
}));
}, []);
const resetAll = useCallback(() => {
const fresh = resetTrainingState();
setState(fresh);
return fresh;
}, []);
const applyDebugSettings = useCallback((debugPatch) => {
setState((prev) => {
let next = {
...prev,
debug: { ...prev.debug, ...debugPatch },
};
if (debugPatch.vehicleTonnage !== undefined) {
next.trainingTask = {
...next.trainingTask,
vehicleTonnage: debugPatch.vehicleTonnage,
vehicleType:
debugPatch.vehicleTonnage >= 49
? '49吨氢能重卡'
: debugPatch.vehicleTonnage >= 18
? '18吨氢能轻卡'
: '4.5吨氢能物流车',
};
if (prev.step7.pickupCode) {
next = invalidateAfterStep3(next);
next.step7 = { ...next.step7, status: PICKUP_STATUS.REVOKED };
}
}
if (debugPatch.pickupStatus !== undefined) {
const statusMap = {
pending: PICKUP_STATUS.PENDING,
locked: PICKUP_STATUS.LOCKED,
single_sign: PICKUP_STATUS.PROCESSING,
both_signed: PICKUP_STATUS.COMPLETED,
revoked: PICKUP_STATUS.REVOKED,
};
const status = statusMap[debugPatch.pickupStatus] || PICKUP_STATUS.PENDING;
next.step7 = {
...next.step7,
status,
pickupCode: next.step7.pickupCode || `PK-${next.trainingTask.taskId}`,
numericCode: next.step7.numericCode || generatePickupCode(),
generatedAt: next.step7.generatedAt || new Date().toISOString(),
lockedAt: status !== PICKUP_STATUS.PENDING ? new Date().toISOString() : null,
completedAt: status === PICKUP_STATUS.COMPLETED ? new Date().toISOString() : null,
};
if (status === PICKUP_STATUS.COMPLETED || status === PICKUP_STATUS.REVOKED) {
next.step7.status = status;
}
}
if (debugPatch.videoStatus === 'completed') {
next.step4 = {
completed: true,
watchedSeconds: 30,
completedAt: new Date().toISOString(),
};
} else if (debugPatch.videoStatus === 'incomplete') {
next.step4 = { completed: false, watchedSeconds: 0, completedAt: null };
next.maxCompletedStep = Math.min(next.maxCompletedStep, 3);
}
return next;
});
}, [invalidateAfterStep3]);
const updateCertificate = useCallback((certType, data) => {
setState((prev) => {
let next = {
...prev,
step3: {
...prev.step3,
certificates: {
...prev.step3.certificates,
[certType]: { ...prev.step3.certificates[certType], ...data },
},
},
};
if (prev.step6.submitted || prev.step7.pickupCode) {
next = invalidateAfterStep3(next);
}
return next;
});
}, [invalidateAfterStep3]);
const submitTraining = useCallback(() => {
setState((prev) => {
const numericCode = generatePickupCode();
const now = new Date().toISOString();
return {
...prev,
step6: {
...prev.step6,
submitted: true,
submittedAt: now,
},
step7: {
pickupCode: `PK-${prev.trainingTask.taskId}-${numericCode}`,
numericCode,
status: PICKUP_STATUS.PENDING,
generatedAt: now,
completedAt: null,
lockedAt: null,
},
maxCompletedStep: TOTAL_STEPS,
currentStep: TOTAL_STEPS,
};
});
}, []);
const value = useMemo(
() => ({
state,
updateState,
goToStep,
completeStep,
resetAll,
applyDebugSettings,
updateCertificate,
submitTraining,
isQualificationRequired,
getRequiredCertTypes,
stepNames: STEP_NAMES,
documentVersion: DOCUMENT_VERSION,
}),
[
state,
updateState,
goToStep,
completeStep,
resetAll,
applyDebugSettings,
updateCertificate,
submitTraining,
]
);
return <TrainingContext.Provider value={value}>{children}</TrainingContext.Provider>;
}
export function useTraining() {
const ctx = useContext(TrainingContext);
if (!ctx) throw new Error('useTraining must be used within TrainingProvider');
return ctx;
}
export { createInitialState };

View File

@@ -0,0 +1,146 @@
export const THEME = {
primary: '#7AB929',
primaryLight: 'rgba(122, 185, 41, 0.14)',
bg: '#F7F8FA',
card: '#FFFFFF',
title: '#1D2129',
text: '#4E5969',
textSecondary: '#86909C',
border: '#E5E6EB',
danger: '#F53F3F',
warning: '#FF7D00',
};
export const STORAGE_KEY = 'xll_driver_training_state_v1';
export const DOCUMENT_VERSION = 'V2.1.0-20260701';
export const MOCK_SMS_CODE = '123456';
/** 原型演示用手机号(虚构脱敏) */
export const DEMO_PHONE = '13812345678';
export const STEP_NAMES = [
'阅读培训文档',
'手机号验证',
'司机证照',
'安全培训视频',
'本人照片',
'信息确认',
'手写签名',
'生成提车码',
];
export const TOTAL_STEPS = STEP_NAMES.length;
export const CERT_TYPES = {
ID_FRONT: 'idFront',
ID_BACK: 'idBack',
LICENSE_FRONT: 'licenseFront',
LICENSE_BACK: 'licenseBack',
QUALIFICATION: 'qualification',
};
export const CERT_LABELS = {
[CERT_TYPES.ID_FRONT]: '身份证正面',
[CERT_TYPES.ID_BACK]: '身份证反面',
[CERT_TYPES.LICENSE_FRONT]: '驾驶证正页',
[CERT_TYPES.LICENSE_BACK]: '驾驶证副页',
[CERT_TYPES.QUALIFICATION]: '从业资格证',
};
export const PICKUP_STATUS = {
PENDING: 'pending',
LOCKED: 'locked',
PROCESSING: 'processing',
COMPLETED: 'completed',
EXPIRED: 'expired',
REVOKED: 'revoked',
};
export const PICKUP_STATUS_LABELS = {
[PICKUP_STATUS.PENDING]: '待核验',
[PICKUP_STATUS.LOCKED]: '已锁定',
[PICKUP_STATUS.PROCESSING]: '交车处理中',
[PICKUP_STATUS.COMPLETED]: '已完成',
[PICKUP_STATUS.EXPIRED]: '已失效',
[PICKUP_STATUS.REVOKED]: '已撤销',
};
export const OCR_MOCK = {
idFront: {
name: '张建国',
idNumber: '3304**********1234',
},
idBack: {
validPeriod: '2024.01.01—2044.01.01',
},
licenseFront: {
name: '张建国',
licenseNumber: '3304**********1234',
vehicleClass: 'A2',
validPeriod: '2024.01.01—2034.01.01',
},
licenseBack: {},
qualification: {
name: '张建国',
gender: '男',
birthDate: '1985-03-27',
nationality: '中国',
address: '浙江省嘉兴市南湖区',
certNumber: '3304********5678',
vehicleClass: 'A1A2',
category: '经营性道路货物运输驾驶员',
initialIssueDate: '2021年04月22日',
validFrom: '2021年04月22日',
validPeriod: '2027年04月21日止',
},
};
export const TRAINING_DOCUMENT = `一、驾驶员安全责任
1. 驾驶员须严格遵守国家道路交通安全法律法规及公司安全管理制度。
2. 出车前须确认本人身体状况良好,严禁疲劳驾驶、酒后驾驶或服用影响驾驶安全的药物后驾车。
3. 驾驶员对车辆运行安全、货物安全及氢能系统安全负有直接责任。
二、出车前检查要求
1. 检查轮胎气压、制动系统、转向系统、灯光信号是否正常。
2. 检查氢气储存系统、管路接头、阀门及泄漏报警装置。
3. 检查仪表盘各项指示,确认无故障报警后方可出车。
4. 检查随车安全设施:灭火器、三角警示牌、反光背心等。
三、氢能车辆使用注意事项
1. 加氢须在指定加氢站进行,严禁私自改装氢气系统。
2. 发现氢气泄漏气味或报警时,立即停车熄火,撤离至安全区域并上报。
3. 禁止在加氢站、隧道、密闭空间等危险区域吸烟或使用明火。
4. 氢气瓶区域严禁敲击、碰撞,避免高温环境长时间停放。
5. 车辆停放时应选择通风良好区域,远离火源和人员密集场所。
四、突发事件处理要求
1. 发生交通事故时,立即报警并保护现场,按规定上报公司安全部门。
2. 发生氢气泄漏时,切断电源,疏散人员,设置警戒区,等待专业人员处置。
3. 发生火情时,使用车载灭火器进行初期扑救,无法控制时立即撤离并报警。
4. 所有突发事件须在30分钟内完成首次上报。
五、禁止事项
1. 严禁无证驾驶、准驾车型不符驾驶。
2. 严禁超速、超载、超员行驶。
3. 严禁行车中使用手机或其他分散注意力的行为。
4. 严禁擅自拆卸、改装车辆安全及氢能系统。
5. 严禁将车辆交予未经培训和授权的人员驾驶。
六、个人信息和证件使用说明
1. 本次培训采集的身份证、驾驶证、从业资格证及本人照片仅用于本次交车核验和安全培训存档。
2. 证件信息须真实有效,如有虚假将承担相应法律责任。
3. 培训记录与交车任务绑定,提车码仅限本人本次交车使用。
4. 如对证件采集有异议,请联系现场运维人员协助处理。`;
export const REQUIRED_VEHICLE_CLASS = 'A2';
/** 现行机动车驾驶证准驾车型代号(本流程可选范围) */
export const VEHICLE_CLASS_OPTIONS = ['A1', 'A2', 'A3', 'B1', 'B2', 'C1', 'C2'];

View File

@@ -0,0 +1,106 @@
import { STORAGE_KEY, DEMO_PHONE, MOCK_SMS_CODE } from './constants';
export function createInitialState(overrides = {}) {
const now = new Date().toISOString();
return {
trainingTask: {
taskId: 'JC202607170023',
projectName: '上海示范运营项目',
vehiclePlate: '浙F·****8',
vehicleType: '49吨氢能重卡',
vehicleTonnage: 49,
plannedDate: '2026-07-20',
requiredVehicleClass: 'A2',
...overrides.trainingTask,
},
currentStep: 1,
maxCompletedStep: 0,
step1: {
readProgress: 0,
scrolledToBottom: false,
confirmed: false,
checked: false,
startTime: null,
endTime: null,
confirmTime: null,
documentVersion: null,
},
step2: {
phone: DEMO_PHONE,
code: MOCK_SMS_CODE,
verified: false,
verifiedAt: null,
},
step3: {
certificates: {},
},
step4: {
completed: false,
watchedSeconds: 0,
completedAt: null,
},
step5: {
photoDataUrl: null,
confirmed: false,
capturedAt: null,
},
step6: {
signatureDataUrl: null,
checked: false,
submitted: false,
submittedAt: null,
},
step7: {
pickupCode: null,
numericCode: null,
status: 'pending',
generatedAt: null,
completedAt: null,
lockedAt: null,
},
debug: {
vehicleTonnage: 49,
ocrMode: 'success',
cameraPermission: 'allow',
videoStatus: 'incomplete',
pickupStatus: 'pending',
},
createdAt: now,
updatedAt: now,
};
}
export function loadTrainingState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return createInitialState();
const parsed = JSON.parse(raw);
const merged = { ...createInitialState(), ...parsed };
merged.trainingTask.vehicleTonnage = merged.debug?.vehicleTonnage ?? merged.trainingTask.vehicleTonnage;
if (merged.step6?.submitted && merged.step7?.pickupCode && merged.currentStep < 8) {
merged.currentStep = 8;
merged.maxCompletedStep = Math.max(merged.maxCompletedStep || 0, 8);
}
if (merged.currentStep === 7 && merged.step7?.pickupCode && !merged.step6?.signatureDataUrl) {
merged.currentStep = 8;
}
return merged;
} catch {
return createInitialState();
}
}
export function saveTrainingState(state) {
const toSave = { ...state, updatedAt: new Date().toISOString() };
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
return toSave;
}
export function resetTrainingState() {
localStorage.removeItem(STORAGE_KEY);
return createInitialState();
}
export function generatePickupCode() {
return String(Math.floor(100000 + Math.random() * 900000));
}

View File

@@ -0,0 +1,104 @@
import React from 'react';
import { DOCUMENT_VERSION, TRAINING_DOCUMENT } from '../data/constants';
import TrainingDocumentViewer from '../components/TrainingDocumentViewer';
export default function Step1Document({ state, updateState, completeStep, renderPart = 'all' }) {
const { step1 } = state;
const readProgress = step1.readProgress || 0;
const scrolledToBottom = step1.scrolledToBottom;
const checked = step1.checked;
const handleStart = () => {
if (!step1.startTime) {
updateState((prev) => ({
...prev,
step1: { ...prev.step1, startTime: new Date().toISOString() },
}));
}
};
const handleReachBottom = () => {
updateState((prev) => ({
...prev,
step1: {
...prev.step1,
scrolledToBottom: true,
readProgress: 100,
endTime: prev.step1.endTime || new Date().toISOString(),
},
}));
};
const handleNext = () => {
const now = new Date().toISOString();
updateState((prev) => ({
...prev,
step1: {
...prev.step1,
confirmed: true,
checked: true,
confirmTime: now,
documentVersion: DOCUMENT_VERSION,
readProgress: 100,
scrolledToBottom: true,
},
}));
completeStep(1);
};
const canProceed = scrolledToBottom && checked;
const content = (
<div className="dt-card">
<h2 className="dt-card-title">安全培训须知</h2>
<TrainingDocumentViewer
content={TRAINING_DOCUMENT}
onProgress={(p) => {
updateState((prev) => ({ ...prev, step1: { ...prev.step1, readProgress: p } }));
}}
onReachBottom={handleReachBottom}
onStart={handleStart}
/>
<div className="dt-read-progress">
<div className="dt-read-progress-bar">
<div className="dt-read-progress-fill" style={{ width: `${readProgress}%` }} />
</div>
<span>已阅读 {readProgress}%</span>
</div>
<label className="dt-checkbox-row">
<input
type="checkbox"
checked={checked}
disabled={!scrolledToBottom}
onChange={(e) =>
updateState((prev) => ({
...prev,
step1: { ...prev.step1, checked: e.target.checked },
}))
}
/>
<span>本人已完整阅读并知晓以上内容</span>
</label>
</div>
);
const footer = (
<>
<div className="dt-footer-hint">
{!scrolledToBottom ? '请完整阅读培训文档后继续。' : !checked ? '请勾选确认后继续。' : ''}
</div>
<button type="button" className="dt-btn-primary" disabled={!canProceed} onClick={handleNext}>
我已知晓进入下一步
</button>
</>
);
if (renderPart === 'content') return content;
if (renderPart === 'footer') return footer;
return (
<>
{content}
{footer}
</>
);
}

View File

@@ -0,0 +1,105 @@
import React, { useState } from 'react';
import PhoneVerificationForm, { verifyPhoneStep } from '../components/PhoneVerificationForm';
import { DEMO_PHONE, MOCK_SMS_CODE } from '../data/constants';
export default function Step2Phone({ state, updateState, completeStep, renderPart = 'all' }) {
const [errors, setErrors] = useState({});
const handleVerify = () => {
const result = verifyPhoneStep(state.step2);
if (!result.ok) {
setErrors({
phoneError: result.phoneError,
codeError: result.codeError,
});
return;
}
setErrors({});
updateState((prev) => ({
...prev,
step2: {
...prev.step2,
phone: result.phone,
code: result.code,
verified: true,
verifiedAt: new Date().toISOString(),
},
}));
completeStep(2);
};
const handleDemoSkip = () => {
updateState((prev) => ({
...prev,
step2: {
phone: DEMO_PHONE,
code: MOCK_SMS_CODE,
verified: true,
verifiedAt: new Date().toISOString(),
},
}));
completeStep(2);
};
if (state.step2.verified) {
const content = (
<div className="dt-card">
<div className="dt-success-banner">
<span></span>
<span>手机号 {state.step2.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')} 已验证</span>
</div>
</div>
);
const footer = (
<button type="button" className="dt-btn-primary" onClick={() => completeStep(2)}>
进入下一步
</button>
);
if (renderPart === 'content') return content;
if (renderPart === 'footer') return footer;
return (
<>
{content}
{footer}
</>
);
}
const content = (
<div className="dt-card">
<h2 className="dt-card-title">手机号验证</h2>
<PhoneVerificationForm
value={state.step2}
showActions={false}
onChange={(data) => updateState((prev) => ({ ...prev, step2: { ...prev.step2, ...data } }))}
/>
{errors.phoneError && <div className="dt-field-error">{errors.phoneError}</div>}
{errors.codeError && <div className="dt-field-error">{errors.codeError}</div>}
</div>
);
const footer = (
<>
<button type="button" className="dt-btn-primary" onClick={handleVerify}>
验证并继续
</button>
<button
type="button"
className="dt-btn-secondary"
style={{ marginTop: 8 }}
onClick={handleDemoSkip}
>
演示跳过验证
</button>
</>
);
if (renderPart === 'content') return content;
if (renderPart === 'footer') return footer;
return (
<>
{content}
{footer}
</>
);
}

View File

@@ -0,0 +1,334 @@
import React, { useRef, useState } from 'react';
import CertificateUploadCard from '../components/CertificateUploadCard';
import { ManualCertificateForm, getCertFieldLabel } from '../components/OCRResultForm';
import UploadSourceSheet from '../components/UploadSourceSheet';
import { CERT_TYPES, OCR_MOCK } from '../data/constants';
import { isQualificationRequired, getRequiredCertTypes } from '../context/TrainingContext';
const OCR_CERT_TYPES = [CERT_TYPES.ID_FRONT, CERT_TYPES.LICENSE_FRONT, CERT_TYPES.QUALIFICATION];
const BACK_ONLY = [CERT_TYPES.ID_BACK, CERT_TYPES.LICENSE_BACK];
function parseValidEnd(period) {
if (!period) return null;
const parts = period.split(/[—\-~]/);
const end = parts[parts.length - 1]?.trim().replace(/\./g, '-');
return end ? new Date(end) : null;
}
export default function Step3Certificates({ state, updateCertificate, completeStep, renderPart = 'all' }) {
const tonnage = state.trainingTask.vehicleTonnage;
const requiredTypes = getRequiredCertTypes(tonnage);
const showOptionalQual = !isQualificationRequired(tonnage);
const certs = state.step3.certificates;
const requiredDoneCount = requiredTypes.filter((t) => certs[t]?.status === 'done').length;
const allDone = requiredDoneCount === requiredTypes.length;
const cameraRef = useRef(null);
const albumRef = useRef(null);
const [uploadSheetCert, setUploadSheetCert] = useState(null);
const [activeCert, setActiveCert] = useState(null);
const [modal, setModal] = useState(null);
const [formData, setFormData] = useState({});
const [formErrors, setFormErrors] = useState({});
const [validationError, setValidationError] = useState('');
const ocrMode = state.debug.ocrMode;
const allCertTypes = showOptionalQual
? [...requiredTypes, CERT_TYPES.QUALIFICATION]
: requiredTypes;
const showUploadOptions = (certType) => {
setUploadSheetCert(certType);
};
const pickCamera = () => {
if (!uploadSheetCert) return;
setActiveCert(uploadSheetCert);
setUploadSheetCert(null);
setTimeout(() => cameraRef.current?.click(), 0);
};
const pickAlbum = () => {
if (!uploadSheetCert) return;
setActiveCert(uploadSheetCert);
setUploadSheetCert(null);
setTimeout(() => albumRef.current?.click(), 0);
};
const runOCR = (certType, imageUrl) => {
updateCertificate(certType, { status: 'recognizing', imageUrl });
setTimeout(() => {
if (ocrMode === 'fail') {
updateCertificate(certType, { status: 'failed', imageUrl, mode: 'ocr' });
setModal({ type: 'ocr_fail', certType, imageUrl });
return;
}
let mock = { ...OCR_MOCK[certType] };
if (ocrMode === 'mismatch' && certType === CERT_TYPES.LICENSE_FRONT) {
mock = { ...mock, name: '李晓明', licenseNumber: '3304**********9999' };
}
updateCertificate(certType, { status: 'confirm', imageUrl, mode: 'ocr', fields: mock, ocrData: mock });
setModal({ type: 'confirm', certType, imageUrl, fields: mock });
setFormData(mock);
}, 1000);
};
const handleFile = (e) => {
const file = e.target.files?.[0];
if (!file || !activeCert) return;
const reader = new FileReader();
updateCertificate(activeCert, { status: 'uploading' });
reader.onload = () => {
if (BACK_ONLY.includes(activeCert)) {
setTimeout(() => {
updateCertificate(activeCert, {
status: 'done',
imageUrl: reader.result,
fields: OCR_MOCK[activeCert] || {},
completedAt: new Date().toISOString(),
});
}, 800);
} else {
runOCR(activeCert, reader.result);
}
};
reader.readAsDataURL(file);
e.target.value = '';
};
const openCert = (certType) => {
const cert = certs[certType];
if (cert?.status === 'done') {
setModal({ type: 'view', certType, ...cert });
setFormData(cert.fields || {});
return;
}
if (cert?.status === 'confirm') {
setModal({ type: 'confirm', certType, imageUrl: cert.imageUrl, fields: cert.fields });
setFormData(cert.fields || {});
return;
}
if (cert?.status === 'failed') {
setModal({ type: 'ocr_fail', certType, imageUrl: cert.imageUrl });
return;
}
showUploadOptions(certType);
};
const confirmCert = () => {
const { certType } = modal;
const errors = {};
const fields = formData;
if (OCR_CERT_TYPES.includes(certType)) {
if (!fields.name) errors.name = '请填写姓名';
if (certType === CERT_TYPES.ID_FRONT && !fields.idNumber) errors.idNumber = '请填写身份证号';
if (certType === CERT_TYPES.LICENSE_FRONT) {
if (!fields.licenseNumber) errors.licenseNumber = '请填写证号';
if (!fields.vehicleClass) errors.vehicleClass = '请选择准驾车型';
if (!fields.validPeriod) errors.validPeriod = '请填写有效期限';
}
if (certType === CERT_TYPES.QUALIFICATION) {
if (!fields.certNumber) errors.certNumber = '请填写证号';
if (!fields.vehicleClass) errors.vehicleClass = '请填写准驾车型';
if (!fields.category) errors.category = '请填写从业资格类别';
if (!fields.initialIssueDate) errors.initialIssueDate = '请填写初次领证日期';
if (!fields.validFrom) errors.validFrom = '请填写有效起始日期';
if (!fields.validPeriod) errors.validPeriod = '请填写有效期限';
}
}
setFormErrors(errors);
if (Object.keys(errors).length) return;
updateCertificate(certType, {
status: 'done',
fields,
imageUrl: modal.imageUrl || certs[certType]?.imageUrl,
mode: modal.mode || certs[certType]?.mode || 'ocr',
completedAt: new Date().toISOString(),
});
setModal(null);
setFormErrors({});
};
const startManual = () => {
const { certType, imageUrl } = modal;
updateCertificate(certType, { status: 'confirm', mode: 'manual', imageUrl, fields: {} });
setModal({ type: 'manual', certType, imageUrl, mode: 'manual' });
setFormData({});
};
const validateAll = () => {
const idFront = certs[CERT_TYPES.ID_FRONT]?.fields;
const license = certs[CERT_TYPES.LICENSE_FRONT]?.fields;
for (const t of requiredTypes) {
if (!certs[t] || certs[t].status !== 'done') {
setValidationError('请完成所有必填证照上传');
return false;
}
}
if (!idFront || !license) {
setValidationError('请完成身份证和驾驶证信息确认');
return false;
}
if (idFront.name !== license.name) {
setValidationError('身份证姓名与驾驶证姓名不一致');
return false;
}
if (idFront.idNumber !== license.licenseNumber) {
setValidationError('身份证号码与驾驶证证号不一致');
return false;
}
const endDate = parseValidEnd(license.validPeriod);
if (endDate && endDate < new Date()) {
setValidationError('驾驶证已过期,请更新证件');
return false;
}
if (license.vehicleClass !== state.trainingTask.requiredVehicleClass) {
setValidationError(`准驾车型不满足要求,当前车辆需要 ${state.trainingTask.requiredVehicleClass} 驾照`);
return false;
}
setValidationError('');
return true;
};
const modals = (
<>
<UploadSourceSheet
visible={!!uploadSheetCert}
certType={uploadSheetCert}
onCamera={pickCamera}
onAlbum={pickAlbum}
onCancel={() => setUploadSheetCert(null)}
/>
{modal?.type === 'ocr_fail' && (
<div className="dt-modal-overlay" onClick={() => setModal(null)}>
<div className="dt-modal" onClick={(e) => e.stopPropagation()}>
<div className="dt-error-card" style={{ margin: 0 }}>
<p>证件信息识别失败可以重新拍摄或手动填写</p>
<div className="dt-error-actions">
<button
type="button"
className="dt-btn-outline"
onClick={() => {
setModal(null);
showUploadOptions(modal.certType);
}}
>
重新上传
</button>
<button type="button" className="dt-btn-primary" style={{ flex: 1 }} onClick={startManual}>
手动填写
</button>
</div>
</div>
</div>
</div>
)}
{(modal?.type === 'confirm' || modal?.type === 'manual' || modal?.type === 'view') && (
<div className="dt-modal-overlay" onClick={() => setModal(null)}>
<div className="dt-modal" onClick={(e) => e.stopPropagation()} style={{ maxHeight: '80vh', overflow: 'auto' }}>
<h3 className="dt-modal-title">
{modal.type === 'view' ? '查看证照' : modal.type === 'manual' ? '手动填写' : '确认识别结果'}
</h3>
{modal.imageUrl && <img src={modal.imageUrl} alt="" style={{ width: '100%', borderRadius: 8, marginBottom: 12 }} />}
{(modal.type === 'confirm' || modal.type === 'manual') && OCR_CERT_TYPES.includes(modal.certType) && (
<ManualCertificateForm
certType={modal.certType}
data={formData}
onChange={(key, val) => setFormData((d) => ({ ...d, [key]: val }))}
errors={formErrors}
/>
)}
{modal.type === 'view' && modal.fields && Object.keys(modal.fields).length > 0 && (
<div className="dt-info-list">
{Object.entries(modal.fields)
.filter(([k]) => k !== 'fileNumber')
.map(([k, v]) => (
<div className="dt-info-item" key={k}>
<span className="label">{getCertFieldLabel(k)}</span>
<span>{v}</span>
</div>
))}
</div>
)}
<div className="dt-modal-actions" style={{ marginTop: 16 }}>
<button type="button" className="dt-btn-secondary" onClick={() => setModal(null)}>关闭</button>
{modal.type !== 'view' && (
<button type="button" className="dt-btn-primary" onClick={confirmCert}>确认</button>
)}
</div>
</div>
</div>
)}
</>
);
const content = (
<>
<input
ref={cameraRef}
type="file"
accept="image/*"
capture="environment"
style={{ display: 'none' }}
onChange={handleFile}
/>
<input
ref={albumRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleFile}
/>
<div className="dt-card">
<h2 className="dt-card-title">司机证照</h2>
<p className="dt-field-hint" style={{ marginBottom: 12 }}>
{isQualificationRequired(tonnage)
? `当前车辆为 ${tonnage} 吨,从业资格证为必填项。`
: `当前车辆为 ${tonnage} 吨,从业资格证为选填项。`}
点击证件卡片可选择拍照或从相册上传
</p>
{allCertTypes.map((t) => (
<CertificateUploadCard
key={t}
certType={t}
required={requiredTypes.includes(t)}
status={certs[t]?.status || 'pending'}
imageUrl={certs[t]?.imageUrl}
onClick={() => openCert(t)}
/>
))}
{allDone && (
<div className="dt-success-banner">
<span></span>
<span>证照信息已完成</span>
</div>
)}
{validationError && <div className="dt-field-error" style={{ marginTop: 8 }}>{validationError}</div>}
</div>
{modals}
</>
);
const footer = (
<>
{!allDone && (
<div className="dt-footer-hint">
请完成全部必填证照上传{requiredDoneCount}/{requiredTypes.length}
</div>
)}
<button type="button" className="dt-btn-primary" disabled={!allDone} onClick={() => validateAll() && completeStep(3)}>
下一步
</button>
</>
);
if (renderPart === 'content') return content;
if (renderPart === 'footer') return footer;
return (
<>
{content}
{footer}
</>
);
}

View File

@@ -0,0 +1,60 @@
import React from 'react';
import TrainingVideoPlayer from '../components/TrainingVideoPlayer';
export default function Step4Video({ state, updateState, completeStep, renderPart = 'all' }) {
const { step4, debug } = state;
const completed = step4.completed;
const handleComplete = () => {
updateState((prev) => ({
...prev,
step4: {
completed: true,
watchedSeconds: 30,
completedAt: new Date().toISOString(),
},
}));
};
const content = (
<div className="dt-card">
<h2 className="dt-card-title">安全培训视频</h2>
<p className="dt-field-hint" style={{ marginBottom: 12 }}>
氢能重卡安全驾驶培训 · 预计时长 30
</p>
<TrainingVideoPlayer
completed={completed}
watchedSeconds={step4.watchedSeconds}
onProgress={(s) =>
updateState((prev) => ({ ...prev, step4: { ...prev.step4, watchedSeconds: s } }))
}
onComplete={handleComplete}
debugComplete={debug.videoStatus === 'completed' && !completed}
/>
{completed && (
<div className="dt-success-banner" style={{ marginTop: 12 }}>
<span></span>
<span>视频观看完成</span>
</div>
)}
</div>
);
const footer = (
<>
{!completed && <div className="dt-footer-hint">请完整观看安全培训视频</div>}
<button type="button" className="dt-btn-primary" disabled={!completed} onClick={() => completeStep(4)}>
下一步
</button>
</>
);
if (renderPart === 'content') return content;
if (renderPart === 'footer') return footer;
return (
<>
{content}
{footer}
</>
);
}

View File

@@ -0,0 +1,60 @@
import React from 'react';
import CameraCapture from '../components/CameraCapture';
export default function Step5Portrait({ state, updateState, completeStep, renderPart = 'all' }) {
const { step5, debug } = state;
const content = (
<div className="dt-card">
<h2 className="dt-card-title">本人照片</h2>
<CameraCapture
photoDataUrl={step5.photoDataUrl}
confirmed={step5.confirmed}
cameraPermission={debug.cameraPermission}
showDevFallback
onCapture={(url) =>
updateState((prev) => ({
...prev,
step5: { ...prev.step5, photoDataUrl: url, confirmed: false },
}))
}
onConfirm={() =>
updateState((prev) => ({
...prev,
step5: {
...prev.step5,
confirmed: true,
capturedAt: new Date().toISOString(),
},
}))
}
onRetake={() =>
updateState((prev) => ({
...prev,
step5: { photoDataUrl: null, confirmed: false, capturedAt: null },
}))
}
/>
</div>
);
const footer = (
<>
{!step5.confirmed && (
<div className="dt-footer-hint">请拍照并确认使用本人照片</div>
)}
<button type="button" className="dt-btn-primary" disabled={!step5.confirmed} onClick={() => completeStep(5)}>
下一步
</button>
</>
);
if (renderPart === 'content') return content;
if (renderPart === 'footer') return footer;
return (
<>
{content}
{footer}
</>
);
}

View File

@@ -0,0 +1,86 @@
import React from 'react';
import { formatNow } from '../utils/formatDateTime';
const DECLARATION =
'本人确认所提交的身份及驾驶证件信息真实有效,已完整阅读安全培训文档并观看安全培训视频,已知悉氢能车辆驾驶及应急处置要求,并确认由本人执行本次提车。';
export default function Step6Signature({ state, updateState, completeStep, renderPart = 'all' }) {
const { step6, step3, trainingTask } = state;
const checked = step6.checked;
const driverName = step3.certificates?.idFront?.fields?.name || '—';
const idLast4 = (step3.certificates?.idFront?.fields?.idNumber || '****').slice(-4);
const vehicleClass = step3.certificates?.licenseFront?.fields?.vehicleClass || '—';
const content = (
<div className="dt-card dt-step6-card">
<h2 className="dt-card-title">签字确认</h2>
<section className="dt-step6-section">
<h3 className="dt-step6-section-title"> 阅读声明</h3>
<div className="dt-step6-declaration">{DECLARATION}</div>
</section>
<section className="dt-step6-section">
<h3 className="dt-step6-section-title"> 核对信息</h3>
<div className="dt-info-list dt-step6-info">
<div className="dt-info-item dt-info-item--highlight">
<span className="label">司机姓名</span>
<span>{driverName}</span>
</div>
<div className="dt-info-item dt-info-item--highlight">
<span className="label">当前车辆</span>
<span>{trainingTask.vehiclePlate}</span>
</div>
<div className="dt-info-item">
<span className="label">身份证后四位</span>
<span>{idLast4}</span>
</div>
<div className="dt-info-item">
<span className="label">驾驶证准驾车型</span>
<span>{vehicleClass}</span>
</div>
<div className="dt-info-item">
<span className="label">培训完成时间</span>
<span>{formatNow()}</span>
</div>
</div>
</section>
<label className="dt-checkbox-row">
<input
type="checkbox"
checked={checked}
onChange={(e) => {
updateState((prev) => ({ ...prev, step6: { ...prev.step6, checked: e.target.checked } }));
}}
/>
<span>本人确认以上信息及签名真实有效</span>
</label>
</div>
);
const footer = (
<>
{!checked ? <div className="dt-footer-hint">请勾选本人确认以上信息及签名真实有效</div> : null}
<button
type="button"
className={`dt-btn-primary ${checked ? 'dt-btn-primary--ready' : ''}`}
disabled={!checked}
onClick={() => completeStep(6)}
>
下一步手写签名
</button>
</>
);
if (renderPart === 'content') return content;
if (renderPart === 'footer') return footer;
return (
<>
{content}
{footer}
</>
);
}

View File

@@ -0,0 +1,11 @@
import React from 'react';
import PickupCodeCard from '../components/PickupCodeCard';
export default function Step7PickupCode({ state, renderPart = 'all' }) {
const { step7 } = state;
const content = <PickupCodeCard step7={step7} />;
if (renderPart === 'footer') return null;
return content;
}

View File

@@ -0,0 +1,169 @@
import React, { useEffect, useRef, useState } from 'react';
import { formatNow } from '../utils/formatDateTime';
import SignatureCanvas from '../components/SignatureCanvas';
import ConfirmDialog from '../components/ConfirmDialog';
function useContainerLandscape(rootRef) {
const [isLandscape, setIsLandscape] = useState(false);
useEffect(() => {
const root = rootRef.current?.closest('.driver-training-app') || rootRef.current;
if (!root) return undefined;
const check = () => {
const { width, height } = root.getBoundingClientRect();
setIsLandscape(width > height * 1.05);
};
check();
const observer = new ResizeObserver(check);
observer.observe(root);
window.addEventListener('orientationchange', check);
return () => {
observer.disconnect();
window.removeEventListener('orientationchange', check);
};
}, [rootRef]);
return isLandscape;
}
export default function Step7Signature({
state,
updateState,
submitTraining,
goToStep,
renderPart = 'all',
}) {
const { step6, step3, trainingTask } = state;
const [showSubmitDialog, setShowSubmitDialog] = useState(false);
const rootRef = useRef(null);
const canvasRef = useRef(null);
const isLandscape = useContainerLandscape(rootRef);
const hasSignature = !!step6.signatureDataUrl;
const driverName = step3.certificates?.idFront?.fields?.name || '—';
const handleSubmit = () => {
submitTraining();
setShowSubmitDialog(false);
};
if (step6.submitted) {
if (renderPart === 'footer') return null;
return (
<div className="dt-card">
<div className="dt-success-banner">
<span></span>
<span>培训记录已提交</span>
</div>
</div>
);
}
if (renderPart === 'footer') return null;
const dialogSummary = (
<>
<div className="dt-modal-summary-row">
<span className="label">司机</span>
<span>{driverName}</span>
</div>
<div className="dt-modal-summary-row">
<span className="label">车辆</span>
<span>{trainingTask.vehiclePlate}</span>
</div>
<div className="dt-modal-summary-row">
<span className="label">培训完成时间</span>
<span>{formatNow()}</span>
</div>
{hasSignature && (
<div className="dt-modal-signature-preview">
<span className="label">签名预览</span>
<img src={step6.signatureDataUrl} alt="签名预览" />
</div>
)}
</>
);
const submitDialog = (
<ConfirmDialog
visible={showSubmitDialog}
title="确认提交培训并生成提车码"
note="提交后不可修改,提车码将出示给运维扫码。"
summary={dialogSummary}
cancelText="返回检查"
confirmText="确认提交"
onCancel={() => setShowSubmitDialog(false)}
onConfirm={handleSubmit}
/>
);
return (
<div
ref={rootRef}
className={`dt-esign-root ${isLandscape ? 'dt-esign-root--landscape' : 'dt-esign-root--portrait-preview'}`}
>
{!isLandscape && (
<div className="dt-esign-preview-banner">
<div className="dt-esign-preview-banner-icon" aria-hidden="true">
</div>
<div className="dt-esign-preview-banner-text">
<strong>横屏签字预览</strong>
<span>正式使用时请旋转手机至横屏下方为横屏全屏签名页效果可直接体验签字并完成提交</span>
</div>
</div>
)}
<div className="dt-esign-landscape">
<header className="dt-esign-topbar">
<button type="button" className="dt-esign-topbar-back" onClick={() => goToStep(6)} aria-label="返回">
</button>
<h1 className="dt-esign-topbar-title">手写签名</h1>
<span className="dt-esign-topbar-slot" />
</header>
<div className="dt-esign-signer">
<span>{driverName}</span>
<span className="dt-esign-signer-dot">·</span>
<span>{trainingTask.vehiclePlate}</span>
</div>
<div className="dt-esign-canvas-area">
<SignatureCanvas
ref={canvasRef}
variant="esign"
hideActions
watermark="请在此处签名"
value={step6.signatureDataUrl}
onChange={(url) =>
updateState((prev) => ({ ...prev, step6: { ...prev.step6, signatureDataUrl: url } }))
}
/>
</div>
<footer className="dt-esign-toolbar">
<button type="button" className="dt-esign-tool-btn" onClick={() => canvasRef.current?.clear()}>
清除重写
</button>
<button type="button" className="dt-esign-tool-btn" onClick={() => goToStep(6)}>
取消
</button>
<button
type="button"
className={`dt-esign-tool-done ${hasSignature ? 'dt-esign-tool-done--ready' : ''}`}
disabled={!hasSignature}
onClick={() => setShowSubmitDialog(true)}
>
完成
</button>
</footer>
</div>
{submitDialog}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,11 @@
export function formatDateTime(iso) {
if (!iso) return '-';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '-';
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function formatNow() {
return formatDateTime(new Date().toISOString());
}

File diff suppressed because it is too large Load Diff

View File

@@ -14,9 +14,18 @@
## 最新改版
审批中心与还车应结款多部门子流程见 **PRD 01审批中心改版说明**V1.4)。
### 交车模块PRD 02 · V1.3.22026-07-01
### 审批中心要点(与标注 `page-audit` 一致)
- **连拍相册单选**:连拍环节相册为单选模式,每次只能选 1 张
- **瑕疵/其他多选**:补充照片仍支持相册一次选多张
- **连拍自动保存**:每次确认照片并进入下一项时静默保存草稿
- **拍照操作条**:「拍照 / 相册」及完成态三按钮等宽平铺
### 审批中心PRD 01 · V1.4
审批中心与还车应结款多部门子流程见 **PRD 01审批中心改版说明**
#### 审批中心要点(与标注 `page-audit` 一致)
- 搜索:仅按卡片加粗标题(工作流标题)模糊匹配
- 更多筛选:发起人(滚轮选择)、发起时间(年/月/日滚轮,起止日期)
@@ -26,6 +35,6 @@
## 需求文档与标注
- **标注目录**PRD 00 / PRD 01 / PRD 02 及页面说明(`page-audit``page-delivery` 等)
- **PRD 02交车说明**:交车列表、分步办理与签章流程
- **PRD 02交车说明**:交车列表、分步办理与签章流程(含 V1.3.2 连拍相册单选与连拍自动保存)
- **导航栏**:不设「需求说明」入口;以标注工具与目录为主
- **业务评审**`.spec/prototype-review.md`

View File

@@ -1,7 +1,7 @@
# 小羚羚小程序 · 交车说明
**文档版本**V1.2
**更新日期**2026-06-29
**文档版本**V1.3.2
**更新日期**2026-07-01
**适用范围**:运维管理 / 交车模块(`#page=delivery`
**参照**web 交车管理 + Axhub「交车完成」原型 · 默认区域权限:浙江省-嘉兴市
@@ -47,6 +47,15 @@ Hero 展示客户名称、项目名称与交车区域;含选择车辆、车辆
交车模块内**所有拍照环节**均支持从手机相册本地上传图片,包括:识别车牌号、车辆配置(车身广告/放大字)、驾驶培训手动记录证件照、交车连拍/补拍、瑕疵与其他照片添加、备胎拍摄等;相机页底部提供「拍照」「相册」操作。
**相册选择规则V1.3.2**
| 场景 | 规则 |
| --- | --- |
| **连拍环节** | **相册单选**:文件选择器为单选模式,每次只能选 1 张;选好后进入当前项预览,点「完成」确认 |
| 瑕疵 / 其他补充照片 | 相册多选可一次添加多张(该类合计最多 6 张) |
| 备胎 / 其他单拍场景 | 相册单选,每次 1 张 |
| 通用限制 | 仅接受常见图片格式;单张不超过 5MB |
#### 车辆配置
- 车身广告及放大字、尾板(必填,有则须上传照片)
@@ -103,7 +112,18 @@ Hero 展示客户名称、项目名称与交车区域;含选择车辆、车辆
**备胎照片**:从车辆情况步骤移至拍摄照片「轮胎情况」连拍末项(有备胎时必拍);车辆情况仅保留是否有备胎。
**分步操作条**车辆情况、交车检查项点击「下一步」时自动保存;拍摄照片完成后为「保存」「发送签章文件」。
**拍照操作条布局V1.3**
相机页底部操作条(`.xll-dv-photo-camera-actions`)按钮等宽平铺:
| 状态 | 按钮 |
| --- | --- |
| 未拍摄 | 「拍照」「相册」两按钮等宽 |
| 已拍摄 | 「重新拍摄」「相册」「完成」三按钮等宽 |
**分步操作条**:车辆情况、交车检查项点击「下一步」时自动保存;拍摄照片步骤完成后为「保存」「发送签章文件」。
**连拍自动保存V1.3**:连拍中每次点击「完成」进入下一拍摄项时,**强制静默自动保存**交车单草稿(未开始 → 已保存)。与「下一步」自动保存规则一致,无需用户再点「保存」。
**照片上传**:连拍/单拍每次点击「完成」继续时,上一张自动上传并在服务端打水印(时间、地点)。
@@ -146,10 +166,14 @@ Hero 展示客户名称、项目名称与交车区域;含选择车辆、车辆
- [ ] 车辆配置、驾驶培训、交车数据、交车位置各卡片可填写
- [ ] 交车检查项四类可填,轮胎胎纹支持 mm 录入
- [ ] 拍摄照片五类连拍、相册上传、调焦与备胎末项规则
- [ ] **连拍相册单选**:连拍环节相册为单选模式,每次只能选 1 张(无法多选)
- [ ] **瑕疵/其他多选**:补充照片仍可相册批量添加
- [ ] **连拍自动保存**:每次点「完成」进入下一项后草稿状态为「已保存」
- [ ] **拍照操作条**:拍照/相册(及完成态三按钮)等宽平铺展示
- [ ] 发送签章 → 待客户签章 → 清除签章 / 重新签章 / 客户已签章状态流转
- [ ] 标注目录可打开本 PRD页面标注 `page-delivery` 可定位交车模块
**预览地址示例**`http://localhost:51722/prototypes/xll-miniapp#page=delivery`
**预览地址示例**`http://localhost:51721/prototypes/xll-miniapp#page=delivery`
---
@@ -161,3 +185,14 @@ Hero 展示客户名称、项目名称与交车区域;含选择车辆、车辆
| 审批中心改版 | 标注目录「PRD 01审批中心改版说明」 |
| 页面标注 | `page-delivery` |
| 原型评审 | `src/prototypes/xll-miniapp/.spec/prototype-review.md` |
---
## 七、版本记录
| 版本 | 日期 | 说明 |
| --- | --- | --- |
| V1.3.2 | 2026-07-01 | 连拍相册明确为单选模式(非多选后取首张) |
| V1.3.1 | 2026-07-01 | 连拍环节相册改为仅支持单张上传(不支持批量) |
| V1.3 | 2026-07-01 | 瑕疵/其他相册多选;连拍「完成」强制自动保存;拍照操作条按钮等宽平铺 |
| V1.2 | 2026-06-29 | 交车分步表单、连拍、签章与相册上传基线 |