Compare commits
4 Commits
a619938e0c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad8fbfae1b | ||
|
|
f86b1ab269 | ||
|
|
98439ee3a8 | ||
|
|
0c108f1459 |
@@ -4,7 +4,6 @@
|
|||||||
"repository": "https://github.com/lintendo/Axhub-Make/tree/main/client",
|
"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",
|
"templateUrl": "https://github.com/lintendo/Axhub-Make/releases/download/make-client-template-v0.1.4/axhub-make-client-template.zip",
|
||||||
"templateVersion": "0.1.4",
|
"templateVersion": "0.1.4",
|
||||||
"makeVersion": "0.6.4",
|
|
||||||
"project": {
|
"project": {
|
||||||
"id": "make-project-20260629",
|
"id": "make-project-20260629",
|
||||||
"name": "小羚羚「小程序」"
|
"name": "小羚羚「小程序」"
|
||||||
|
|||||||
@@ -2,12 +2,19 @@
|
|||||||
"version": 1,
|
"version": 1,
|
||||||
"updatedAt": "2026-06-30T02:16:40.884Z",
|
"updatedAt": "2026-06-30T02:16:40.884Z",
|
||||||
"prototypes": [
|
"prototypes": [
|
||||||
|
{
|
||||||
|
"id": "folder-prototypes-xll-miniapp",
|
||||||
|
"kind": "folder",
|
||||||
|
"title": "小羚羚小程序",
|
||||||
|
"children": [
|
||||||
{
|
{
|
||||||
"id": "item:prototypes:xll-miniapp",
|
"id": "item:prototypes:xll-miniapp",
|
||||||
"kind": "item",
|
"kind": "item",
|
||||||
"title": "小程序",
|
"title": "小程序",
|
||||||
"itemKey": "prototypes/xll-miniapp"
|
"itemKey": "prototypes/xll-miniapp"
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"docs": [],
|
"docs": [],
|
||||||
"themesTree": [
|
"themesTree": [
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -121,6 +121,9 @@ storybook-static/
|
|||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
|
|
||||||
|
# Local agent/tool cache (not part of project deliverables)
|
||||||
|
.local/
|
||||||
|
|
||||||
# Git version management temporary files
|
# Git version management temporary files
|
||||||
.git-versions/
|
.git-versions/
|
||||||
|
|
||||||
|
|||||||
56
src/prototypes/xll-miniapp/.spec/authorized-person.md
Normal file
56
src/prototypes/xll-miniapp/.spec/authorized-person.md
Normal 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签宝
|
||||||
|
- [ ] 聚德订单(有授权人)仍可点选卡片并发送签章
|
||||||
12
src/prototypes/xll-miniapp/.spec/prototype-comments.json
Normal file
12
src/prototypes/xll-miniapp/.spec/prototype-comments.json
Normal 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
@@ -11,7 +11,7 @@
|
|||||||
"folderId": "directory-modules",
|
"folderId": "directory-modules",
|
||||||
"folderTitle": "业务模块",
|
"folderTitle": "业务模块",
|
||||||
"defaultExpanded": true,
|
"defaultExpanded": true,
|
||||||
"pageIds": ["audit", "inspection", "vehicle", "delivery", "third-return", "replace"]
|
"pageIds": ["audit", "inspection", "vehicle", "delivery", "third-return", "replace", "driver-training"]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"extraRoutes": [
|
"extraRoutes": [
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import React, { useCallback, useEffect } from 'react';
|
||||||
|
import { TrainingProvider, useTraining } from './context/TrainingContext';
|
||||||
|
import { TOTAL_STEPS } from './data/constants';
|
||||||
|
import { DriverTrainingLayout } from './components/DriverTrainingLayout';
|
||||||
|
import PrototypeDebugPanel from './components/PrototypeDebugPanel';
|
||||||
|
import Toast, { useToast } from './components/Toast';
|
||||||
|
import Step1Document from './steps/Step1Document';
|
||||||
|
import Step2Phone from './steps/Step2Phone';
|
||||||
|
import Step3Certificates from './steps/Step3Certificates';
|
||||||
|
import Step4Video from './steps/Step4Video';
|
||||||
|
import Step5Portrait from './steps/Step5Portrait';
|
||||||
|
import Step6Signature from './steps/Step6Signature';
|
||||||
|
import Step7Signature from './steps/Step7Signature';
|
||||||
|
import Step7PickupCode from './steps/Step7PickupCode';
|
||||||
|
import './styles/driver-training.css';
|
||||||
|
|
||||||
|
const STEPS = [
|
||||||
|
Step1Document,
|
||||||
|
Step2Phone,
|
||||||
|
Step3Certificates,
|
||||||
|
Step4Video,
|
||||||
|
Step5Portrait,
|
||||||
|
Step6Signature,
|
||||||
|
Step7Signature,
|
||||||
|
Step7PickupCode,
|
||||||
|
];
|
||||||
|
|
||||||
|
const SIGNATURE_STEP = 7;
|
||||||
|
|
||||||
|
function StepFooterBar({ currentStep, goToStep, children }) {
|
||||||
|
if (currentStep >= TOTAL_STEPS) return null;
|
||||||
|
|
||||||
|
if (currentStep <= 1) {
|
||||||
|
return <div className="dt-footer-stack">{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dt-footer-stack">
|
||||||
|
<div className="dt-footer-row">
|
||||||
|
<button type="button" className="dt-btn-prev" onClick={() => goToStep(currentStep - 1)}>
|
||||||
|
上一步
|
||||||
|
</button>
|
||||||
|
<div className="dt-footer-main">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStepDetailHint(state) {
|
||||||
|
const { currentStep, step1, step4 } = state;
|
||||||
|
if (currentStep === 1 && step1.readProgress > 0 && step1.readProgress < 100) {
|
||||||
|
return `阅读 ${step1.readProgress}%`;
|
||||||
|
}
|
||||||
|
if (currentStep === 4 && !step4.completed && step4.watchedSeconds > 0) {
|
||||||
|
return `已观看 ${step4.watchedSeconds}s`;
|
||||||
|
}
|
||||||
|
if (currentStep === SIGNATURE_STEP) {
|
||||||
|
return '请横屏签字';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function DriverTrainingContent({ onRegisterBack }) {
|
||||||
|
const { state, updateState, goToStep, completeStep, stepNames, updateCertificate, submitTraining } = useTraining();
|
||||||
|
const { message, show, clear } = useToast();
|
||||||
|
const { currentStep, maxCompletedStep } = state;
|
||||||
|
const stepDetailHint = getStepDetailHint(state);
|
||||||
|
const isSignatureStep = currentStep === SIGNATURE_STEP;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!onRegisterBack) return undefined;
|
||||||
|
onRegisterBack(() => {
|
||||||
|
if (currentStep > 1) {
|
||||||
|
goToStep(currentStep - 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
return () => onRegisterBack(null);
|
||||||
|
}, [onRegisterBack, currentStep, goToStep]);
|
||||||
|
|
||||||
|
const StepComponent = STEPS[currentStep - 1] || STEPS[0];
|
||||||
|
const stepProps = {
|
||||||
|
state,
|
||||||
|
updateState,
|
||||||
|
completeStep,
|
||||||
|
goToStep,
|
||||||
|
showToast: show,
|
||||||
|
updateCertificate,
|
||||||
|
submitTraining,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DriverTrainingLayout
|
||||||
|
currentStep={currentStep}
|
||||||
|
totalSteps={TOTAL_STEPS}
|
||||||
|
stepNames={stepNames}
|
||||||
|
maxCompletedStep={maxCompletedStep}
|
||||||
|
stepDetailHint={stepDetailHint}
|
||||||
|
embedded
|
||||||
|
immersive={isSignatureStep}
|
||||||
|
footer={
|
||||||
|
currentStep < TOTAL_STEPS && currentStep !== SIGNATURE_STEP ? (
|
||||||
|
<StepFooterBar currentStep={currentStep} goToStep={goToStep}>
|
||||||
|
<StepComponent {...stepProps} renderPart="footer" />
|
||||||
|
</StepFooterBar>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<StepComponent {...stepProps} renderPart="content" />
|
||||||
|
</DriverTrainingLayout>
|
||||||
|
<PrototypeDebugPanel />
|
||||||
|
<Toast message={message} onClose={clear} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DriverTrainingModule({ onRegisterBack }) {
|
||||||
|
const registerBack = useCallback(
|
||||||
|
(handler) => {
|
||||||
|
if (typeof onRegisterBack === 'function') {
|
||||||
|
onRegisterBack(handler);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onRegisterBack],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TrainingProvider>
|
||||||
|
<div className="dt-module-root" data-annotation-id="page-driver-training">
|
||||||
|
<DriverTrainingContent onRegisterBack={registerBack} />
|
||||||
|
</div>
|
||||||
|
</TrainingProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import ConfirmDialog from './ConfirmDialog';
|
||||||
|
|
||||||
|
const SAMPLE_PORTRAIT =
|
||||||
|
'data:image/svg+xml,' +
|
||||||
|
encodeURIComponent(
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="240" height="300" viewBox="0 0 240 300">
|
||||||
|
<rect fill="#E8FFEA" width="240" height="300"/>
|
||||||
|
<circle cx="120" cy="110" r="50" fill="#C9CDD4"/>
|
||||||
|
<ellipse cx="120" cy="240" rx="70" ry="55" fill="#C9CDD4"/>
|
||||||
|
<text x="120" y="290" text-anchor="middle" fill="#86909C" font-size="12">示例照片</text>
|
||||||
|
</svg>`
|
||||||
|
);
|
||||||
|
|
||||||
|
export default function CameraCapture({
|
||||||
|
photoDataUrl,
|
||||||
|
confirmed,
|
||||||
|
onCapture,
|
||||||
|
onConfirm,
|
||||||
|
onRetake,
|
||||||
|
cameraPermission,
|
||||||
|
showDevFallback,
|
||||||
|
}) {
|
||||||
|
const videoRef = useRef(null);
|
||||||
|
const streamRef = useRef(null);
|
||||||
|
const [preview, setPreview] = useState(photoDataUrl);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [permissionDenied, setPermissionDenied] = useState(false);
|
||||||
|
const [showPermissionDialog, setShowPermissionDialog] = useState(false);
|
||||||
|
|
||||||
|
const stopStream = useCallback(() => {
|
||||||
|
if (streamRef.current) {
|
||||||
|
streamRef.current.getTracks().forEach((t) => t.stop());
|
||||||
|
streamRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const startCamera = useCallback(async () => {
|
||||||
|
if (cameraPermission === 'deny') {
|
||||||
|
setPermissionDenied(true);
|
||||||
|
setShowPermissionDialog(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: { facingMode: 'user', width: { ideal: 640 }, height: { ideal: 480 } },
|
||||||
|
audio: false,
|
||||||
|
});
|
||||||
|
streamRef.current = stream;
|
||||||
|
if (videoRef.current) {
|
||||||
|
videoRef.current.srcObject = stream;
|
||||||
|
await videoRef.current.play();
|
||||||
|
}
|
||||||
|
setError('');
|
||||||
|
setPermissionDenied(false);
|
||||||
|
} catch {
|
||||||
|
setPermissionDenied(true);
|
||||||
|
setShowPermissionDialog(true);
|
||||||
|
}
|
||||||
|
}, [cameraPermission]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!preview && !confirmed) startCamera();
|
||||||
|
return () => stopStream();
|
||||||
|
}, [preview, confirmed, startCamera, stopStream]);
|
||||||
|
|
||||||
|
const handleCapture = () => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = video.videoWidth || 640;
|
||||||
|
canvas.height = video.videoHeight || 480;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.translate(canvas.width, 0);
|
||||||
|
ctx.scale(-1, 1);
|
||||||
|
ctx.drawImage(video, 0, 0);
|
||||||
|
const dataUrl = canvas.toDataURL('image/jpeg', 0.85);
|
||||||
|
setPreview(dataUrl);
|
||||||
|
stopStream();
|
||||||
|
onCapture?.(dataUrl);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetake = () => {
|
||||||
|
setPreview(null);
|
||||||
|
onRetake?.();
|
||||||
|
startCamera();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUseSample = () => {
|
||||||
|
setPreview(SAMPLE_PORTRAIT);
|
||||||
|
onCapture?.(SAMPLE_PORTRAIT);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (confirmed && photoDataUrl) {
|
||||||
|
return (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<img src={photoDataUrl} alt="本人照片" className="dt-portrait-preview" />
|
||||||
|
<div className="dt-success-banner" style={{ marginTop: 12 }}>
|
||||||
|
<span>✓</span>
|
||||||
|
<span>本人照片已确认</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<p className="dt-field-hint" style={{ marginBottom: 12 }}>
|
||||||
|
请由司机本人使用前置摄像头拍摄正面照片,用于本次培训和现场交车核验。
|
||||||
|
</p>
|
||||||
|
<ul className="dt-camera-tips">
|
||||||
|
<li>请正对摄像头</li>
|
||||||
|
<li>保持光线充足</li>
|
||||||
|
<li>请勿佩戴口罩或遮挡面部</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{!preview ? (
|
||||||
|
<div className="dt-camera-container">
|
||||||
|
<video ref={videoRef} className="dt-camera-video" playsInline muted />
|
||||||
|
<div className="dt-camera-guide">
|
||||||
|
<div className="dt-camera-oval" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<img src={preview} alt="预览" className="dt-portrait-preview" style={{ width: '100%', maxWidth: 280, height: 'auto' }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="dt-field-error">{error}</div>}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: 10, marginTop: 16 }}>
|
||||||
|
{!preview ? (
|
||||||
|
<button type="button" className="dt-btn-primary" onClick={handleCapture} disabled={permissionDenied}>
|
||||||
|
拍摄
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button type="button" className="dt-btn-outline" onClick={handleRetake}>
|
||||||
|
重新拍摄
|
||||||
|
</button>
|
||||||
|
<button type="button" className="dt-btn-primary" style={{ flex: 1 }} onClick={() => onConfirm?.()}>
|
||||||
|
确认使用
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showDevFallback && permissionDenied && (
|
||||||
|
<button type="button" className="dt-btn-secondary" style={{ marginTop: 10 }} onClick={handleUseSample}>
|
||||||
|
使用示例照片(开发模式)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
visible={showPermissionDialog}
|
||||||
|
title="摄像头权限"
|
||||||
|
content="无法调用摄像头,请在浏览器设置中允许摄像头权限后重试。"
|
||||||
|
cancelText="关闭"
|
||||||
|
confirmText="重试"
|
||||||
|
onCancel={() => setShowPermissionDialog(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
setShowPermissionDialog(false);
|
||||||
|
startCamera();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CERT_LABELS } from '../data/constants';
|
||||||
|
|
||||||
|
const STATUS_TEXT = {
|
||||||
|
pending: '待上传',
|
||||||
|
uploading: '上传中',
|
||||||
|
recognizing: '识别中',
|
||||||
|
confirm: '待确认',
|
||||||
|
done: '已完成',
|
||||||
|
failed: '识别失败',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CertificateUploadCard({ certType, required, status, onClick, imageUrl }) {
|
||||||
|
const label = CERT_LABELS[certType];
|
||||||
|
const isDone = status === 'done';
|
||||||
|
const isFailed = status === 'failed';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`dt-cert-card ${isDone ? 'done' : ''} ${isFailed ? 'error' : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && onClick?.()}
|
||||||
|
>
|
||||||
|
<div className="dt-cert-card-header">
|
||||||
|
<span className="dt-cert-card-title">{label}</span>
|
||||||
|
{required ? (
|
||||||
|
<span className="dt-cert-badge">必填</span>
|
||||||
|
) : (
|
||||||
|
<span className="dt-cert-badge optional">选填</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="dt-cert-status">{STATUS_TEXT[status] || '待上传'}</div>
|
||||||
|
{imageUrl && (
|
||||||
|
<div className="dt-cert-preview">
|
||||||
|
<img src={imageUrl} alt={label} className="dt-cert-thumb" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export default function ConfirmDialog({
|
||||||
|
visible,
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
note,
|
||||||
|
summary,
|
||||||
|
onCancel,
|
||||||
|
onConfirm,
|
||||||
|
cancelText = '取消',
|
||||||
|
confirmText = '确认',
|
||||||
|
}) {
|
||||||
|
if (!visible) return null;
|
||||||
|
return (
|
||||||
|
<div className="dt-modal-overlay" onClick={onCancel}>
|
||||||
|
<div className="dt-modal dt-modal--summary" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h3 className="dt-modal-title">{title}</h3>
|
||||||
|
{summary ? <div className="dt-modal-summary">{summary}</div> : null}
|
||||||
|
{note ? <p className="dt-modal-note">{note}</p> : null}
|
||||||
|
{content ? <p className="dt-modal-content">{content}</p> : null}
|
||||||
|
<div className="dt-modal-actions">
|
||||||
|
<button type="button" className="dt-btn-secondary" onClick={onCancel}>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="dt-btn-primary" onClick={onConfirm}>
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const TRAINING_PHASES = [
|
||||||
|
{ id: 'prep', label: '身份与须知', steps: [1, 2] },
|
||||||
|
{ id: 'verify', label: '资质与培训', steps: [3, 4, 5] },
|
||||||
|
{ id: 'confirm', label: '签署与提车', steps: [6, 7, 8] },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getPhaseStatus(phase, currentStep) {
|
||||||
|
const maxStep = Math.max(...phase.steps);
|
||||||
|
const minStep = Math.min(...phase.steps);
|
||||||
|
if (currentStep > maxStep) return 'done';
|
||||||
|
if (currentStep >= minStep && currentStep <= maxStep) return 'active';
|
||||||
|
return 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrainingPhaseBar({ currentStep }) {
|
||||||
|
return (
|
||||||
|
<div className="dt-phase-bar" role="list" aria-label="培训阶段">
|
||||||
|
{TRAINING_PHASES.map((phase, index) => {
|
||||||
|
const status = getPhaseStatus(phase, currentStep);
|
||||||
|
return (
|
||||||
|
<React.Fragment key={phase.id}>
|
||||||
|
{index > 0 && (
|
||||||
|
<div
|
||||||
|
className={`dt-phase-connector ${status === 'done' || status === 'active' ? 'done' : ''}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className={`dt-phase-item dt-phase-item--${status}`} role="listitem">
|
||||||
|
<span className="dt-phase-icon">
|
||||||
|
{status === 'done' ? '✓' : index + 1}
|
||||||
|
</span>
|
||||||
|
<span className="dt-phase-label">{phase.label}</span>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TrainingStepHeader({
|
||||||
|
currentStep,
|
||||||
|
totalSteps = 8,
|
||||||
|
stepNames,
|
||||||
|
stepDetailHint = '',
|
||||||
|
}) {
|
||||||
|
const isComplete = currentStep >= totalSteps;
|
||||||
|
const progressPercent = (currentStep / totalSteps) * 100;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`dt-step-header ${isComplete ? 'dt-step-header--complete' : ''}`}>
|
||||||
|
<div className="dt-step-indicator">
|
||||||
|
<span className="dt-step-text">{isComplete ? '培训已完成' : '培训进度'}</span>
|
||||||
|
<span className="dt-step-count">
|
||||||
|
步骤 {currentStep}/{totalSteps}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TrainingPhaseBar currentStep={currentStep} />
|
||||||
|
|
||||||
|
<div className="dt-progress-bar" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
className="dt-progress-fill"
|
||||||
|
style={{ width: `${progressPercent}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dt-step-meta">
|
||||||
|
<span className="dt-step-name">{stepNames[currentStep - 1]}</span>
|
||||||
|
{stepDetailHint ? (
|
||||||
|
<span className="dt-step-detail-hint">{stepDetailHint}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DriverTrainingLayout({
|
||||||
|
currentStep,
|
||||||
|
totalSteps = 8,
|
||||||
|
stepNames,
|
||||||
|
stepDetailHint,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
embedded = false,
|
||||||
|
immersive = false,
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`driver-training-app${embedded ? ' driver-training-app--embedded' : ''}${immersive ? ' driver-training-app--signature-immersive' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="dt-page">
|
||||||
|
{!embedded && (
|
||||||
|
<header className="dt-header">
|
||||||
|
<div className="dt-header-bar">
|
||||||
|
<h1 className="dt-header-title">司机安全培训</h1>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
<div className={`dt-content${immersive ? ' dt-content--signature-immersive' : ''}`}>
|
||||||
|
{!immersive ? (
|
||||||
|
<TrainingStepHeader
|
||||||
|
currentStep={currentStep}
|
||||||
|
totalSteps={totalSteps}
|
||||||
|
stepNames={stepNames}
|
||||||
|
stepDetailHint={stepDetailHint}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
{footer && <div className={`dt-footer${immersive ? ' dt-footer--signature-immersive' : ''}`}>{footer}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { VEHICLE_CLASS_OPTIONS } from '../data/constants';
|
||||||
|
|
||||||
|
const FIELD_CONFIG = {
|
||||||
|
idFront: [
|
||||||
|
{ key: 'name', label: '姓名', required: true },
|
||||||
|
{ key: 'idNumber', label: '身份证号', required: true },
|
||||||
|
],
|
||||||
|
idBack: [{ key: 'validPeriod', label: '有效期限', required: true }],
|
||||||
|
licenseFront: [
|
||||||
|
{ key: 'name', label: '姓名', required: true },
|
||||||
|
{ key: 'licenseNumber', label: '证号', required: true },
|
||||||
|
{ key: 'vehicleClass', label: '准驾车型', required: true, inputType: 'select', options: VEHICLE_CLASS_OPTIONS },
|
||||||
|
{ key: 'validPeriod', label: '有效期限', required: true },
|
||||||
|
],
|
||||||
|
qualification: [
|
||||||
|
{ key: 'name', label: '姓名', required: true },
|
||||||
|
{ key: 'gender', label: '性别', required: false },
|
||||||
|
{ key: 'birthDate', label: '出生日期', required: false },
|
||||||
|
{ key: 'nationality', label: '国籍', required: false },
|
||||||
|
{ key: 'address', label: '住址', required: false },
|
||||||
|
{ key: 'certNumber', label: '证号', required: true },
|
||||||
|
{ key: 'vehicleClass', label: '准驾车型', required: true },
|
||||||
|
{ key: 'category', label: '从业资格类别', required: true },
|
||||||
|
{ key: 'initialIssueDate', label: '初次领证日期', required: true },
|
||||||
|
{ key: 'validFrom', label: '有效起始日期', required: true },
|
||||||
|
{ key: 'validPeriod', label: '有效期限', required: true },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CERT_FIELD_LABELS = Object.fromEntries(
|
||||||
|
Object.values(FIELD_CONFIG)
|
||||||
|
.flat()
|
||||||
|
.map((f) => [f.key, f.label]),
|
||||||
|
);
|
||||||
|
|
||||||
|
export function getCertFieldLabel(key) {
|
||||||
|
return CERT_FIELD_LABELS[key] || key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function OCRResultForm({ certType, data, onChange, errors = {} }) {
|
||||||
|
const fields = FIELD_CONFIG[certType];
|
||||||
|
if (!fields) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dt-ocr-form">
|
||||||
|
{fields.map((f) => (
|
||||||
|
<div className="dt-form-group" key={f.key}>
|
||||||
|
<label className="dt-label">
|
||||||
|
{f.label}
|
||||||
|
{f.required && <span style={{ color: '#F53F3F' }}> *</span>}
|
||||||
|
</label>
|
||||||
|
{f.inputType === 'select' ? (
|
||||||
|
<select
|
||||||
|
className={`dt-input dt-select ${errors[f.key] ? 'error' : ''}`}
|
||||||
|
value={data[f.key] || ''}
|
||||||
|
onChange={(e) => onChange(f.key, e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">请选择{f.label}</option>
|
||||||
|
{(f.options || []).map((code) => (
|
||||||
|
<option key={code} value={code}>
|
||||||
|
{code}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
className={`dt-input ${errors[f.key] ? 'error' : ''}`}
|
||||||
|
value={data[f.key] || ''}
|
||||||
|
onChange={(e) => onChange(f.key, e.target.value)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{errors[f.key] && <div className="dt-field-error">{errors[f.key]}</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ManualCertificateForm({ certType, data, onChange, errors = {} }) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="dt-manual-tip">请根据证件原件准确填写,相关信息将用于本次交车核验。</div>
|
||||||
|
<OCRResultForm certType={certType} data={data} onChange={onChange} errors={errors} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { DEMO_PHONE, MOCK_SMS_CODE } from '../data/constants';
|
||||||
|
|
||||||
|
const PHONE_REG = /^1[3-9]\d{9}$/;
|
||||||
|
|
||||||
|
export default function PhoneVerificationForm({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
onVerified,
|
||||||
|
showActions = true,
|
||||||
|
}) {
|
||||||
|
const phone = value.phone || DEMO_PHONE;
|
||||||
|
const code = value.code || MOCK_SMS_CODE;
|
||||||
|
const [phoneError, setPhoneError] = useState('');
|
||||||
|
const [codeError, setCodeError] = useState('');
|
||||||
|
const [countdown, setCountdown] = useState(0);
|
||||||
|
const [sending, setSending] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (countdown <= 0) return undefined;
|
||||||
|
const t = setTimeout(() => setCountdown((c) => c - 1), 1000);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [countdown]);
|
||||||
|
|
||||||
|
const patch = (data) => onChange?.({ phone, code, ...value, ...data });
|
||||||
|
|
||||||
|
const handleSendCode = () => {
|
||||||
|
if (!PHONE_REG.test(phone)) {
|
||||||
|
setPhoneError('请输入正确的11位手机号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPhoneError('');
|
||||||
|
setSending(true);
|
||||||
|
setTimeout(() => {
|
||||||
|
setSending(false);
|
||||||
|
setCountdown(60);
|
||||||
|
}, 500);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerify = () => {
|
||||||
|
if (!PHONE_REG.test(phone)) {
|
||||||
|
setPhoneError('请输入正确的11位手机号');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPhoneError('');
|
||||||
|
if (code !== MOCK_SMS_CODE) {
|
||||||
|
setCodeError('验证码错误,请重新输入。');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCodeError('');
|
||||||
|
setSuccess(true);
|
||||||
|
onChange?.({
|
||||||
|
phone,
|
||||||
|
code,
|
||||||
|
verified: true,
|
||||||
|
verifiedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
setTimeout(() => onVerified?.(), 800);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div className="dt-success-banner">
|
||||||
|
<span>✓</span>
|
||||||
|
<span>手机号验证成功</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="dt-form-group">
|
||||||
|
<label className="dt-label">手机号</label>
|
||||||
|
<input
|
||||||
|
className={`dt-input ${phoneError ? 'error' : ''}`}
|
||||||
|
type="tel"
|
||||||
|
maxLength={11}
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
value={phone}
|
||||||
|
onChange={(e) => {
|
||||||
|
patch({ phone: e.target.value.replace(/\D/g, '') });
|
||||||
|
setPhoneError('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{phoneError && <div className="dt-field-error">{phoneError}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="dt-form-group">
|
||||||
|
<label className="dt-label">验证码</label>
|
||||||
|
<div className="dt-code-row">
|
||||||
|
<input
|
||||||
|
className={`dt-input ${codeError ? 'error' : ''}`}
|
||||||
|
type="text"
|
||||||
|
maxLength={6}
|
||||||
|
placeholder="请输入验证码"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => {
|
||||||
|
patch({ code: e.target.value.replace(/\D/g, '') });
|
||||||
|
setCodeError('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dt-code-btn"
|
||||||
|
disabled={countdown > 0 || sending}
|
||||||
|
onClick={handleSendCode}
|
||||||
|
>
|
||||||
|
{sending ? '发送中...' : countdown > 0 ? `${countdown}s` : '获取验证码'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{codeError && <div className="dt-field-error">{codeError}</div>}
|
||||||
|
</div>
|
||||||
|
<p className="dt-field-hint">
|
||||||
|
手机号用于绑定本次培训记录和接收培训状态通知。
|
||||||
|
<br />
|
||||||
|
演示环境已预填手机号与验证码({MOCK_SMS_CODE}),可直接验证继续。
|
||||||
|
</p>
|
||||||
|
{showActions && (
|
||||||
|
<button type="button" className="dt-btn-primary" style={{ marginTop: 16 }} onClick={handleVerify}>
|
||||||
|
验证并继续
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPhoneStep(step2) {
|
||||||
|
const phone = step2.phone || DEMO_PHONE;
|
||||||
|
const code = step2.code || MOCK_SMS_CODE;
|
||||||
|
if (!PHONE_REG.test(phone)) {
|
||||||
|
return { ok: false, phoneError: '请输入正确的11位手机号' };
|
||||||
|
}
|
||||||
|
if (code !== MOCK_SMS_CODE) {
|
||||||
|
return { ok: false, codeError: '验证码错误,请重新输入。' };
|
||||||
|
}
|
||||||
|
return { ok: true, phone, code };
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
import StatusTag from './StatusTag';
|
||||||
|
|
||||||
|
import { PICKUP_STATUS } from '../data/constants';
|
||||||
|
|
||||||
|
function drawQRCode(canvas, text) {
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const size = 160;
|
||||||
|
canvas.width = size;
|
||||||
|
canvas.height = size;
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.fillRect(0, 0, size, size);
|
||||||
|
|
||||||
|
const hash = text.split('').reduce((a, c) => a + c.charCodeAt(0), 0);
|
||||||
|
const cell = 8;
|
||||||
|
const cells = size / cell;
|
||||||
|
|
||||||
|
for (let y = 0; y < cells; y++) {
|
||||||
|
for (let x = 0; x < cells; x++) {
|
||||||
|
const inCorner =
|
||||||
|
(x < 9 && y < 9) || (x > cells - 10 && y < 9) || (x < 9 && y > cells - 10);
|
||||||
|
const val = (hash * (x + 1) * (y + 1) + x * y) % 7;
|
||||||
|
if (inCorner) {
|
||||||
|
if (
|
||||||
|
(x === 1 || x === 7 || x === cells - 8 || x === cells - 2) &&
|
||||||
|
(y === 1 || y === 7)
|
||||||
|
) {
|
||||||
|
ctx.fillStyle = '#1D2129';
|
||||||
|
ctx.fillRect(x * cell, y * cell, cell, cell);
|
||||||
|
} else if (x >= 2 && x <= 6 && y >= 2 && y <= 6) {
|
||||||
|
ctx.fillStyle = '#1D2129';
|
||||||
|
ctx.fillRect(x * cell, y * cell, cell, cell);
|
||||||
|
}
|
||||||
|
} else if (val < 3) {
|
||||||
|
ctx.fillStyle = '#1D2129';
|
||||||
|
ctx.fillRect(x * cell, y * cell, cell, cell);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_HINTS = {
|
||||||
|
[PICKUP_STATUS.PENDING]: '请向运维人员出示二维码',
|
||||||
|
[PICKUP_STATUS.LOCKED]: '运维已核验,正在办理交车',
|
||||||
|
[PICKUP_STATUS.PROCESSING]: '等待双方完成电子签署',
|
||||||
|
[PICKUP_STATUS.COMPLETED]: '交车已完成,提车码已失效',
|
||||||
|
[PICKUP_STATUS.REVOKED]: '交车任务已取消,提车码不可使用',
|
||||||
|
[PICKUP_STATUS.EXPIRED]: '提车码已失效',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PickupCodeCard({ step7 }) {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const isDisabled =
|
||||||
|
step7.status === PICKUP_STATUS.COMPLETED ||
|
||||||
|
step7.status === PICKUP_STATUS.REVOKED ||
|
||||||
|
step7.status === PICKUP_STATUS.EXPIRED;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (canvasRef.current && step7.pickupCode) {
|
||||||
|
drawQRCode(canvasRef.current, step7.pickupCode);
|
||||||
|
}
|
||||||
|
}, [step7.pickupCode]);
|
||||||
|
|
||||||
|
const hint = STATUS_HINTS[step7.status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dt-pickup-page">
|
||||||
|
<div className="dt-pickup-success dt-animate-fade-in">
|
||||||
|
<div className="dt-pickup-icon">✓</div>
|
||||||
|
<h2 className="dt-pickup-title">安全培训已完成</h2>
|
||||||
|
<p className="dt-pickup-subtitle">请向运维人员出示下方提车码</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dt-card dt-qr-card dt-animate-scale-in">
|
||||||
|
<div className="dt-qr-card-header">
|
||||||
|
<StatusTag status={step7.status} />
|
||||||
|
{hint ? <p className="dt-qr-status-hint">{hint}</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="dt-qr-frame">
|
||||||
|
<canvas ref={canvasRef} className={`dt-qr-code ${isDisabled ? 'disabled' : ''}`} />
|
||||||
|
{isDisabled && <div className="dt-qr-overlay">已失效</div>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dt-warning-tip">提车码仅限本次交车使用,请勿转发给他人。</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useTraining } from '../context/TrainingContext';
|
||||||
|
|
||||||
|
export default function PrototypeDebugPanel() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const { state, applyDebugSettings, resetAll } = useTraining();
|
||||||
|
const { debug } = state;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dt-debug-panel">
|
||||||
|
{open && (
|
||||||
|
<div className="dt-debug-body">
|
||||||
|
<div className="dt-debug-section">
|
||||||
|
<label>车辆吨位</label>
|
||||||
|
<select
|
||||||
|
value={debug.vehicleTonnage}
|
||||||
|
onChange={(e) => applyDebugSettings({ vehicleTonnage: Number(e.target.value) })}
|
||||||
|
>
|
||||||
|
<option value={4.5}>4.5 吨</option>
|
||||||
|
<option value={18}>18 吨</option>
|
||||||
|
<option value={49}>49 吨</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="dt-debug-section">
|
||||||
|
<label>OCR 结果</label>
|
||||||
|
<select
|
||||||
|
value={debug.ocrMode}
|
||||||
|
onChange={(e) => applyDebugSettings({ ocrMode: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="success">识别成功</option>
|
||||||
|
<option value="fail">识别失败</option>
|
||||||
|
<option value="mismatch">字段不一致</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="dt-debug-section">
|
||||||
|
<label>摄像头权限</label>
|
||||||
|
<select
|
||||||
|
value={debug.cameraPermission}
|
||||||
|
onChange={(e) => applyDebugSettings({ cameraPermission: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="allow">允许</option>
|
||||||
|
<option value="deny">拒绝</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="dt-debug-section">
|
||||||
|
<label>视频状态</label>
|
||||||
|
<select
|
||||||
|
value={debug.videoStatus}
|
||||||
|
onChange={(e) => applyDebugSettings({ videoStatus: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="incomplete">未完成</option>
|
||||||
|
<option value="completed">已完成</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="dt-debug-section">
|
||||||
|
<label>提车状态</label>
|
||||||
|
<select
|
||||||
|
value={debug.pickupStatus}
|
||||||
|
onChange={(e) => applyDebugSettings({ pickupStatus: e.target.value })}
|
||||||
|
>
|
||||||
|
<option value="pending">待核验</option>
|
||||||
|
<option value="locked">已锁定</option>
|
||||||
|
<option value="single_sign">单方签字</option>
|
||||||
|
<option value="both_signed">双方签字完成</option>
|
||||||
|
<option value="revoked">任务撤销</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="dt-debug-reset" onClick={resetAll}>
|
||||||
|
重置全部培训数据
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="button" className="dt-debug-toggle" onClick={() => setOpen(!open)} title="原型控制台">
|
||||||
|
{open ? '×' : '调试'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const SignatureCanvas = forwardRef(function SignatureCanvas(
|
||||||
|
{ value, onChange, onClear, variant = 'default', hideActions = false, watermark = '' },
|
||||||
|
ref,
|
||||||
|
) {
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const wrapRef = useRef(null);
|
||||||
|
const drawing = useRef(false);
|
||||||
|
const [hasStroke, setHasStroke] = useState(!!value);
|
||||||
|
const isEsign = variant === 'esign';
|
||||||
|
const isFullscreen = variant === 'fullscreen' || isEsign;
|
||||||
|
|
||||||
|
const valueRef = useRef(value);
|
||||||
|
valueRef.current = value;
|
||||||
|
|
||||||
|
const setupCanvas = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const wrap = wrapRef.current;
|
||||||
|
if (!canvas || !wrap) return;
|
||||||
|
|
||||||
|
const rect = wrap.getBoundingClientRect();
|
||||||
|
if (rect.width <= 0 || rect.height <= 0) return;
|
||||||
|
|
||||||
|
const savedValue = valueRef.current;
|
||||||
|
canvas.width = rect.width * 2;
|
||||||
|
canvas.height = rect.height * 2;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||||
|
ctx.scale(2, 2);
|
||||||
|
ctx.strokeStyle = '#1D2129';
|
||||||
|
ctx.lineWidth = isEsign ? 3 : isFullscreen ? 2.5 : 2;
|
||||||
|
ctx.lineCap = 'round';
|
||||||
|
ctx.lineJoin = 'round';
|
||||||
|
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||||
|
|
||||||
|
if (savedValue) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => ctx.drawImage(img, 0, 0, rect.width, rect.height);
|
||||||
|
img.src = savedValue;
|
||||||
|
}
|
||||||
|
}, [isEsign, isFullscreen]);
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const wrap = wrapRef.current;
|
||||||
|
if (!canvas || !wrap) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const rect = wrap.getBoundingClientRect();
|
||||||
|
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||||
|
setHasStroke(false);
|
||||||
|
onChange?.(null);
|
||||||
|
onClear?.();
|
||||||
|
}, [onChange, onClear]);
|
||||||
|
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
clear: handleClear,
|
||||||
|
hasStroke: () => hasStroke,
|
||||||
|
}));
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setupCanvas();
|
||||||
|
const wrap = wrapRef.current;
|
||||||
|
if (!wrap || typeof ResizeObserver === 'undefined') return undefined;
|
||||||
|
|
||||||
|
const observer = new ResizeObserver(() => setupCanvas());
|
||||||
|
observer.observe(wrap);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [setupCanvas]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!drawing.current) {
|
||||||
|
setupCanvas();
|
||||||
|
}
|
||||||
|
}, [value, setupCanvas]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setHasStroke(!!value);
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
const getPos = (e, canvas) => {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
|
||||||
|
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
|
||||||
|
return { x: clientX - rect.left, y: clientY - rect.top };
|
||||||
|
};
|
||||||
|
|
||||||
|
const startDraw = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
drawing.current = true;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const { x, y } = getPos(e, canvas);
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, y);
|
||||||
|
};
|
||||||
|
|
||||||
|
const draw = (e) => {
|
||||||
|
if (!drawing.current) return;
|
||||||
|
e.preventDefault();
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const { x, y } = getPos(e, canvas);
|
||||||
|
ctx.lineTo(x, y);
|
||||||
|
ctx.stroke();
|
||||||
|
setHasStroke(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const endDraw = () => {
|
||||||
|
if (!drawing.current) return;
|
||||||
|
drawing.current = false;
|
||||||
|
const canvas = canvasRef.current;
|
||||||
|
onChange?.(canvas.toDataURL('image/png'));
|
||||||
|
};
|
||||||
|
|
||||||
|
const placeholderText = isEsign ? watermark || '请在此处签名' : isFullscreen ? '请横屏在此处签名' : '请在此处签名';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`dt-signature-wrap ${isEsign ? 'dt-signature-wrap--esign' : ''} ${isFullscreen && !isEsign ? 'dt-signature-wrap--fullscreen' : ''}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
ref={wrapRef}
|
||||||
|
className={`dt-signature-pad ${hasStroke ? 'dt-signature-pad--signed' : ''} ${isEsign ? 'dt-signature-pad--esign' : ''} ${isFullscreen && !isEsign ? 'dt-signature-pad--fullscreen' : ''}`}
|
||||||
|
>
|
||||||
|
{!hasStroke && !isEsign && (
|
||||||
|
<span className="dt-signature-placeholder" aria-hidden="true">
|
||||||
|
{placeholderText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isEsign && !hasStroke && (
|
||||||
|
<span className="dt-signature-watermark" aria-hidden="true">
|
||||||
|
{watermark || '请在此处签名'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className={`dt-signature-baseline ${isEsign ? 'dt-signature-baseline--esign' : ''}`} aria-hidden="true" />
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className={`dt-signature-canvas ${isEsign ? 'dt-signature-canvas--esign' : ''} ${isFullscreen && !isEsign ? 'dt-signature-canvas--fullscreen' : ''}`}
|
||||||
|
onMouseDown={startDraw}
|
||||||
|
onMouseMove={draw}
|
||||||
|
onMouseUp={endDraw}
|
||||||
|
onMouseLeave={endDraw}
|
||||||
|
onTouchStart={startDraw}
|
||||||
|
onTouchMove={draw}
|
||||||
|
onTouchEnd={endDraw}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{!hideActions && (
|
||||||
|
<div className={`dt-signature-actions ${isFullscreen ? 'dt-signature-actions--fullscreen' : ''}`}>
|
||||||
|
<button type="button" className="dt-btn-secondary dt-signature-clear" onClick={handleClear}>
|
||||||
|
清除重签
|
||||||
|
</button>
|
||||||
|
{hasStroke && (
|
||||||
|
<span className="dt-signature-done-badge">
|
||||||
|
<span aria-hidden="true">✓</span> 已签名
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SignatureCanvas;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { PICKUP_STATUS, PICKUP_STATUS_LABELS } from '../data/constants';
|
||||||
|
|
||||||
|
const STATUS_CLASS = {
|
||||||
|
[PICKUP_STATUS.PENDING]: 'pending',
|
||||||
|
[PICKUP_STATUS.LOCKED]: 'locked',
|
||||||
|
[PICKUP_STATUS.PROCESSING]: 'processing',
|
||||||
|
[PICKUP_STATUS.COMPLETED]: 'completed',
|
||||||
|
[PICKUP_STATUS.EXPIRED]: 'expired',
|
||||||
|
[PICKUP_STATUS.REVOKED]: 'revoked',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function StatusTag({ status }) {
|
||||||
|
const cls = STATUS_CLASS[status] || 'pending';
|
||||||
|
const label = PICKUP_STATUS_LABELS[status] || status;
|
||||||
|
return <span className={`dt-status-tag ${cls}`}>{label}</span>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
let toastTimer = null;
|
||||||
|
|
||||||
|
export default function Toast({ message, onClose }) {
|
||||||
|
const [visible, setVisible] = useState(!!message);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!message) return undefined;
|
||||||
|
setVisible(true);
|
||||||
|
if (toastTimer) clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(() => {
|
||||||
|
setVisible(false);
|
||||||
|
onClose?.();
|
||||||
|
}, 2500);
|
||||||
|
return () => clearTimeout(toastTimer);
|
||||||
|
}, [message, onClose]);
|
||||||
|
|
||||||
|
if (!visible || !message) return null;
|
||||||
|
return <div className="dt-toast">{message}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useToast() {
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const show = (msg) => setMessage(msg);
|
||||||
|
const clear = () => setMessage('');
|
||||||
|
return { message, show, clear };
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef } from 'react';
|
||||||
|
|
||||||
|
export default function TrainingDocumentViewer({ content, onProgress, onReachBottom, startTime, onStart }) {
|
||||||
|
const ref = useRef(null);
|
||||||
|
const started = useRef(false);
|
||||||
|
|
||||||
|
const handleScroll = useCallback(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el) return;
|
||||||
|
if (!started.current) {
|
||||||
|
started.current = true;
|
||||||
|
onStart?.();
|
||||||
|
}
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = el;
|
||||||
|
const progress = Math.min(100, Math.round((scrollTop / (scrollHeight - clientHeight || 1)) * 100));
|
||||||
|
onProgress?.(progress);
|
||||||
|
if (scrollTop + clientHeight >= scrollHeight - 8) {
|
||||||
|
onReachBottom?.();
|
||||||
|
}
|
||||||
|
}, [onProgress, onReachBottom, onStart]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
handleScroll();
|
||||||
|
}, [handleScroll, content]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="dt-document-viewer" onScroll={handleScroll}>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
const DURATION = 30;
|
||||||
|
|
||||||
|
function IconPlay() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||||
|
<path d="M8 5v14l11-7z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IconPause() {
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
|
||||||
|
<path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrainingVideoPlayer({ completed, watchedSeconds, onProgress, onComplete, debugComplete }) {
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [current, setCurrent] = useState(watchedSeconds || 0);
|
||||||
|
const timerRef = useRef(null);
|
||||||
|
|
||||||
|
const stopTimer = useCallback(() => {
|
||||||
|
if (timerRef.current) {
|
||||||
|
clearInterval(timerRef.current);
|
||||||
|
timerRef.current = null;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleVisibility = () => {
|
||||||
|
if (document.hidden) setPlaying(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility);
|
||||||
|
return () => document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playing || completed) {
|
||||||
|
stopTimer();
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
timerRef.current = setInterval(() => {
|
||||||
|
setCurrent((prev) => {
|
||||||
|
const next = Math.min(prev + 1, DURATION);
|
||||||
|
onProgress?.(next);
|
||||||
|
if (next >= DURATION) {
|
||||||
|
setPlaying(false);
|
||||||
|
onComplete?.();
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
return () => stopTimer();
|
||||||
|
}, [playing, completed, onProgress, onComplete, stopTimer]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (debugComplete && !completed) {
|
||||||
|
setCurrent(DURATION);
|
||||||
|
onProgress?.(DURATION);
|
||||||
|
onComplete?.();
|
||||||
|
}
|
||||||
|
}, [debugComplete, completed, onProgress, onComplete]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (completed) setCurrent(DURATION);
|
||||||
|
}, [completed]);
|
||||||
|
|
||||||
|
const togglePlay = () => {
|
||||||
|
if (completed) return;
|
||||||
|
setPlaying((p) => !p);
|
||||||
|
};
|
||||||
|
|
||||||
|
const progress = (current / DURATION) * 100;
|
||||||
|
const formatTime = (s) => `${String(Math.floor(s / 60)).padStart(2, '0')}:${String(s % 60).padStart(2, '0')}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dt-video-player">
|
||||||
|
<div className="dt-video-screen">
|
||||||
|
<div className="dt-video-overlay">
|
||||||
|
<div style={{ fontSize: 18, fontWeight: 600 }}>氢能重卡安全驾驶培训</div>
|
||||||
|
<div style={{ fontSize: 13, opacity: 0.8 }}>预计时长 30 秒</div>
|
||||||
|
{!playing && !completed && (
|
||||||
|
<button type="button" className="dt-video-play-btn" onClick={togglePlay} style={{ width: 56, height: 56, fontSize: 24 }}>
|
||||||
|
<IconPlay />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dt-video-controls">
|
||||||
|
<button type="button" className="dt-video-play-btn" onClick={togglePlay} disabled={completed}>
|
||||||
|
{playing ? <IconPause /> : <IconPlay />}
|
||||||
|
</button>
|
||||||
|
<div className="dt-video-progress">
|
||||||
|
<div className="dt-video-progress-fill" style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
<span style={{ color: '#fff', fontSize: 12, flexShrink: 0 }}>
|
||||||
|
{formatTime(current)}/{formatTime(DURATION)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CERT_LABELS } from '../data/constants';
|
||||||
|
|
||||||
|
export default function UploadSourceSheet({ visible, certType, onCamera, onAlbum, onCancel }) {
|
||||||
|
if (!visible || !certType) return null;
|
||||||
|
const label = CERT_LABELS[certType] || '证件';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="dt-sheet-overlay" onClick={onCancel}>
|
||||||
|
<div className="dt-sheet" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="dt-sheet-title">上传{label}</div>
|
||||||
|
<p className="dt-sheet-desc">请选择获取证件照片的方式</p>
|
||||||
|
<button type="button" className="dt-sheet-option" onClick={onCamera}>
|
||||||
|
<span className="dt-sheet-option-icon" aria-hidden>📷</span>
|
||||||
|
<span className="dt-sheet-option-text">
|
||||||
|
<strong>拍照上传</strong>
|
||||||
|
<small>调用摄像头拍摄证件</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="dt-sheet-option" onClick={onAlbum}>
|
||||||
|
<span className="dt-sheet-option-icon" aria-hidden>🖼</span>
|
||||||
|
<span className="dt-sheet-option-text">
|
||||||
|
<strong>从相册选择</strong>
|
||||||
|
<small>选择本地已保存的证件照片</small>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="dt-sheet-cancel" onClick={onCancel}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||||
|
import {
|
||||||
|
DOCUMENT_VERSION,
|
||||||
|
PICKUP_STATUS,
|
||||||
|
STEP_NAMES,
|
||||||
|
TOTAL_STEPS,
|
||||||
|
} from '../data/constants';
|
||||||
|
import {
|
||||||
|
createInitialState,
|
||||||
|
generatePickupCode,
|
||||||
|
loadTrainingState,
|
||||||
|
resetTrainingState,
|
||||||
|
saveTrainingState,
|
||||||
|
} from '../data/storage';
|
||||||
|
|
||||||
|
const TrainingContext = createContext(null);
|
||||||
|
|
||||||
|
function isQualificationRequired(tonnage) {
|
||||||
|
return tonnage > 18;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequiredCertTypes(tonnage) {
|
||||||
|
const base = ['idFront', 'idBack', 'licenseFront', 'licenseBack'];
|
||||||
|
if (isQualificationRequired(tonnage)) base.push('qualification');
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { isQualificationRequired, getRequiredCertTypes };
|
||||||
|
|
||||||
|
export function TrainingProvider({ children }) {
|
||||||
|
const [state, setState] = useState(() => loadTrainingState());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
saveTrainingState(state);
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
const updateState = useCallback((updater) => {
|
||||||
|
setState((prev) => {
|
||||||
|
const next = typeof updater === 'function' ? updater(prev) : { ...prev, ...updater };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const invalidateAfterStep3 = useCallback((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step6: {
|
||||||
|
signatureDataUrl: null,
|
||||||
|
checked: false,
|
||||||
|
submitted: false,
|
||||||
|
submittedAt: null,
|
||||||
|
},
|
||||||
|
step7: {
|
||||||
|
pickupCode: null,
|
||||||
|
numericCode: null,
|
||||||
|
status: PICKUP_STATUS.PENDING,
|
||||||
|
generatedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
lockedAt: null,
|
||||||
|
},
|
||||||
|
maxCompletedStep: Math.min(prev.maxCompletedStep, 5),
|
||||||
|
currentStep: Math.min(prev.currentStep, 6),
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
const goToStep = useCallback((step) => {
|
||||||
|
setState((prev) => {
|
||||||
|
if (step > prev.maxCompletedStep + 1) return prev;
|
||||||
|
return { ...prev, currentStep: step };
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const completeStep = useCallback((step) => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
maxCompletedStep: Math.max(prev.maxCompletedStep, step),
|
||||||
|
currentStep: Math.min(step + 1, TOTAL_STEPS),
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetAll = useCallback(() => {
|
||||||
|
const fresh = resetTrainingState();
|
||||||
|
setState(fresh);
|
||||||
|
return fresh;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyDebugSettings = useCallback((debugPatch) => {
|
||||||
|
setState((prev) => {
|
||||||
|
let next = {
|
||||||
|
...prev,
|
||||||
|
debug: { ...prev.debug, ...debugPatch },
|
||||||
|
};
|
||||||
|
|
||||||
|
if (debugPatch.vehicleTonnage !== undefined) {
|
||||||
|
next.trainingTask = {
|
||||||
|
...next.trainingTask,
|
||||||
|
vehicleTonnage: debugPatch.vehicleTonnage,
|
||||||
|
vehicleType:
|
||||||
|
debugPatch.vehicleTonnage >= 49
|
||||||
|
? '49吨氢能重卡'
|
||||||
|
: debugPatch.vehicleTonnage >= 18
|
||||||
|
? '18吨氢能轻卡'
|
||||||
|
: '4.5吨氢能物流车',
|
||||||
|
};
|
||||||
|
if (prev.step7.pickupCode) {
|
||||||
|
next = invalidateAfterStep3(next);
|
||||||
|
next.step7 = { ...next.step7, status: PICKUP_STATUS.REVOKED };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debugPatch.pickupStatus !== undefined) {
|
||||||
|
const statusMap = {
|
||||||
|
pending: PICKUP_STATUS.PENDING,
|
||||||
|
locked: PICKUP_STATUS.LOCKED,
|
||||||
|
single_sign: PICKUP_STATUS.PROCESSING,
|
||||||
|
both_signed: PICKUP_STATUS.COMPLETED,
|
||||||
|
revoked: PICKUP_STATUS.REVOKED,
|
||||||
|
};
|
||||||
|
const status = statusMap[debugPatch.pickupStatus] || PICKUP_STATUS.PENDING;
|
||||||
|
next.step7 = {
|
||||||
|
...next.step7,
|
||||||
|
status,
|
||||||
|
pickupCode: next.step7.pickupCode || `PK-${next.trainingTask.taskId}`,
|
||||||
|
numericCode: next.step7.numericCode || generatePickupCode(),
|
||||||
|
generatedAt: next.step7.generatedAt || new Date().toISOString(),
|
||||||
|
lockedAt: status !== PICKUP_STATUS.PENDING ? new Date().toISOString() : null,
|
||||||
|
completedAt: status === PICKUP_STATUS.COMPLETED ? new Date().toISOString() : null,
|
||||||
|
};
|
||||||
|
if (status === PICKUP_STATUS.COMPLETED || status === PICKUP_STATUS.REVOKED) {
|
||||||
|
next.step7.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (debugPatch.videoStatus === 'completed') {
|
||||||
|
next.step4 = {
|
||||||
|
completed: true,
|
||||||
|
watchedSeconds: 30,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
} else if (debugPatch.videoStatus === 'incomplete') {
|
||||||
|
next.step4 = { completed: false, watchedSeconds: 0, completedAt: null };
|
||||||
|
next.maxCompletedStep = Math.min(next.maxCompletedStep, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [invalidateAfterStep3]);
|
||||||
|
|
||||||
|
const updateCertificate = useCallback((certType, data) => {
|
||||||
|
setState((prev) => {
|
||||||
|
let next = {
|
||||||
|
...prev,
|
||||||
|
step3: {
|
||||||
|
...prev.step3,
|
||||||
|
certificates: {
|
||||||
|
...prev.step3.certificates,
|
||||||
|
[certType]: { ...prev.step3.certificates[certType], ...data },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (prev.step6.submitted || prev.step7.pickupCode) {
|
||||||
|
next = invalidateAfterStep3(next);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [invalidateAfterStep3]);
|
||||||
|
|
||||||
|
const submitTraining = useCallback(() => {
|
||||||
|
setState((prev) => {
|
||||||
|
const numericCode = generatePickupCode();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
step6: {
|
||||||
|
...prev.step6,
|
||||||
|
submitted: true,
|
||||||
|
submittedAt: now,
|
||||||
|
},
|
||||||
|
step7: {
|
||||||
|
pickupCode: `PK-${prev.trainingTask.taskId}-${numericCode}`,
|
||||||
|
numericCode,
|
||||||
|
status: PICKUP_STATUS.PENDING,
|
||||||
|
generatedAt: now,
|
||||||
|
completedAt: null,
|
||||||
|
lockedAt: null,
|
||||||
|
},
|
||||||
|
maxCompletedStep: TOTAL_STEPS,
|
||||||
|
currentStep: TOTAL_STEPS,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
state,
|
||||||
|
updateState,
|
||||||
|
goToStep,
|
||||||
|
completeStep,
|
||||||
|
resetAll,
|
||||||
|
applyDebugSettings,
|
||||||
|
updateCertificate,
|
||||||
|
submitTraining,
|
||||||
|
isQualificationRequired,
|
||||||
|
getRequiredCertTypes,
|
||||||
|
stepNames: STEP_NAMES,
|
||||||
|
documentVersion: DOCUMENT_VERSION,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
state,
|
||||||
|
updateState,
|
||||||
|
goToStep,
|
||||||
|
completeStep,
|
||||||
|
resetAll,
|
||||||
|
applyDebugSettings,
|
||||||
|
updateCertificate,
|
||||||
|
submitTraining,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return <TrainingContext.Provider value={value}>{children}</TrainingContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTraining() {
|
||||||
|
const ctx = useContext(TrainingContext);
|
||||||
|
if (!ctx) throw new Error('useTraining must be used within TrainingProvider');
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { createInitialState };
|
||||||
146
src/prototypes/xll-miniapp/driver-training/data/constants.js
vendored
Normal file
146
src/prototypes/xll-miniapp/driver-training/data/constants.js
vendored
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
export const THEME = {
|
||||||
|
primary: '#7AB929',
|
||||||
|
primaryLight: 'rgba(122, 185, 41, 0.14)',
|
||||||
|
bg: '#F7F8FA',
|
||||||
|
card: '#FFFFFF',
|
||||||
|
title: '#1D2129',
|
||||||
|
text: '#4E5969',
|
||||||
|
textSecondary: '#86909C',
|
||||||
|
border: '#E5E6EB',
|
||||||
|
danger: '#F53F3F',
|
||||||
|
warning: '#FF7D00',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STORAGE_KEY = 'xll_driver_training_state_v1';
|
||||||
|
|
||||||
|
export const DOCUMENT_VERSION = 'V2.1.0-20260701';
|
||||||
|
|
||||||
|
export const MOCK_SMS_CODE = '123456';
|
||||||
|
|
||||||
|
/** 原型演示用手机号(虚构脱敏) */
|
||||||
|
export const DEMO_PHONE = '13812345678';
|
||||||
|
|
||||||
|
export const STEP_NAMES = [
|
||||||
|
'阅读培训文档',
|
||||||
|
'手机号验证',
|
||||||
|
'司机证照',
|
||||||
|
'安全培训视频',
|
||||||
|
'本人照片',
|
||||||
|
'信息确认',
|
||||||
|
'手写签名',
|
||||||
|
'生成提车码',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const TOTAL_STEPS = STEP_NAMES.length;
|
||||||
|
|
||||||
|
export const CERT_TYPES = {
|
||||||
|
ID_FRONT: 'idFront',
|
||||||
|
ID_BACK: 'idBack',
|
||||||
|
LICENSE_FRONT: 'licenseFront',
|
||||||
|
LICENSE_BACK: 'licenseBack',
|
||||||
|
QUALIFICATION: 'qualification',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CERT_LABELS = {
|
||||||
|
[CERT_TYPES.ID_FRONT]: '身份证正面',
|
||||||
|
[CERT_TYPES.ID_BACK]: '身份证反面',
|
||||||
|
[CERT_TYPES.LICENSE_FRONT]: '驾驶证正页',
|
||||||
|
[CERT_TYPES.LICENSE_BACK]: '驾驶证副页',
|
||||||
|
[CERT_TYPES.QUALIFICATION]: '从业资格证',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PICKUP_STATUS = {
|
||||||
|
PENDING: 'pending',
|
||||||
|
LOCKED: 'locked',
|
||||||
|
PROCESSING: 'processing',
|
||||||
|
COMPLETED: 'completed',
|
||||||
|
EXPIRED: 'expired',
|
||||||
|
REVOKED: 'revoked',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PICKUP_STATUS_LABELS = {
|
||||||
|
[PICKUP_STATUS.PENDING]: '待核验',
|
||||||
|
[PICKUP_STATUS.LOCKED]: '已锁定',
|
||||||
|
[PICKUP_STATUS.PROCESSING]: '交车处理中',
|
||||||
|
[PICKUP_STATUS.COMPLETED]: '已完成',
|
||||||
|
[PICKUP_STATUS.EXPIRED]: '已失效',
|
||||||
|
[PICKUP_STATUS.REVOKED]: '已撤销',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const OCR_MOCK = {
|
||||||
|
idFront: {
|
||||||
|
name: '张建国',
|
||||||
|
idNumber: '3304**********1234',
|
||||||
|
},
|
||||||
|
idBack: {
|
||||||
|
validPeriod: '2024.01.01—2044.01.01',
|
||||||
|
},
|
||||||
|
licenseFront: {
|
||||||
|
name: '张建国',
|
||||||
|
licenseNumber: '3304**********1234',
|
||||||
|
vehicleClass: 'A2',
|
||||||
|
validPeriod: '2024.01.01—2034.01.01',
|
||||||
|
},
|
||||||
|
licenseBack: {},
|
||||||
|
qualification: {
|
||||||
|
name: '张建国',
|
||||||
|
gender: '男',
|
||||||
|
birthDate: '1985-03-27',
|
||||||
|
nationality: '中国',
|
||||||
|
address: '浙江省嘉兴市南湖区',
|
||||||
|
certNumber: '3304********5678',
|
||||||
|
vehicleClass: 'A1A2',
|
||||||
|
category: '经营性道路货物运输驾驶员',
|
||||||
|
initialIssueDate: '2021年04月22日',
|
||||||
|
validFrom: '2021年04月22日',
|
||||||
|
validPeriod: '2027年04月21日止',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TRAINING_DOCUMENT = `一、驾驶员安全责任
|
||||||
|
|
||||||
|
1. 驾驶员须严格遵守国家道路交通安全法律法规及公司安全管理制度。
|
||||||
|
2. 出车前须确认本人身体状况良好,严禁疲劳驾驶、酒后驾驶或服用影响驾驶安全的药物后驾车。
|
||||||
|
3. 驾驶员对车辆运行安全、货物安全及氢能系统安全负有直接责任。
|
||||||
|
|
||||||
|
二、出车前检查要求
|
||||||
|
|
||||||
|
1. 检查轮胎气压、制动系统、转向系统、灯光信号是否正常。
|
||||||
|
2. 检查氢气储存系统、管路接头、阀门及泄漏报警装置。
|
||||||
|
3. 检查仪表盘各项指示,确认无故障报警后方可出车。
|
||||||
|
4. 检查随车安全设施:灭火器、三角警示牌、反光背心等。
|
||||||
|
|
||||||
|
三、氢能车辆使用注意事项
|
||||||
|
|
||||||
|
1. 加氢须在指定加氢站进行,严禁私自改装氢气系统。
|
||||||
|
2. 发现氢气泄漏气味或报警时,立即停车熄火,撤离至安全区域并上报。
|
||||||
|
3. 禁止在加氢站、隧道、密闭空间等危险区域吸烟或使用明火。
|
||||||
|
4. 氢气瓶区域严禁敲击、碰撞,避免高温环境长时间停放。
|
||||||
|
5. 车辆停放时应选择通风良好区域,远离火源和人员密集场所。
|
||||||
|
|
||||||
|
四、突发事件处理要求
|
||||||
|
|
||||||
|
1. 发生交通事故时,立即报警并保护现场,按规定上报公司安全部门。
|
||||||
|
2. 发生氢气泄漏时,切断电源,疏散人员,设置警戒区,等待专业人员处置。
|
||||||
|
3. 发生火情时,使用车载灭火器进行初期扑救,无法控制时立即撤离并报警。
|
||||||
|
4. 所有突发事件须在30分钟内完成首次上报。
|
||||||
|
|
||||||
|
五、禁止事项
|
||||||
|
|
||||||
|
1. 严禁无证驾驶、准驾车型不符驾驶。
|
||||||
|
2. 严禁超速、超载、超员行驶。
|
||||||
|
3. 严禁行车中使用手机或其他分散注意力的行为。
|
||||||
|
4. 严禁擅自拆卸、改装车辆安全及氢能系统。
|
||||||
|
5. 严禁将车辆交予未经培训和授权的人员驾驶。
|
||||||
|
|
||||||
|
六、个人信息和证件使用说明
|
||||||
|
|
||||||
|
1. 本次培训采集的身份证、驾驶证、从业资格证及本人照片仅用于本次交车核验和安全培训存档。
|
||||||
|
2. 证件信息须真实有效,如有虚假将承担相应法律责任。
|
||||||
|
3. 培训记录与交车任务绑定,提车码仅限本人本次交车使用。
|
||||||
|
4. 如对证件采集有异议,请联系现场运维人员协助处理。`;
|
||||||
|
|
||||||
|
export const REQUIRED_VEHICLE_CLASS = 'A2';
|
||||||
|
|
||||||
|
/** 现行机动车驾驶证准驾车型代号(本流程可选范围) */
|
||||||
|
export const VEHICLE_CLASS_OPTIONS = ['A1', 'A2', 'A3', 'B1', 'B2', 'C1', 'C2'];
|
||||||
106
src/prototypes/xll-miniapp/driver-training/data/storage.js
vendored
Normal file
106
src/prototypes/xll-miniapp/driver-training/data/storage.js
vendored
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import { STORAGE_KEY, DEMO_PHONE, MOCK_SMS_CODE } from './constants';
|
||||||
|
|
||||||
|
export function createInitialState(overrides = {}) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
return {
|
||||||
|
trainingTask: {
|
||||||
|
taskId: 'JC202607170023',
|
||||||
|
projectName: '上海示范运营项目',
|
||||||
|
vehiclePlate: '浙F·****8',
|
||||||
|
vehicleType: '49吨氢能重卡',
|
||||||
|
vehicleTonnage: 49,
|
||||||
|
plannedDate: '2026-07-20',
|
||||||
|
requiredVehicleClass: 'A2',
|
||||||
|
...overrides.trainingTask,
|
||||||
|
},
|
||||||
|
currentStep: 1,
|
||||||
|
maxCompletedStep: 0,
|
||||||
|
step1: {
|
||||||
|
readProgress: 0,
|
||||||
|
scrolledToBottom: false,
|
||||||
|
confirmed: false,
|
||||||
|
checked: false,
|
||||||
|
startTime: null,
|
||||||
|
endTime: null,
|
||||||
|
confirmTime: null,
|
||||||
|
documentVersion: null,
|
||||||
|
},
|
||||||
|
step2: {
|
||||||
|
phone: DEMO_PHONE,
|
||||||
|
code: MOCK_SMS_CODE,
|
||||||
|
verified: false,
|
||||||
|
verifiedAt: null,
|
||||||
|
},
|
||||||
|
step3: {
|
||||||
|
certificates: {},
|
||||||
|
},
|
||||||
|
step4: {
|
||||||
|
completed: false,
|
||||||
|
watchedSeconds: 0,
|
||||||
|
completedAt: null,
|
||||||
|
},
|
||||||
|
step5: {
|
||||||
|
photoDataUrl: null,
|
||||||
|
confirmed: false,
|
||||||
|
capturedAt: null,
|
||||||
|
},
|
||||||
|
step6: {
|
||||||
|
signatureDataUrl: null,
|
||||||
|
checked: false,
|
||||||
|
submitted: false,
|
||||||
|
submittedAt: null,
|
||||||
|
},
|
||||||
|
step7: {
|
||||||
|
pickupCode: null,
|
||||||
|
numericCode: null,
|
||||||
|
status: 'pending',
|
||||||
|
generatedAt: null,
|
||||||
|
completedAt: null,
|
||||||
|
lockedAt: null,
|
||||||
|
},
|
||||||
|
debug: {
|
||||||
|
vehicleTonnage: 49,
|
||||||
|
ocrMode: 'success',
|
||||||
|
cameraPermission: 'allow',
|
||||||
|
videoStatus: 'incomplete',
|
||||||
|
pickupStatus: 'pending',
|
||||||
|
},
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadTrainingState() {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return createInitialState();
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
const merged = { ...createInitialState(), ...parsed };
|
||||||
|
merged.trainingTask.vehicleTonnage = merged.debug?.vehicleTonnage ?? merged.trainingTask.vehicleTonnage;
|
||||||
|
if (merged.step6?.submitted && merged.step7?.pickupCode && merged.currentStep < 8) {
|
||||||
|
merged.currentStep = 8;
|
||||||
|
merged.maxCompletedStep = Math.max(merged.maxCompletedStep || 0, 8);
|
||||||
|
}
|
||||||
|
if (merged.currentStep === 7 && merged.step7?.pickupCode && !merged.step6?.signatureDataUrl) {
|
||||||
|
merged.currentStep = 8;
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
} catch {
|
||||||
|
return createInitialState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveTrainingState(state) {
|
||||||
|
const toSave = { ...state, updatedAt: new Date().toISOString() };
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(toSave));
|
||||||
|
return toSave;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetTrainingState() {
|
||||||
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
return createInitialState();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generatePickupCode() {
|
||||||
|
return String(Math.floor(100000 + Math.random() * 900000));
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { DOCUMENT_VERSION, TRAINING_DOCUMENT } from '../data/constants';
|
||||||
|
import TrainingDocumentViewer from '../components/TrainingDocumentViewer';
|
||||||
|
|
||||||
|
export default function Step1Document({ state, updateState, completeStep, renderPart = 'all' }) {
|
||||||
|
const { step1 } = state;
|
||||||
|
const readProgress = step1.readProgress || 0;
|
||||||
|
const scrolledToBottom = step1.scrolledToBottom;
|
||||||
|
const checked = step1.checked;
|
||||||
|
|
||||||
|
const handleStart = () => {
|
||||||
|
if (!step1.startTime) {
|
||||||
|
updateState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step1: { ...prev.step1, startTime: new Date().toISOString() },
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReachBottom = () => {
|
||||||
|
updateState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step1: {
|
||||||
|
...prev.step1,
|
||||||
|
scrolledToBottom: true,
|
||||||
|
readProgress: 100,
|
||||||
|
endTime: prev.step1.endTime || new Date().toISOString(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNext = () => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
updateState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step1: {
|
||||||
|
...prev.step1,
|
||||||
|
confirmed: true,
|
||||||
|
checked: true,
|
||||||
|
confirmTime: now,
|
||||||
|
documentVersion: DOCUMENT_VERSION,
|
||||||
|
readProgress: 100,
|
||||||
|
scrolledToBottom: true,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
completeStep(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const canProceed = scrolledToBottom && checked;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="dt-card">
|
||||||
|
<h2 className="dt-card-title">安全培训须知</h2>
|
||||||
|
<TrainingDocumentViewer
|
||||||
|
content={TRAINING_DOCUMENT}
|
||||||
|
onProgress={(p) => {
|
||||||
|
updateState((prev) => ({ ...prev, step1: { ...prev.step1, readProgress: p } }));
|
||||||
|
}}
|
||||||
|
onReachBottom={handleReachBottom}
|
||||||
|
onStart={handleStart}
|
||||||
|
/>
|
||||||
|
<div className="dt-read-progress">
|
||||||
|
<div className="dt-read-progress-bar">
|
||||||
|
<div className="dt-read-progress-fill" style={{ width: `${readProgress}%` }} />
|
||||||
|
</div>
|
||||||
|
<span>已阅读 {readProgress}%</span>
|
||||||
|
</div>
|
||||||
|
<label className="dt-checkbox-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={!scrolledToBottom}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step1: { ...prev.step1, checked: e.target.checked },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span>本人已完整阅读并知晓以上内容</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const footer = (
|
||||||
|
<>
|
||||||
|
<div className="dt-footer-hint">
|
||||||
|
{!scrolledToBottom ? '请完整阅读培训文档后继续。' : !checked ? '请勾选确认后继续。' : ''}
|
||||||
|
</div>
|
||||||
|
<button type="button" className="dt-btn-primary" disabled={!canProceed} onClick={handleNext}>
|
||||||
|
我已知晓,进入下一步
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (renderPart === 'content') return content;
|
||||||
|
if (renderPart === 'footer') return footer;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{content}
|
||||||
|
{footer}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
105
src/prototypes/xll-miniapp/driver-training/steps/Step2Phone.jsx
Normal file
105
src/prototypes/xll-miniapp/driver-training/steps/Step2Phone.jsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import PhoneVerificationForm, { verifyPhoneStep } from '../components/PhoneVerificationForm';
|
||||||
|
import { DEMO_PHONE, MOCK_SMS_CODE } from '../data/constants';
|
||||||
|
|
||||||
|
export default function Step2Phone({ state, updateState, completeStep, renderPart = 'all' }) {
|
||||||
|
const [errors, setErrors] = useState({});
|
||||||
|
|
||||||
|
const handleVerify = () => {
|
||||||
|
const result = verifyPhoneStep(state.step2);
|
||||||
|
if (!result.ok) {
|
||||||
|
setErrors({
|
||||||
|
phoneError: result.phoneError,
|
||||||
|
codeError: result.codeError,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErrors({});
|
||||||
|
updateState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step2: {
|
||||||
|
...prev.step2,
|
||||||
|
phone: result.phone,
|
||||||
|
code: result.code,
|
||||||
|
verified: true,
|
||||||
|
verifiedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
completeStep(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDemoSkip = () => {
|
||||||
|
updateState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step2: {
|
||||||
|
phone: DEMO_PHONE,
|
||||||
|
code: MOCK_SMS_CODE,
|
||||||
|
verified: true,
|
||||||
|
verifiedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
completeStep(2);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state.step2.verified) {
|
||||||
|
const content = (
|
||||||
|
<div className="dt-card">
|
||||||
|
<div className="dt-success-banner">
|
||||||
|
<span>✓</span>
|
||||||
|
<span>手机号 {state.step2.phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')} 已验证</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
const footer = (
|
||||||
|
<button type="button" className="dt-btn-primary" onClick={() => completeStep(2)}>
|
||||||
|
进入下一步
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
if (renderPart === 'content') return content;
|
||||||
|
if (renderPart === 'footer') return footer;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{content}
|
||||||
|
{footer}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="dt-card">
|
||||||
|
<h2 className="dt-card-title">手机号验证</h2>
|
||||||
|
<PhoneVerificationForm
|
||||||
|
value={state.step2}
|
||||||
|
showActions={false}
|
||||||
|
onChange={(data) => updateState((prev) => ({ ...prev, step2: { ...prev.step2, ...data } }))}
|
||||||
|
/>
|
||||||
|
{errors.phoneError && <div className="dt-field-error">{errors.phoneError}</div>}
|
||||||
|
{errors.codeError && <div className="dt-field-error">{errors.codeError}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const footer = (
|
||||||
|
<>
|
||||||
|
<button type="button" className="dt-btn-primary" onClick={handleVerify}>
|
||||||
|
验证并继续
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dt-btn-secondary"
|
||||||
|
style={{ marginTop: 8 }}
|
||||||
|
onClick={handleDemoSkip}
|
||||||
|
>
|
||||||
|
演示:跳过验证
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (renderPart === 'content') return content;
|
||||||
|
if (renderPart === 'footer') return footer;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{content}
|
||||||
|
{footer}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import React, { useRef, useState } from 'react';
|
||||||
|
import CertificateUploadCard from '../components/CertificateUploadCard';
|
||||||
|
import { ManualCertificateForm, getCertFieldLabel } from '../components/OCRResultForm';
|
||||||
|
import UploadSourceSheet from '../components/UploadSourceSheet';
|
||||||
|
import { CERT_TYPES, OCR_MOCK } from '../data/constants';
|
||||||
|
import { isQualificationRequired, getRequiredCertTypes } from '../context/TrainingContext';
|
||||||
|
|
||||||
|
const OCR_CERT_TYPES = [CERT_TYPES.ID_FRONT, CERT_TYPES.LICENSE_FRONT, CERT_TYPES.QUALIFICATION];
|
||||||
|
const BACK_ONLY = [CERT_TYPES.ID_BACK, CERT_TYPES.LICENSE_BACK];
|
||||||
|
|
||||||
|
function parseValidEnd(period) {
|
||||||
|
if (!period) return null;
|
||||||
|
const parts = period.split(/[—\-~]/);
|
||||||
|
const end = parts[parts.length - 1]?.trim().replace(/\./g, '-');
|
||||||
|
return end ? new Date(end) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Step3Certificates({ state, updateCertificate, completeStep, renderPart = 'all' }) {
|
||||||
|
const tonnage = state.trainingTask.vehicleTonnage;
|
||||||
|
const requiredTypes = getRequiredCertTypes(tonnage);
|
||||||
|
const showOptionalQual = !isQualificationRequired(tonnage);
|
||||||
|
const certs = state.step3.certificates;
|
||||||
|
const requiredDoneCount = requiredTypes.filter((t) => certs[t]?.status === 'done').length;
|
||||||
|
const allDone = requiredDoneCount === requiredTypes.length;
|
||||||
|
const cameraRef = useRef(null);
|
||||||
|
const albumRef = useRef(null);
|
||||||
|
const [uploadSheetCert, setUploadSheetCert] = useState(null);
|
||||||
|
const [activeCert, setActiveCert] = useState(null);
|
||||||
|
const [modal, setModal] = useState(null);
|
||||||
|
const [formData, setFormData] = useState({});
|
||||||
|
const [formErrors, setFormErrors] = useState({});
|
||||||
|
const [validationError, setValidationError] = useState('');
|
||||||
|
const ocrMode = state.debug.ocrMode;
|
||||||
|
|
||||||
|
const allCertTypes = showOptionalQual
|
||||||
|
? [...requiredTypes, CERT_TYPES.QUALIFICATION]
|
||||||
|
: requiredTypes;
|
||||||
|
|
||||||
|
const showUploadOptions = (certType) => {
|
||||||
|
setUploadSheetCert(certType);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickCamera = () => {
|
||||||
|
if (!uploadSheetCert) return;
|
||||||
|
setActiveCert(uploadSheetCert);
|
||||||
|
setUploadSheetCert(null);
|
||||||
|
setTimeout(() => cameraRef.current?.click(), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickAlbum = () => {
|
||||||
|
if (!uploadSheetCert) return;
|
||||||
|
setActiveCert(uploadSheetCert);
|
||||||
|
setUploadSheetCert(null);
|
||||||
|
setTimeout(() => albumRef.current?.click(), 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const runOCR = (certType, imageUrl) => {
|
||||||
|
updateCertificate(certType, { status: 'recognizing', imageUrl });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (ocrMode === 'fail') {
|
||||||
|
updateCertificate(certType, { status: 'failed', imageUrl, mode: 'ocr' });
|
||||||
|
setModal({ type: 'ocr_fail', certType, imageUrl });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let mock = { ...OCR_MOCK[certType] };
|
||||||
|
if (ocrMode === 'mismatch' && certType === CERT_TYPES.LICENSE_FRONT) {
|
||||||
|
mock = { ...mock, name: '李晓明', licenseNumber: '3304**********9999' };
|
||||||
|
}
|
||||||
|
updateCertificate(certType, { status: 'confirm', imageUrl, mode: 'ocr', fields: mock, ocrData: mock });
|
||||||
|
setModal({ type: 'confirm', certType, imageUrl, fields: mock });
|
||||||
|
setFormData(mock);
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFile = (e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (!file || !activeCert) return;
|
||||||
|
const reader = new FileReader();
|
||||||
|
updateCertificate(activeCert, { status: 'uploading' });
|
||||||
|
reader.onload = () => {
|
||||||
|
if (BACK_ONLY.includes(activeCert)) {
|
||||||
|
setTimeout(() => {
|
||||||
|
updateCertificate(activeCert, {
|
||||||
|
status: 'done',
|
||||||
|
imageUrl: reader.result,
|
||||||
|
fields: OCR_MOCK[activeCert] || {},
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}, 800);
|
||||||
|
} else {
|
||||||
|
runOCR(activeCert, reader.result);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
e.target.value = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCert = (certType) => {
|
||||||
|
const cert = certs[certType];
|
||||||
|
if (cert?.status === 'done') {
|
||||||
|
setModal({ type: 'view', certType, ...cert });
|
||||||
|
setFormData(cert.fields || {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cert?.status === 'confirm') {
|
||||||
|
setModal({ type: 'confirm', certType, imageUrl: cert.imageUrl, fields: cert.fields });
|
||||||
|
setFormData(cert.fields || {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cert?.status === 'failed') {
|
||||||
|
setModal({ type: 'ocr_fail', certType, imageUrl: cert.imageUrl });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showUploadOptions(certType);
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmCert = () => {
|
||||||
|
const { certType } = modal;
|
||||||
|
const errors = {};
|
||||||
|
const fields = formData;
|
||||||
|
if (OCR_CERT_TYPES.includes(certType)) {
|
||||||
|
if (!fields.name) errors.name = '请填写姓名';
|
||||||
|
if (certType === CERT_TYPES.ID_FRONT && !fields.idNumber) errors.idNumber = '请填写身份证号';
|
||||||
|
if (certType === CERT_TYPES.LICENSE_FRONT) {
|
||||||
|
if (!fields.licenseNumber) errors.licenseNumber = '请填写证号';
|
||||||
|
if (!fields.vehicleClass) errors.vehicleClass = '请选择准驾车型';
|
||||||
|
if (!fields.validPeriod) errors.validPeriod = '请填写有效期限';
|
||||||
|
}
|
||||||
|
if (certType === CERT_TYPES.QUALIFICATION) {
|
||||||
|
if (!fields.certNumber) errors.certNumber = '请填写证号';
|
||||||
|
if (!fields.vehicleClass) errors.vehicleClass = '请填写准驾车型';
|
||||||
|
if (!fields.category) errors.category = '请填写从业资格类别';
|
||||||
|
if (!fields.initialIssueDate) errors.initialIssueDate = '请填写初次领证日期';
|
||||||
|
if (!fields.validFrom) errors.validFrom = '请填写有效起始日期';
|
||||||
|
if (!fields.validPeriod) errors.validPeriod = '请填写有效期限';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setFormErrors(errors);
|
||||||
|
if (Object.keys(errors).length) return;
|
||||||
|
|
||||||
|
updateCertificate(certType, {
|
||||||
|
status: 'done',
|
||||||
|
fields,
|
||||||
|
imageUrl: modal.imageUrl || certs[certType]?.imageUrl,
|
||||||
|
mode: modal.mode || certs[certType]?.mode || 'ocr',
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
setModal(null);
|
||||||
|
setFormErrors({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const startManual = () => {
|
||||||
|
const { certType, imageUrl } = modal;
|
||||||
|
updateCertificate(certType, { status: 'confirm', mode: 'manual', imageUrl, fields: {} });
|
||||||
|
setModal({ type: 'manual', certType, imageUrl, mode: 'manual' });
|
||||||
|
setFormData({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateAll = () => {
|
||||||
|
const idFront = certs[CERT_TYPES.ID_FRONT]?.fields;
|
||||||
|
const license = certs[CERT_TYPES.LICENSE_FRONT]?.fields;
|
||||||
|
|
||||||
|
for (const t of requiredTypes) {
|
||||||
|
if (!certs[t] || certs[t].status !== 'done') {
|
||||||
|
setValidationError('请完成所有必填证照上传');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!idFront || !license) {
|
||||||
|
setValidationError('请完成身份证和驾驶证信息确认');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (idFront.name !== license.name) {
|
||||||
|
setValidationError('身份证姓名与驾驶证姓名不一致');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (idFront.idNumber !== license.licenseNumber) {
|
||||||
|
setValidationError('身份证号码与驾驶证证号不一致');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const endDate = parseValidEnd(license.validPeriod);
|
||||||
|
if (endDate && endDate < new Date()) {
|
||||||
|
setValidationError('驾驶证已过期,请更新证件');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (license.vehicleClass !== state.trainingTask.requiredVehicleClass) {
|
||||||
|
setValidationError(`准驾车型不满足要求,当前车辆需要 ${state.trainingTask.requiredVehicleClass} 驾照`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setValidationError('');
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const modals = (
|
||||||
|
<>
|
||||||
|
<UploadSourceSheet
|
||||||
|
visible={!!uploadSheetCert}
|
||||||
|
certType={uploadSheetCert}
|
||||||
|
onCamera={pickCamera}
|
||||||
|
onAlbum={pickAlbum}
|
||||||
|
onCancel={() => setUploadSheetCert(null)}
|
||||||
|
/>
|
||||||
|
{modal?.type === 'ocr_fail' && (
|
||||||
|
<div className="dt-modal-overlay" onClick={() => setModal(null)}>
|
||||||
|
<div className="dt-modal" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="dt-error-card" style={{ margin: 0 }}>
|
||||||
|
<p>证件信息识别失败,可以重新拍摄或手动填写。</p>
|
||||||
|
<div className="dt-error-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dt-btn-outline"
|
||||||
|
onClick={() => {
|
||||||
|
setModal(null);
|
||||||
|
showUploadOptions(modal.certType);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
重新上传
|
||||||
|
</button>
|
||||||
|
<button type="button" className="dt-btn-primary" style={{ flex: 1 }} onClick={startManual}>
|
||||||
|
手动填写
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(modal?.type === 'confirm' || modal?.type === 'manual' || modal?.type === 'view') && (
|
||||||
|
<div className="dt-modal-overlay" onClick={() => setModal(null)}>
|
||||||
|
<div className="dt-modal" onClick={(e) => e.stopPropagation()} style={{ maxHeight: '80vh', overflow: 'auto' }}>
|
||||||
|
<h3 className="dt-modal-title">
|
||||||
|
{modal.type === 'view' ? '查看证照' : modal.type === 'manual' ? '手动填写' : '确认识别结果'}
|
||||||
|
</h3>
|
||||||
|
{modal.imageUrl && <img src={modal.imageUrl} alt="" style={{ width: '100%', borderRadius: 8, marginBottom: 12 }} />}
|
||||||
|
{(modal.type === 'confirm' || modal.type === 'manual') && OCR_CERT_TYPES.includes(modal.certType) && (
|
||||||
|
<ManualCertificateForm
|
||||||
|
certType={modal.certType}
|
||||||
|
data={formData}
|
||||||
|
onChange={(key, val) => setFormData((d) => ({ ...d, [key]: val }))}
|
||||||
|
errors={formErrors}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{modal.type === 'view' && modal.fields && Object.keys(modal.fields).length > 0 && (
|
||||||
|
<div className="dt-info-list">
|
||||||
|
{Object.entries(modal.fields)
|
||||||
|
.filter(([k]) => k !== 'fileNumber')
|
||||||
|
.map(([k, v]) => (
|
||||||
|
<div className="dt-info-item" key={k}>
|
||||||
|
<span className="label">{getCertFieldLabel(k)}</span>
|
||||||
|
<span>{v}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="dt-modal-actions" style={{ marginTop: 16 }}>
|
||||||
|
<button type="button" className="dt-btn-secondary" onClick={() => setModal(null)}>关闭</button>
|
||||||
|
{modal.type !== 'view' && (
|
||||||
|
<button type="button" className="dt-btn-primary" onClick={confirmCert}>确认</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
ref={cameraRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
capture="environment"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleFile}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
ref={albumRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
style={{ display: 'none' }}
|
||||||
|
onChange={handleFile}
|
||||||
|
/>
|
||||||
|
<div className="dt-card">
|
||||||
|
<h2 className="dt-card-title">司机证照</h2>
|
||||||
|
<p className="dt-field-hint" style={{ marginBottom: 12 }}>
|
||||||
|
{isQualificationRequired(tonnage)
|
||||||
|
? `当前车辆为 ${tonnage} 吨,从业资格证为必填项。`
|
||||||
|
: `当前车辆为 ${tonnage} 吨,从业资格证为选填项。`}
|
||||||
|
点击证件卡片可选择拍照或从相册上传。
|
||||||
|
</p>
|
||||||
|
{allCertTypes.map((t) => (
|
||||||
|
<CertificateUploadCard
|
||||||
|
key={t}
|
||||||
|
certType={t}
|
||||||
|
required={requiredTypes.includes(t)}
|
||||||
|
status={certs[t]?.status || 'pending'}
|
||||||
|
imageUrl={certs[t]?.imageUrl}
|
||||||
|
onClick={() => openCert(t)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{allDone && (
|
||||||
|
<div className="dt-success-banner">
|
||||||
|
<span>✓</span>
|
||||||
|
<span>证照信息已完成</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{validationError && <div className="dt-field-error" style={{ marginTop: 8 }}>{validationError}</div>}
|
||||||
|
</div>
|
||||||
|
{modals}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const footer = (
|
||||||
|
<>
|
||||||
|
{!allDone && (
|
||||||
|
<div className="dt-footer-hint">
|
||||||
|
请完成全部必填证照上传({requiredDoneCount}/{requiredTypes.length})
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button type="button" className="dt-btn-primary" disabled={!allDone} onClick={() => validateAll() && completeStep(3)}>
|
||||||
|
下一步
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (renderPart === 'content') return content;
|
||||||
|
if (renderPart === 'footer') return footer;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{content}
|
||||||
|
{footer}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import TrainingVideoPlayer from '../components/TrainingVideoPlayer';
|
||||||
|
|
||||||
|
export default function Step4Video({ state, updateState, completeStep, renderPart = 'all' }) {
|
||||||
|
const { step4, debug } = state;
|
||||||
|
const completed = step4.completed;
|
||||||
|
|
||||||
|
const handleComplete = () => {
|
||||||
|
updateState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step4: {
|
||||||
|
completed: true,
|
||||||
|
watchedSeconds: 30,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="dt-card">
|
||||||
|
<h2 className="dt-card-title">安全培训视频</h2>
|
||||||
|
<p className="dt-field-hint" style={{ marginBottom: 12 }}>
|
||||||
|
氢能重卡安全驾驶培训 · 预计时长 30 秒
|
||||||
|
</p>
|
||||||
|
<TrainingVideoPlayer
|
||||||
|
completed={completed}
|
||||||
|
watchedSeconds={step4.watchedSeconds}
|
||||||
|
onProgress={(s) =>
|
||||||
|
updateState((prev) => ({ ...prev, step4: { ...prev.step4, watchedSeconds: s } }))
|
||||||
|
}
|
||||||
|
onComplete={handleComplete}
|
||||||
|
debugComplete={debug.videoStatus === 'completed' && !completed}
|
||||||
|
/>
|
||||||
|
{completed && (
|
||||||
|
<div className="dt-success-banner" style={{ marginTop: 12 }}>
|
||||||
|
<span>✓</span>
|
||||||
|
<span>视频观看完成</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const footer = (
|
||||||
|
<>
|
||||||
|
{!completed && <div className="dt-footer-hint">请完整观看安全培训视频</div>}
|
||||||
|
<button type="button" className="dt-btn-primary" disabled={!completed} onClick={() => completeStep(4)}>
|
||||||
|
下一步
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (renderPart === 'content') return content;
|
||||||
|
if (renderPart === 'footer') return footer;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{content}
|
||||||
|
{footer}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import CameraCapture from '../components/CameraCapture';
|
||||||
|
|
||||||
|
export default function Step5Portrait({ state, updateState, completeStep, renderPart = 'all' }) {
|
||||||
|
const { step5, debug } = state;
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="dt-card">
|
||||||
|
<h2 className="dt-card-title">本人照片</h2>
|
||||||
|
<CameraCapture
|
||||||
|
photoDataUrl={step5.photoDataUrl}
|
||||||
|
confirmed={step5.confirmed}
|
||||||
|
cameraPermission={debug.cameraPermission}
|
||||||
|
showDevFallback
|
||||||
|
onCapture={(url) =>
|
||||||
|
updateState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step5: { ...prev.step5, photoDataUrl: url, confirmed: false },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onConfirm={() =>
|
||||||
|
updateState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step5: {
|
||||||
|
...prev.step5,
|
||||||
|
confirmed: true,
|
||||||
|
capturedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
onRetake={() =>
|
||||||
|
updateState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
step5: { photoDataUrl: null, confirmed: false, capturedAt: null },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const footer = (
|
||||||
|
<>
|
||||||
|
{!step5.confirmed && (
|
||||||
|
<div className="dt-footer-hint">请拍照并确认使用本人照片</div>
|
||||||
|
)}
|
||||||
|
<button type="button" className="dt-btn-primary" disabled={!step5.confirmed} onClick={() => completeStep(5)}>
|
||||||
|
下一步
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (renderPart === 'content') return content;
|
||||||
|
if (renderPart === 'footer') return footer;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{content}
|
||||||
|
{footer}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
import { formatNow } from '../utils/formatDateTime';
|
||||||
|
|
||||||
|
const DECLARATION =
|
||||||
|
'本人确认所提交的身份及驾驶证件信息真实有效,已完整阅读安全培训文档并观看安全培训视频,已知悉氢能车辆驾驶及应急处置要求,并确认由本人执行本次提车。';
|
||||||
|
|
||||||
|
export default function Step6Signature({ state, updateState, completeStep, renderPart = 'all' }) {
|
||||||
|
const { step6, step3, trainingTask } = state;
|
||||||
|
const checked = step6.checked;
|
||||||
|
|
||||||
|
const driverName = step3.certificates?.idFront?.fields?.name || '—';
|
||||||
|
const idLast4 = (step3.certificates?.idFront?.fields?.idNumber || '****').slice(-4);
|
||||||
|
const vehicleClass = step3.certificates?.licenseFront?.fields?.vehicleClass || '—';
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<div className="dt-card dt-step6-card">
|
||||||
|
<h2 className="dt-card-title">签字确认</h2>
|
||||||
|
|
||||||
|
<section className="dt-step6-section">
|
||||||
|
<h3 className="dt-step6-section-title">① 阅读声明</h3>
|
||||||
|
<div className="dt-step6-declaration">{DECLARATION}</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="dt-step6-section">
|
||||||
|
<h3 className="dt-step6-section-title">② 核对信息</h3>
|
||||||
|
<div className="dt-info-list dt-step6-info">
|
||||||
|
<div className="dt-info-item dt-info-item--highlight">
|
||||||
|
<span className="label">司机姓名</span>
|
||||||
|
<span>{driverName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dt-info-item dt-info-item--highlight">
|
||||||
|
<span className="label">当前车辆</span>
|
||||||
|
<span>{trainingTask.vehiclePlate}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dt-info-item">
|
||||||
|
<span className="label">身份证后四位</span>
|
||||||
|
<span>{idLast4}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dt-info-item">
|
||||||
|
<span className="label">驾驶证准驾车型</span>
|
||||||
|
<span>{vehicleClass}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dt-info-item">
|
||||||
|
<span className="label">培训完成时间</span>
|
||||||
|
<span>{formatNow()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<label className="dt-checkbox-row">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => {
|
||||||
|
updateState((prev) => ({ ...prev, step6: { ...prev.step6, checked: e.target.checked } }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>本人确认以上信息及签名真实有效。</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const footer = (
|
||||||
|
<>
|
||||||
|
{!checked ? <div className="dt-footer-hint">请勾选「本人确认以上信息及签名真实有效」</div> : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`dt-btn-primary ${checked ? 'dt-btn-primary--ready' : ''}`}
|
||||||
|
disabled={!checked}
|
||||||
|
onClick={() => completeStep(6)}
|
||||||
|
>
|
||||||
|
下一步:手写签名
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (renderPart === 'content') return content;
|
||||||
|
if (renderPart === 'footer') return footer;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{content}
|
||||||
|
{footer}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PickupCodeCard from '../components/PickupCodeCard';
|
||||||
|
|
||||||
|
export default function Step7PickupCode({ state, renderPart = 'all' }) {
|
||||||
|
const { step7 } = state;
|
||||||
|
|
||||||
|
const content = <PickupCodeCard step7={step7} />;
|
||||||
|
|
||||||
|
if (renderPart === 'footer') return null;
|
||||||
|
return content;
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
import { formatNow } from '../utils/formatDateTime';
|
||||||
|
|
||||||
|
import SignatureCanvas from '../components/SignatureCanvas';
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog';
|
||||||
|
|
||||||
|
function useContainerLandscape(rootRef) {
|
||||||
|
const [isLandscape, setIsLandscape] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = rootRef.current?.closest('.driver-training-app') || rootRef.current;
|
||||||
|
if (!root) return undefined;
|
||||||
|
|
||||||
|
const check = () => {
|
||||||
|
const { width, height } = root.getBoundingClientRect();
|
||||||
|
setIsLandscape(width > height * 1.05);
|
||||||
|
};
|
||||||
|
|
||||||
|
check();
|
||||||
|
const observer = new ResizeObserver(check);
|
||||||
|
observer.observe(root);
|
||||||
|
window.addEventListener('orientationchange', check);
|
||||||
|
return () => {
|
||||||
|
observer.disconnect();
|
||||||
|
window.removeEventListener('orientationchange', check);
|
||||||
|
};
|
||||||
|
}, [rootRef]);
|
||||||
|
|
||||||
|
return isLandscape;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Step7Signature({
|
||||||
|
state,
|
||||||
|
updateState,
|
||||||
|
submitTraining,
|
||||||
|
goToStep,
|
||||||
|
renderPart = 'all',
|
||||||
|
}) {
|
||||||
|
const { step6, step3, trainingTask } = state;
|
||||||
|
const [showSubmitDialog, setShowSubmitDialog] = useState(false);
|
||||||
|
const rootRef = useRef(null);
|
||||||
|
const canvasRef = useRef(null);
|
||||||
|
const isLandscape = useContainerLandscape(rootRef);
|
||||||
|
const hasSignature = !!step6.signatureDataUrl;
|
||||||
|
|
||||||
|
const driverName = step3.certificates?.idFront?.fields?.name || '—';
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
submitTraining();
|
||||||
|
setShowSubmitDialog(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (step6.submitted) {
|
||||||
|
if (renderPart === 'footer') return null;
|
||||||
|
return (
|
||||||
|
<div className="dt-card">
|
||||||
|
<div className="dt-success-banner">
|
||||||
|
<span>✓</span>
|
||||||
|
<span>培训记录已提交</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renderPart === 'footer') return null;
|
||||||
|
|
||||||
|
const dialogSummary = (
|
||||||
|
<>
|
||||||
|
<div className="dt-modal-summary-row">
|
||||||
|
<span className="label">司机</span>
|
||||||
|
<span>{driverName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dt-modal-summary-row">
|
||||||
|
<span className="label">车辆</span>
|
||||||
|
<span>{trainingTask.vehiclePlate}</span>
|
||||||
|
</div>
|
||||||
|
<div className="dt-modal-summary-row">
|
||||||
|
<span className="label">培训完成时间</span>
|
||||||
|
<span>{formatNow()}</span>
|
||||||
|
</div>
|
||||||
|
{hasSignature && (
|
||||||
|
<div className="dt-modal-signature-preview">
|
||||||
|
<span className="label">签名预览</span>
|
||||||
|
<img src={step6.signatureDataUrl} alt="签名预览" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
const submitDialog = (
|
||||||
|
<ConfirmDialog
|
||||||
|
visible={showSubmitDialog}
|
||||||
|
title="确认提交培训并生成提车码"
|
||||||
|
note="提交后不可修改,提车码将出示给运维扫码。"
|
||||||
|
summary={dialogSummary}
|
||||||
|
cancelText="返回检查"
|
||||||
|
confirmText="确认提交"
|
||||||
|
onCancel={() => setShowSubmitDialog(false)}
|
||||||
|
onConfirm={handleSubmit}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className={`dt-esign-root ${isLandscape ? 'dt-esign-root--landscape' : 'dt-esign-root--portrait-preview'}`}
|
||||||
|
>
|
||||||
|
{!isLandscape && (
|
||||||
|
<div className="dt-esign-preview-banner">
|
||||||
|
<div className="dt-esign-preview-banner-icon" aria-hidden="true">
|
||||||
|
↻
|
||||||
|
</div>
|
||||||
|
<div className="dt-esign-preview-banner-text">
|
||||||
|
<strong>横屏签字预览</strong>
|
||||||
|
<span>正式使用时请旋转手机至横屏;下方为横屏全屏签名页效果,可直接体验签字并完成提交。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="dt-esign-landscape">
|
||||||
|
<header className="dt-esign-topbar">
|
||||||
|
<button type="button" className="dt-esign-topbar-back" onClick={() => goToStep(6)} aria-label="返回">
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<h1 className="dt-esign-topbar-title">手写签名</h1>
|
||||||
|
<span className="dt-esign-topbar-slot" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="dt-esign-signer">
|
||||||
|
<span>{driverName}</span>
|
||||||
|
<span className="dt-esign-signer-dot">·</span>
|
||||||
|
<span>{trainingTask.vehiclePlate}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="dt-esign-canvas-area">
|
||||||
|
<SignatureCanvas
|
||||||
|
ref={canvasRef}
|
||||||
|
variant="esign"
|
||||||
|
hideActions
|
||||||
|
watermark="请在此处签名"
|
||||||
|
value={step6.signatureDataUrl}
|
||||||
|
onChange={(url) =>
|
||||||
|
updateState((prev) => ({ ...prev, step6: { ...prev.step6, signatureDataUrl: url } }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer className="dt-esign-toolbar">
|
||||||
|
<button type="button" className="dt-esign-tool-btn" onClick={() => canvasRef.current?.clear()}>
|
||||||
|
清除重写
|
||||||
|
</button>
|
||||||
|
<button type="button" className="dt-esign-tool-btn" onClick={() => goToStep(6)}>
|
||||||
|
取消
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`dt-esign-tool-done ${hasSignature ? 'dt-esign-tool-done--ready' : ''}`}
|
||||||
|
disabled={!hasSignature}
|
||||||
|
onClick={() => setShowSubmitDialog(true)}
|
||||||
|
>
|
||||||
|
完成
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
{submitDialog}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
11
src/prototypes/xll-miniapp/driver-training/utils/formatDateTime.js
vendored
Normal file
11
src/prototypes/xll-miniapp/driver-training/utils/formatDateTime.js
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export function formatDateTime(iso) {
|
||||||
|
if (!iso) return '-';
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (Number.isNaN(d.getTime())) return '-';
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNow() {
|
||||||
|
return formatDateTime(new Date().toISOString());
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -14,9 +14,18 @@
|
|||||||
|
|
||||||
## 最新改版
|
## 最新改版
|
||||||
|
|
||||||
审批中心与还车应结款多部门子流程见 **PRD 01|审批中心改版说明**(V1.4)。
|
### 交车模块(PRD 02 · V1.3.2,2026-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 00 / PRD 01 / PRD 02 及页面说明(`page-audit`、`page-delivery` 等)
|
||||||
- **PRD 02|交车说明**:交车列表、分步办理与签章流程
|
- **PRD 02|交车说明**:交车列表、分步办理与签章流程(含 V1.3.2 连拍相册单选与连拍自动保存)
|
||||||
- **导航栏**:不设「需求说明」入口;以标注工具与目录为主
|
- **导航栏**:不设「需求说明」入口;以标注工具与目录为主
|
||||||
- **业务评审**:`.spec/prototype-review.md`
|
- **业务评审**:`.spec/prototype-review.md`
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# 小羚羚小程序 · 交车说明
|
# 小羚羚小程序 · 交车说明
|
||||||
|
|
||||||
**文档版本**:V1.2
|
**文档版本**:V1.3.2
|
||||||
**更新日期**:2026-06-29
|
**更新日期**:2026-07-01
|
||||||
**适用范围**:运维管理 / 交车模块(`#page=delivery`)
|
**适用范围**:运维管理 / 交车模块(`#page=delivery`)
|
||||||
**参照**:web 交车管理 + Axhub「交车(完成)」原型 · 默认区域权限:浙江省-嘉兴市
|
**参照**: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 录入
|
- [ ] 交车检查项四类可填,轮胎胎纹支持 mm 录入
|
||||||
- [ ] 拍摄照片五类连拍、相册上传、调焦与备胎末项规则
|
- [ ] 拍摄照片五类连拍、相册上传、调焦与备胎末项规则
|
||||||
|
- [ ] **连拍相册单选**:连拍环节相册为单选模式,每次只能选 1 张(无法多选)
|
||||||
|
- [ ] **瑕疵/其他多选**:补充照片仍可相册批量添加
|
||||||
|
- [ ] **连拍自动保存**:每次点「完成」进入下一项后草稿状态为「已保存」
|
||||||
|
- [ ] **拍照操作条**:拍照/相册(及完成态三按钮)等宽平铺展示
|
||||||
- [ ] 发送签章 → 待客户签章 → 清除签章 / 重新签章 / 客户已签章状态流转
|
- [ ] 发送签章 → 待客户签章 → 清除签章 / 重新签章 / 客户已签章状态流转
|
||||||
- [ ] 标注目录可打开本 PRD;页面标注 `page-delivery` 可定位交车模块
|
- [ ] 标注目录可打开本 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|审批中心改版说明」 |
|
| 审批中心改版 | 标注目录「PRD 01|审批中心改版说明」 |
|
||||||
| 页面标注 | `page-delivery` |
|
| 页面标注 | `page-delivery` |
|
||||||
| 原型评审 | `src/prototypes/xll-miniapp/.spec/prototype-review.md` |
|
| 原型评审 | `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 | 交车分步表单、连拍、签章与相册上传基线 |
|
||||||
|
|||||||
Reference in New Issue
Block a user