4353 lines
130 KiB
JavaScript
4353 lines
130 KiB
JavaScript
// 【重要】必须使用 const Component 作为组件变量名
|
||
// ONE-OS 小程序 - 年审管理(待处理 / 历史记录 + 筛选抽屉)
|
||
|
||
const { useState, useMemo, useEffect, useRef, useCallback } = React;
|
||
const moment = window.moment || window.dayjs;
|
||
|
||
const COLOR_PRIMARY = '#16D1A1';
|
||
const COLOR_PRIMARY_DEEP = '#00BFA5';
|
||
const COLOR_TEXT = '#1D2129';
|
||
const COLOR_MUTED = '#86909C';
|
||
const COLOR_LINE = '#E5E6EB';
|
||
const COLOR_BG = '#FFFFFF';
|
||
const COLOR_PAGE = '#F2F3F5';
|
||
const COLOR_WARN = '#FF7D00';
|
||
const COLOR_DANGER = '#F53F3F';
|
||
const FONT_FAMILY =
|
||
'-apple-system, BlinkMacSystemFont, "PingFang SC", "Helvetica Neue", STHeiti, sans-serif';
|
||
|
||
/** 兼容 Axhub:antd 可能是 default 导出或子集包,避免解构出 undefined 导致 React #130 */
|
||
const resolveAntdBundle = () => {
|
||
const raw = window.antd;
|
||
if (!raw) return {};
|
||
if (raw.default && typeof raw.default === 'object') {
|
||
return { ...raw, ...raw.default };
|
||
}
|
||
return raw;
|
||
};
|
||
|
||
const antd = resolveAntdBundle();
|
||
|
||
const FallbackTag = ({ children, className, style, color }) => (
|
||
<span
|
||
className={className}
|
||
style={{
|
||
display: 'inline-block',
|
||
fontSize: 11,
|
||
lineHeight: '18px',
|
||
padding: '0 6px',
|
||
borderRadius: 4,
|
||
fontWeight: 600,
|
||
color:
|
||
color === 'error' ? COLOR_DANGER : color === 'warning' ? COLOR_WARN : COLOR_PRIMARY_DEEP,
|
||
background:
|
||
color === 'error'
|
||
? 'rgba(245, 63, 63, 0.1)'
|
||
: color === 'warning'
|
||
? 'rgba(255, 125, 0, 0.1)'
|
||
: 'rgba(22, 209, 161, 0.1)',
|
||
...style
|
||
}}
|
||
>
|
||
{children}
|
||
</span>
|
||
);
|
||
|
||
const FallbackInputNumber = ({ value, onChange, placeholder, min, precision, bordered, controls }) => (
|
||
<input
|
||
type="number"
|
||
className="ant-input"
|
||
style={{ width: '100%', textAlign: 'right', border: bordered === false ? 'none' : undefined }}
|
||
placeholder={placeholder}
|
||
min={min}
|
||
step={precision === 2 ? 0.01 : 1}
|
||
value={value == null ? '' : value}
|
||
onChange={(e) => {
|
||
const raw = e.target.value;
|
||
if (onChange) onChange(raw === '' ? null : Number(raw));
|
||
}}
|
||
/>
|
||
);
|
||
|
||
const FallbackUpload = ({ children, className, onChange, fileList, multiple, accept, beforeUpload, showUploadList }) => (
|
||
<label className={className} style={{ display: 'block', cursor: 'pointer' }}>
|
||
<input
|
||
type="file"
|
||
accept={accept || 'image/*'}
|
||
multiple={multiple}
|
||
style={{ display: 'none' }}
|
||
onChange={(e) => {
|
||
const files = Array.from(e.target.files || []);
|
||
if (beforeUpload && files.some((f) => beforeUpload(f) === false)) return;
|
||
const next = files.map((f, i) => ({
|
||
uid: `local-${Date.now()}-${i}`,
|
||
name: f.name,
|
||
status: 'done',
|
||
originFileObj: f
|
||
}));
|
||
if (onChange) onChange({ fileList: [...(fileList || []), ...next] });
|
||
e.target.value = '';
|
||
}}
|
||
/>
|
||
{children}
|
||
</label>
|
||
);
|
||
|
||
const FallbackDrawer = ({ open, title, placement, width, height, onClose, children, footer, styles }) =>
|
||
open ? (
|
||
<div
|
||
className="ar-fallback-drawer-mask"
|
||
style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,.45)' }}
|
||
onClick={onClose}
|
||
role="presentation"
|
||
>
|
||
<div
|
||
className="ar-fallback-drawer"
|
||
style={{
|
||
position: 'fixed',
|
||
background: '#fff',
|
||
zIndex: 1001,
|
||
...(placement === 'bottom'
|
||
? { left: 0, right: 0, bottom: 0, height: height || 360, borderRadius: '14px 14px 0 0' }
|
||
: { top: 0, right: 0, bottom: 0, width: width || 320 }),
|
||
display: 'flex',
|
||
flexDirection: 'column'
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
role="dialog"
|
||
>
|
||
<div style={{ padding: '14px 16px', fontWeight: 700, borderBottom: '1px solid #e5e6eb' }}>{title}</div>
|
||
<div style={{ flex: 1, overflow: 'auto', ...(styles?.body || {}) }}>{children}</div>
|
||
{footer ? <div>{footer}</div> : null}
|
||
</div>
|
||
</div>
|
||
) : null;
|
||
|
||
const FallbackModal = ({ open, title, children, footer, onCancel, centered, width, bodyStyle }) =>
|
||
open ? (
|
||
<div
|
||
style={{
|
||
position: 'fixed',
|
||
inset: 0,
|
||
zIndex: 1100,
|
||
background: 'rgba(0,0,0,.45)',
|
||
display: 'flex',
|
||
alignItems: centered ? 'center' : 'flex-start',
|
||
justifyContent: 'center',
|
||
padding: 24
|
||
}}
|
||
onClick={onCancel}
|
||
role="presentation"
|
||
>
|
||
<div
|
||
style={{
|
||
background: '#fff',
|
||
borderRadius: 12,
|
||
width: width || 400,
|
||
maxWidth: '100%',
|
||
maxHeight: '85vh',
|
||
overflow: 'hidden',
|
||
display: 'flex',
|
||
flexDirection: 'column'
|
||
}}
|
||
onClick={(e) => e.stopPropagation()}
|
||
role="dialog"
|
||
>
|
||
<div style={{ padding: '14px 16px', fontWeight: 700, borderBottom: '1px solid #e5e6eb' }}>{title}</div>
|
||
<div style={{ overflow: 'auto', padding: 16, ...(bodyStyle || {}) }}>{children}</div>
|
||
{footer ? (
|
||
<div style={{ padding: 12, borderTop: '1px solid #e5e6eb', textAlign: 'right' }}>{footer}</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : null;
|
||
|
||
const FallbackInput = ({
|
||
value,
|
||
onChange,
|
||
readOnly,
|
||
placeholder,
|
||
bordered,
|
||
disabled,
|
||
className,
|
||
onFocus,
|
||
style
|
||
}) => (
|
||
<input
|
||
className={`ant-input${className ? ` ${className}` : ''}`}
|
||
readOnly={readOnly}
|
||
disabled={disabled}
|
||
placeholder={placeholder}
|
||
value={value ?? ''}
|
||
onChange={(e) => onChange && onChange(e)}
|
||
onFocus={onFocus}
|
||
style={{ width: '100%', border: bordered === false ? 'none' : undefined, ...style }}
|
||
/>
|
||
);
|
||
|
||
const FallbackSelect = ({ value, onChange, options, placeholder, bordered, allowClear }) => (
|
||
<select
|
||
className="ant-select"
|
||
value={value ?? ''}
|
||
onChange={(e) => {
|
||
const v = e.target.value;
|
||
if (!onChange) return;
|
||
onChange(allowClear && !v ? undefined : v);
|
||
}}
|
||
style={{ width: '100%', border: bordered === false ? 'none' : undefined, textAlign: 'right' }}
|
||
>
|
||
<option value="">{placeholder || '请选择'}</option>
|
||
{(options || []).map((o) => (
|
||
<option key={String(o.value)} value={o.value}>
|
||
{o.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
);
|
||
|
||
const FallbackButton = ({ children, onClick, type, block, size, disabled, style }) => (
|
||
<button
|
||
type="button"
|
||
disabled={disabled}
|
||
onClick={onClick}
|
||
style={{
|
||
display: block ? 'block' : 'inline-block',
|
||
width: block ? '100%' : undefined,
|
||
padding: size === 'large' ? '10px 16px' : '6px 12px',
|
||
borderRadius: 8,
|
||
border: type === 'primary' ? 'none' : '1px solid #e5e6eb',
|
||
background: type === 'primary' ? COLOR_PRIMARY : '#fff',
|
||
color: type === 'primary' ? '#fff' : COLOR_TEXT,
|
||
fontWeight: 600,
|
||
cursor: disabled ? 'not-allowed' : 'pointer',
|
||
opacity: disabled ? 0.5 : 1,
|
||
...style
|
||
}}
|
||
>
|
||
{children}
|
||
</button>
|
||
);
|
||
|
||
const Drawer = antd.Drawer || FallbackDrawer;
|
||
const Input = antd.Input || FallbackInput;
|
||
const Select = antd.Select || FallbackSelect;
|
||
const Button = antd.Button || FallbackButton;
|
||
const Tag = antd.Tag || FallbackTag;
|
||
const Modal = antd.Modal || FallbackModal;
|
||
const message = antd.message || {
|
||
success: (t) => window.alert(t),
|
||
error: (t) => window.alert(t),
|
||
warning: (t) => window.alert(t),
|
||
info: (t) => window.alert(t)
|
||
};
|
||
const InputNumber = antd.InputNumber || (Input && Input.Number) || FallbackInputNumber;
|
||
const Upload = antd.Upload || FallbackUpload;
|
||
const Image = antd.Image;
|
||
|
||
const FallbackTextArea = ({ value, onChange, placeholder, bordered, rows }) => (
|
||
<textarea
|
||
className="ant-input"
|
||
rows={rows || 2}
|
||
placeholder={placeholder}
|
||
value={value ?? ''}
|
||
onChange={(e) => onChange && onChange(e)}
|
||
style={{ width: '100%', border: bordered === false ? 'none' : undefined, resize: 'none' }}
|
||
/>
|
||
);
|
||
|
||
const TextArea = (antd.Input && antd.Input.TextArea) || FallbackTextArea;
|
||
|
||
/** 省市数据(小程序级联选择) */
|
||
const PROVINCE_CITY_MAP = {
|
||
广东省: ['广州市', '深圳市', '东莞市'],
|
||
江苏省: ['南京市', '苏州市', '无锡市'],
|
||
浙江省: ['杭州市', '宁波市', '温州市'],
|
||
上海市: ['上海市'],
|
||
安徽省: ['合肥市', '芜湖市']
|
||
};
|
||
const PROVINCE_LIST = Object.keys(PROVINCE_CITY_MAP);
|
||
const MAX_PLATE_INPUT_LEN = 8;
|
||
const PLATE_PROVINCES = [
|
||
'京', '津', '沪', '渝', '冀', '豫', '云', '辽', '黑', '湘', '皖', '鲁', '新', '苏', '浙', '赣',
|
||
'鄂', '桂', '甘', '晋', '蒙', '陕', '吉', '闽', '贵', '粤', '青', '藏', '川', '宁', '琼', '使', '领'
|
||
];
|
||
const PLATE_LETTERS = 'ABCDEFGHJKLMNPQRSTUVWXYZ'.split('');
|
||
const PLATE_ALNUM = '0123456789ABCDEFGHJKLMNPQRSTUVWXYZ'.split('');
|
||
const PLATE_LAST_CHARS = ['港', '澳', '学', '警', '挂', '使', '领'];
|
||
|
||
const getPlateKeysForIndex = (index) => {
|
||
if (index === 0) return PLATE_PROVINCES;
|
||
if (index === 1) return PLATE_LETTERS;
|
||
if (index >= 6) return [...PLATE_ALNUM, ...PLATE_LAST_CHARS];
|
||
return PLATE_ALNUM;
|
||
};
|
||
/** 检测服务站列表(检测站、二保共用) */
|
||
const INSPECTION_STATION_LIST = ['汇通检测站', '平湖检测站', '嘉兴检测站', '松江检测站'];
|
||
/** 维修站列表(整备服务站) */
|
||
const REPAIR_STATION_LIST = [
|
||
'杭州拱墅维修站',
|
||
'广州天河维修站',
|
||
'上海浦东维修站',
|
||
'深圳南山维修站',
|
||
'天津港保税区维修站'
|
||
];
|
||
const INSPECTION_STATION_STORAGE_KEY = 'annual-review.station.inspection';
|
||
|
||
const readInspectionStationList = () => {
|
||
try {
|
||
const raw = localStorage.getItem(INSPECTION_STATION_STORAGE_KEY);
|
||
const parsed = JSON.parse(raw);
|
||
if (Array.isArray(parsed) && parsed.length) return parsed;
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return [...INSPECTION_STATION_LIST];
|
||
};
|
||
|
||
const toStationSelectOptions = (list) => (list || []).map((s) => ({ label: s, value: s }));
|
||
|
||
const isFormCostEmpty = (cost) => cost === '' || cost == null;
|
||
|
||
/** 已选服务站时费用必填 */
|
||
const validateStationCost = (form, stationLabel) => {
|
||
if (form?.station && isFormCostEmpty(form.cost)) {
|
||
message.error(`请填写${stationLabel}费用`);
|
||
return false;
|
||
}
|
||
return true;
|
||
};
|
||
|
||
/** 更新行驶证:照片、检验有效期必填(提交时) */
|
||
const validateLicenseForm = (licenseForm) => {
|
||
if (!(licenseForm?.photos || []).length) {
|
||
message.error('请上传行驶证照片');
|
||
return false;
|
||
}
|
||
if (!licenseForm?.inspectionValidUntil) {
|
||
message.error('请填写检验有效期');
|
||
return false;
|
||
}
|
||
return true;
|
||
};
|
||
const EMPTY_SERVICE_FORM = {
|
||
station: '',
|
||
cost: '',
|
||
costBearer: undefined,
|
||
remark: '',
|
||
photos: []
|
||
};
|
||
const EMPTY_INSPECTION_FORM = {
|
||
station: '',
|
||
cost: '',
|
||
remark: ''
|
||
};
|
||
const MAX_SERVICE_PHOTOS = 10;
|
||
const MAX_LICENSE_PHOTOS = 4;
|
||
const LICENSE_OCR_MOCK_MS = 1500;
|
||
const EMPTY_LICENSE_FORM = {
|
||
photos: [],
|
||
inspectionValidUntil: null,
|
||
ocrStatus: 'idle'
|
||
};
|
||
|
||
const mapOperateStatus = (raw) => {
|
||
if (raw === '可运营' || raw === '待运营') return '库存';
|
||
return raw || '—';
|
||
};
|
||
|
||
const MOCK_TASKS = [
|
||
{
|
||
id: 'ar-1',
|
||
plateNo: '粤B58888F',
|
||
vin: 'LGHXCAE28M6789012',
|
||
brand: '福田',
|
||
model: '奥铃4.5吨冷藏车',
|
||
operateStatusRaw: '租赁',
|
||
expireDate: '2026-07-20',
|
||
daysLeft: 49,
|
||
tab: 'pending',
|
||
province: '广东省',
|
||
city: '深圳市',
|
||
executor: '',
|
||
executeTime: ''
|
||
},
|
||
{
|
||
id: 'ar-2',
|
||
plateNo: '沪A03561F',
|
||
vin: 'LMRKH9AC0R1004086',
|
||
brand: '宇通',
|
||
model: '49吨牵引车头',
|
||
operateStatusRaw: '自营',
|
||
expireDate: '2026-07-31',
|
||
daysLeft: 60,
|
||
tab: 'pending',
|
||
province: '上海市',
|
||
city: '上海市',
|
||
executor: '',
|
||
executeTime: ''
|
||
},
|
||
{
|
||
id: 'ar-3',
|
||
plateNo: '苏E33333',
|
||
vin: 'LSXCH9AE8M1094857',
|
||
brand: '陕汽',
|
||
model: '德龙X3000混动牵引车',
|
||
operateStatusRaw: '可运营',
|
||
expireDate: '2026-05-15',
|
||
daysLeft: -17,
|
||
tab: 'pending',
|
||
province: '江苏省',
|
||
city: '苏州市',
|
||
executor: '',
|
||
executeTime: ''
|
||
},
|
||
{
|
||
id: 'ar-7',
|
||
plateNo: '鲁Q88901',
|
||
vin: 'LZZ5CLSB8NC778899',
|
||
brand: '重汽',
|
||
model: '豪沃T7H牵引车',
|
||
operateStatusRaw: '租赁',
|
||
expireDate: '2026-04-10',
|
||
daysLeft: -52,
|
||
tab: 'pending',
|
||
province: '山东省',
|
||
city: '临沂市',
|
||
executor: '',
|
||
executeTime: ''
|
||
},
|
||
{
|
||
id: 'ar-8',
|
||
plateNo: '闽D55662',
|
||
vin: 'LFWNHXSD8P1122334',
|
||
brand: '金龙',
|
||
model: '凯歌纯电动厢货',
|
||
operateStatusRaw: '自营',
|
||
expireDate: '2026-04-27',
|
||
daysLeft: -35,
|
||
tab: 'pending',
|
||
province: '福建省',
|
||
city: '厦门市',
|
||
executor: '',
|
||
executeTime: ''
|
||
},
|
||
{
|
||
id: 'ar-4',
|
||
plateNo: '浙A88888',
|
||
vin: 'LMRKH9AE2P9876543',
|
||
brand: '宇通',
|
||
model: '氢燃料电池大巴',
|
||
operateStatusRaw: '待运营',
|
||
expireDate: '2026-08-10',
|
||
daysLeft: 70,
|
||
tab: 'pending',
|
||
province: '浙江省',
|
||
city: '杭州市',
|
||
executor: '',
|
||
executeTime: ''
|
||
},
|
||
{
|
||
id: 'ar-6',
|
||
plateNo: '皖B66221',
|
||
vin: 'LZZ5CLSB8NA123456',
|
||
brand: '江淮',
|
||
model: '格尔发A5',
|
||
operateStatusRaw: '库存',
|
||
expireDate: '2026-06-28',
|
||
daysLeft: 27,
|
||
tab: 'pending',
|
||
province: '安徽省',
|
||
city: '合肥市',
|
||
executor: '',
|
||
executeTime: ''
|
||
},
|
||
{
|
||
id: 'ar-h1',
|
||
plateNo: '苏A88991',
|
||
vin: 'LSVAM4187C2123456',
|
||
brand: '解放',
|
||
model: 'J6P牵引车',
|
||
operateStatusRaw: '自营',
|
||
expireDate: '2026-03-10',
|
||
daysLeft: 0,
|
||
tab: 'history',
|
||
province: '江苏省',
|
||
city: '南京市',
|
||
executor: '张明辉',
|
||
executeTime: '2026-03-08 14:20'
|
||
},
|
||
{
|
||
id: 'ar-h2',
|
||
plateNo: '粤A11223',
|
||
vin: 'LFWNHXSD8P7654321',
|
||
brand: '比亚迪',
|
||
model: 'T5纯电轻卡',
|
||
operateStatusRaw: '待运营',
|
||
expireDate: '2026-02-20',
|
||
daysLeft: 0,
|
||
tab: 'history',
|
||
province: '广东省',
|
||
city: '广州市',
|
||
executor: '李晓彤',
|
||
executeTime: '2026-02-18 09:45'
|
||
},
|
||
{
|
||
id: 'ar-h3',
|
||
plateNo: '京A55667',
|
||
vin: 'LZZ5CLSB8NB654321',
|
||
brand: '东风',
|
||
model: '天龙KL',
|
||
operateStatusRaw: '租赁',
|
||
expireDate: '2026-01-15',
|
||
daysLeft: 0,
|
||
tab: 'history',
|
||
province: '广东省',
|
||
city: '东莞市',
|
||
executor: '王建国',
|
||
executeTime: '2026-01-12 16:30'
|
||
}
|
||
].map((t) => ({
|
||
...t,
|
||
operateStatus: mapOperateStatus(t.operateStatusRaw)
|
||
}));
|
||
|
||
/** 退出运营车辆无需年审,不纳入任务列表 */
|
||
const isAnnualReviewExcluded = (task) =>
|
||
task.operateStatusRaw === '退出运营' || task.operateStatus === '退出运营';
|
||
|
||
/** 待办:已过期天数(未过期为 0,越大表示过期越久) */
|
||
const getOverdueDays = (task) => {
|
||
if (task?.daysLeft != null && task.daysLeft < 0) return -task.daysLeft;
|
||
if (moment && task?.expireDate) {
|
||
const overdue = moment().startOf('day').diff(moment(task.expireDate).startOf('day'), 'days');
|
||
return overdue > 0 ? overdue : 0;
|
||
}
|
||
return 0;
|
||
};
|
||
|
||
/** 待办排序用剩余天数(越短越靠前) */
|
||
const getDaysLeftForSort = (task) => {
|
||
if (task?.daysLeft != null) return task.daysLeft;
|
||
if (moment && task?.expireDate) {
|
||
return moment(task.expireDate).startOf('day').diff(moment().startOf('day'), 'days');
|
||
}
|
||
return Number.MAX_SAFE_INTEGER;
|
||
};
|
||
|
||
/** 待办列表排序:① 过期越久越靠前 ② 剩余天数越短越靠前 */
|
||
const sortPendingTasks = (tasks) =>
|
||
[...tasks].sort((a, b) => {
|
||
const overdueDiff = getOverdueDays(b) - getOverdueDays(a);
|
||
if (overdueDiff !== 0) return overdueDiff;
|
||
return getDaysLeftForSort(a) - getDaysLeftForSort(b);
|
||
});
|
||
|
||
const AR_DRAFT_STORAGE_KEY = 'oneos_ar_operate_drafts_v1';
|
||
/** 原型:年审提交后写入,供 Web 端证照管理读取演示 */
|
||
const CERTIFICATE_LICENSE_SYNC_KEY = 'oneos_certificate_license_sync';
|
||
|
||
const loadOperateDrafts = () => {
|
||
try {
|
||
const raw = localStorage.getItem(AR_DRAFT_STORAGE_KEY);
|
||
if (!raw) return {};
|
||
const parsed = JSON.parse(raw);
|
||
return parsed && typeof parsed === 'object' ? parsed : {};
|
||
} catch {
|
||
return {};
|
||
}
|
||
};
|
||
|
||
const persistOperateDrafts = (drafts) => {
|
||
try {
|
||
localStorage.setItem(AR_DRAFT_STORAGE_KEY, JSON.stringify(drafts));
|
||
} catch {
|
||
/* 原型环境可能无 localStorage */
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 年审提交后,将最新行驶证照片 + 检验有效期同步至证照管理(正式环境走服务端接口)
|
||
* @returns {{ plateNo: string, inspectionValidUntil: string, photoCount: number } | null}
|
||
*/
|
||
const syncLicenseToCertificateManagement = (task, licenseForm, operator) => {
|
||
if (!task?.plateNo) return null;
|
||
const photos = serializeUploadFileList(licenseForm?.photos || []);
|
||
const inspectionValidUntil = licenseForm?.inspectionValidUntil || '';
|
||
const payload = {
|
||
plateNo: task.plateNo,
|
||
vin: task.vin || '',
|
||
inspectionValidUntil,
|
||
photos,
|
||
photoCount: photos.length,
|
||
operator: operator || MOCK_CURRENT_HANDLER,
|
||
source: 'annual_review',
|
||
syncedAt: new Date().toISOString()
|
||
};
|
||
try {
|
||
const store = JSON.parse(localStorage.getItem(CERTIFICATE_LICENSE_SYNC_KEY) || '{}');
|
||
store[task.plateNo] = payload;
|
||
localStorage.setItem(CERTIFICATE_LICENSE_SYNC_KEY, JSON.stringify(store));
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
return payload;
|
||
};
|
||
|
||
const getUploadPreviewUrl = (file) => {
|
||
if (!file) return '';
|
||
if (file.url) return file.url;
|
||
if (file.thumbUrl) return file.thumbUrl;
|
||
if (file.originFileObj && typeof URL !== 'undefined' && URL.createObjectURL) {
|
||
return URL.createObjectURL(file.originFileObj);
|
||
}
|
||
return '';
|
||
};
|
||
|
||
const serializeUploadFileList = (list) =>
|
||
(list || []).map((f) => ({
|
||
uid: f.uid,
|
||
name: f.name,
|
||
url: getUploadPreviewUrl(f) || '',
|
||
status: f.status || 'done'
|
||
}));
|
||
|
||
const deserializeUploadFileList = (list) =>
|
||
(list || []).map((f) => ({
|
||
uid: f.uid || `file-${f.name}-${Math.random().toString(36).slice(2, 8)}`,
|
||
name: f.name,
|
||
url: f.url,
|
||
thumbUrl: f.url,
|
||
status: f.status || 'done'
|
||
}));
|
||
|
||
/** 操作页运营状态:库存细分为可运营/待运营 */
|
||
const formatOperateStatusDisplay = (task) => {
|
||
const base = task.operateStatus || '—';
|
||
if (base === '库存' && (task.operateStatusRaw === '可运营' || task.operateStatusRaw === '待运营')) {
|
||
return `库存(${task.operateStatusRaw})`;
|
||
}
|
||
return base;
|
||
};
|
||
|
||
/** 完成时间展示至分钟 YYYY-MM-DD HH:mm */
|
||
const formatCompleteTime = (value) => {
|
||
if (!value) return '—';
|
||
if (moment) {
|
||
const m = moment(value);
|
||
if (m.isValid && m.isValid()) return m.format('YYYY-MM-DD HH:mm');
|
||
}
|
||
const normalized = String(value).trim().replace('T', ' ').slice(0, 16);
|
||
return normalized.length >= 16 ? normalized : value;
|
||
};
|
||
|
||
const DEFAULT_FILTER = {
|
||
provinceCity: null,
|
||
expireRange: null,
|
||
handler: '',
|
||
executeTimeRange: null
|
||
};
|
||
|
||
/** 历史记录 · 办理人选项 */
|
||
const HANDLER_OPTIONS = [
|
||
{ label: '全部', value: '' },
|
||
{ label: '张明辉', value: '张明辉' },
|
||
{ label: '李晓彤', value: '李晓彤' },
|
||
{ label: '王建国', value: '王建国' }
|
||
];
|
||
|
||
/** 原型:当前登录办理人 */
|
||
const MOCK_CURRENT_HANDLER = '张明辉';
|
||
|
||
const formatDisplayMoney = (cost) => {
|
||
if (cost === '' || cost == null) return '—';
|
||
const n = Number(cost);
|
||
return Number.isFinite(n) ? `${n.toFixed(2)} 元` : '—';
|
||
};
|
||
|
||
const hasServiceContent = (form) =>
|
||
!!(form?.station || form?.cost || form?.remark || (form?.photos || []).length);
|
||
|
||
const buildMockLicensePhotos = (taskId) => [
|
||
{
|
||
uid: `license-${taskId}-1`,
|
||
name: '行驶证.jpg',
|
||
url: `https://picsum.photos/seed/ar-license-${taskId}/240/180`,
|
||
status: 'done'
|
||
}
|
||
];
|
||
|
||
const buildSampleHistorySnapshot = (task) => ({
|
||
inspectionForm: {
|
||
station: task.id === 'ar-h2' ? '平湖检测站' : '汇通检测站',
|
||
cost: task.id === 'ar-h3' ? '450' : '320',
|
||
remark: task.id === 'ar-h1' ? '已通过年检' : ''
|
||
},
|
||
licenseForm: {
|
||
photos: buildMockLicensePhotos(task.id),
|
||
inspectionValidUntil: task.expireDate,
|
||
ocrStatus: 'done'
|
||
},
|
||
m2Expanded: task.id === 'ar-h1',
|
||
m2Form:
|
||
task.id === 'ar-h1'
|
||
? { station: '汇通检测站', cost: '180', remark: '二保已完成', photos: [] }
|
||
: { ...EMPTY_SERVICE_FORM },
|
||
zbExpanded: false,
|
||
zbForm: { ...EMPTY_SERVICE_FORM }
|
||
});
|
||
|
||
const getHistorySnapshot = (task) => {
|
||
const snap = task?.operateSnapshot || buildSampleHistorySnapshot(task);
|
||
const licensePhotos = snap.licenseForm?.photos;
|
||
if (task?.tab === 'history' && (!licensePhotos || !licensePhotos.length)) {
|
||
return {
|
||
...snap,
|
||
licenseForm: {
|
||
...snap.licenseForm,
|
||
photos: buildMockLicensePhotos(task.id)
|
||
}
|
||
};
|
||
}
|
||
return snap;
|
||
};
|
||
|
||
const formatProvinceCity = (provinceCity) => {
|
||
if (!provinceCity || !provinceCity[0]) return '';
|
||
if (provinceCity[1]) return `${provinceCity[0]} / ${provinceCity[1]}`;
|
||
return provinceCity[0];
|
||
};
|
||
|
||
const formatTaskRegion = (task) => {
|
||
if (!task?.province) return '—';
|
||
if (task.city) return `${task.province}-${task.city}`;
|
||
return task.province;
|
||
};
|
||
|
||
const formatDateRangeDisplay = (range) => {
|
||
if (!range || !range[0] || !range[1]) return '';
|
||
if (moment) {
|
||
const start = moment(range[0]);
|
||
const end = moment(range[1]);
|
||
if (start.isValid() && end.isValid()) {
|
||
return `${start.format('YYYY-MM-DD')} ~ ${end.format('YYYY-MM-DD')}`;
|
||
}
|
||
}
|
||
return '已选择时间范围';
|
||
};
|
||
|
||
const formatSingleDateDisplay = (val) => {
|
||
if (!val) return '';
|
||
if (moment) {
|
||
const m = moment(val);
|
||
if (m.isValid()) return m.format('YYYY-MM-DD');
|
||
}
|
||
if (typeof val === 'string') return val;
|
||
return '';
|
||
};
|
||
|
||
const normalizePlateNo = (plate) => (plate || '').replace(/\s/g, '').toUpperCase();
|
||
|
||
const platesMatch = (a, b) => {
|
||
const na = normalizePlateNo(a);
|
||
const nb = normalizePlateNo(b);
|
||
return na.length > 0 && na === nb;
|
||
};
|
||
|
||
/** 模拟 OCR 识别行驶证车牌号(原型:默认与任务一致;文件名含 mismatch 可模拟不一致) */
|
||
const mockOcrLicensePlate = (task, photos) => {
|
||
const list = photos || [];
|
||
const last = list[list.length - 1];
|
||
const name = String(last?.name || last?.originFileObj?.name || '').toLowerCase();
|
||
if (name.includes('mismatch') || name.includes('不一致')) {
|
||
return '粤B00000D';
|
||
}
|
||
return task?.plateNo || '';
|
||
};
|
||
|
||
/** 模拟 OCR 仅识别到年月,返回该月最后一天 YYYY-MM-DD */
|
||
const mockOcrLicenseValidUntil = (task) => {
|
||
const base = task?.expireDate || '2026-07-20';
|
||
if (moment) {
|
||
return moment(base).endOf('month').format('YYYY-MM-DD');
|
||
}
|
||
const parts = String(base).split('-');
|
||
const year = Number(parts[0]) || new Date().getFullYear();
|
||
const month = Number(parts[1]) || 1;
|
||
const day = getDaysInMonth(year, month);
|
||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||
};
|
||
|
||
const PAGE_STYLE = `
|
||
.ar-mini-root {
|
||
min-height: 100vh;
|
||
background: linear-gradient(180deg, #e8ebeF 0%, ${COLOR_PAGE} 32%);
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 16px 12px 32px;
|
||
box-sizing: border-box;
|
||
font-family: ${FONT_FAMILY};
|
||
}
|
||
.ar-phone {
|
||
width: 100%;
|
||
max-width: 390px;
|
||
min-height: 844px;
|
||
background: ${COLOR_PAGE};
|
||
border-radius: 28px;
|
||
overflow: hidden;
|
||
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.12), 0 0 0 1px rgba(15, 23, 42, 0.06);
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: relative;
|
||
}
|
||
.ar-chrome {
|
||
flex-shrink: 0;
|
||
background: ${COLOR_BG};
|
||
}
|
||
.ar-status-bar {
|
||
height: 44px;
|
||
padding: 14px 24px 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
box-sizing: border-box;
|
||
}
|
||
.ar-status-time {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: ${COLOR_TEXT};
|
||
letter-spacing: -0.02em;
|
||
}
|
||
.ar-status-icons {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
color: ${COLOR_TEXT};
|
||
}
|
||
.ar-mp-navbar {
|
||
height: 44px;
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 0 12px 0 8px;
|
||
box-sizing: border-box;
|
||
border-bottom: 1px solid rgba(0, 0, 0, 0.06);
|
||
position: relative;
|
||
}
|
||
.ar-mp-navbar-left {
|
||
display: flex;
|
||
align-items: center;
|
||
min-width: 72px;
|
||
z-index: 2;
|
||
}
|
||
.ar-mp-navbar-title {
|
||
position: absolute;
|
||
left: 50%;
|
||
transform: translateX(-50%);
|
||
max-width: 46%;
|
||
font-size: 17px;
|
||
font-weight: 600;
|
||
color: ${COLOR_TEXT};
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
pointer-events: none;
|
||
}
|
||
.ar-mp-navbar-right {
|
||
margin-left: auto;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
z-index: 2;
|
||
}
|
||
.ar-mp-back {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 36px;
|
||
height: 36px;
|
||
border: none;
|
||
background: transparent;
|
||
color: ${COLOR_TEXT};
|
||
cursor: pointer;
|
||
border-radius: 8px;
|
||
padding: 0;
|
||
}
|
||
.ar-mp-back:active {
|
||
background: rgba(0, 0, 0, 0.05);
|
||
}
|
||
.ar-mp-link {
|
||
border: none;
|
||
background: transparent;
|
||
color: ${COLOR_PRIMARY_DEEP};
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
padding: 6px 4px;
|
||
cursor: pointer;
|
||
white-space: nowrap;
|
||
}
|
||
.ar-mp-capsule {
|
||
display: flex;
|
||
align-items: center;
|
||
height: 32px;
|
||
border-radius: 16px;
|
||
border: 0.5px solid rgba(0, 0, 0, 0.12);
|
||
background: rgba(255, 255, 255, 0.92);
|
||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
|
||
overflow: hidden;
|
||
}
|
||
.ar-mp-capsule-btn {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 44px;
|
||
height: 32px;
|
||
border: none;
|
||
background: transparent;
|
||
color: ${COLOR_TEXT};
|
||
font-size: 18px;
|
||
line-height: 1;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
}
|
||
.ar-mp-capsule-btn:active {
|
||
background: rgba(0, 0, 0, 0.06);
|
||
}
|
||
.ar-mp-capsule-btn--more {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
letter-spacing: 1px;
|
||
}
|
||
.ar-mp-capsule-close-icon {
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 50%;
|
||
border: 1.5px solid ${COLOR_TEXT};
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 12px;
|
||
line-height: 1;
|
||
font-weight: 300;
|
||
}
|
||
.ar-mp-capsule-divider {
|
||
width: 0.5px;
|
||
height: 18px;
|
||
background: rgba(0, 0, 0, 0.12);
|
||
flex-shrink: 0;
|
||
}
|
||
.ar-tabs {
|
||
display: flex;
|
||
border-bottom: 1px solid ${COLOR_LINE};
|
||
background: ${COLOR_BG};
|
||
flex-shrink: 0;
|
||
}
|
||
.ar-tab {
|
||
flex: 1;
|
||
text-align: center;
|
||
padding: 12px 0;
|
||
font-size: 15px;
|
||
font-weight: 500;
|
||
color: ${COLOR_MUTED};
|
||
cursor: pointer;
|
||
position: relative;
|
||
border: none;
|
||
background: transparent;
|
||
min-height: 44px;
|
||
transition: color 0.2s ease;
|
||
}
|
||
.ar-tab.active {
|
||
color: ${COLOR_PRIMARY_DEEP};
|
||
font-weight: 700;
|
||
}
|
||
.ar-tab.active::after {
|
||
content: '';
|
||
position: absolute;
|
||
left: 50%;
|
||
bottom: 0;
|
||
transform: translateX(-50%);
|
||
width: 28px;
|
||
height: 3px;
|
||
border-radius: 2px;
|
||
background: linear-gradient(90deg, ${COLOR_PRIMARY}, ${COLOR_PRIMARY_DEEP});
|
||
}
|
||
.ar-search-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 14px;
|
||
background: ${COLOR_PAGE};
|
||
flex-shrink: 0;
|
||
}
|
||
.ar-search-box {
|
||
flex: 1;
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
background: ${COLOR_BG};
|
||
border-radius: 22px;
|
||
padding: 0 14px;
|
||
min-height: 42px;
|
||
box-shadow: inset 0 0 0 1px rgba(0,0,0,0.05);
|
||
cursor: pointer;
|
||
}
|
||
.ar-search-placeholder {
|
||
position: absolute;
|
||
left: 14px;
|
||
right: 14px;
|
||
font-size: 15px;
|
||
color: ${COLOR_MUTED};
|
||
pointer-events: none;
|
||
line-height: 42px;
|
||
z-index: 0;
|
||
}
|
||
.ar-search-box input {
|
||
flex: 1;
|
||
position: relative;
|
||
z-index: 1;
|
||
border: none;
|
||
outline: none;
|
||
font-size: 15px;
|
||
background: transparent;
|
||
color: ${COLOR_TEXT};
|
||
min-width: 0;
|
||
width: 100%;
|
||
cursor: pointer;
|
||
caret-color: transparent;
|
||
}
|
||
.ar-region-trigger {
|
||
width: 100%;
|
||
cursor: pointer;
|
||
caret-color: transparent;
|
||
}
|
||
.ar-region-picker {
|
||
display: flex;
|
||
height: 220px;
|
||
border: 1px solid ${COLOR_LINE};
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
background: ${COLOR_BG};
|
||
}
|
||
.ar-region-col {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
.ar-region-col + .ar-region-col {
|
||
border-left: 1px solid ${COLOR_LINE};
|
||
}
|
||
.ar-region-item {
|
||
display: block;
|
||
width: 100%;
|
||
border: none;
|
||
background: transparent;
|
||
text-align: left;
|
||
padding: 12px 14px;
|
||
font-size: 15px;
|
||
color: ${COLOR_TEXT};
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
}
|
||
.ar-region-item.active {
|
||
background: rgba(22, 209, 161, 0.12);
|
||
color: ${COLOR_PRIMARY_DEEP};
|
||
font-weight: 600;
|
||
}
|
||
.ar-region-item:active {
|
||
background: #f2f3f5;
|
||
}
|
||
.ar-region-drawer-hint {
|
||
font-size: 12px;
|
||
color: ${COLOR_MUTED};
|
||
margin-bottom: 10px;
|
||
}
|
||
.ar-filter-section-label {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: ${COLOR_MUTED};
|
||
margin: 8px 0 12px;
|
||
padding-top: 4px;
|
||
border-top: 1px solid ${COLOR_LINE};
|
||
}
|
||
.ar-mini-trigger {
|
||
width: 100%;
|
||
cursor: pointer;
|
||
caret-color: transparent;
|
||
}
|
||
.ar-picker-single {
|
||
max-height: 260px;
|
||
overflow-y: auto;
|
||
border: 1px solid ${COLOR_LINE};
|
||
border-radius: 10px;
|
||
background: ${COLOR_BG};
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
.ar-date-range-tabs {
|
||
display: flex;
|
||
gap: 6px;
|
||
background: #f2f3f5;
|
||
border-radius: 10px;
|
||
padding: 4px;
|
||
margin-bottom: 4px;
|
||
}
|
||
.ar-date-range-tab {
|
||
flex: 1;
|
||
padding: 8px 6px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
background: transparent;
|
||
font-size: 13px;
|
||
color: ${COLOR_MUTED};
|
||
cursor: pointer;
|
||
line-height: 1.35;
|
||
transition: background 0.15s, color 0.15s, box-shadow 0.15s;
|
||
}
|
||
.ar-date-range-tab.active {
|
||
background: ${COLOR_BG};
|
||
color: ${COLOR_TEXT};
|
||
font-weight: 600;
|
||
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.08);
|
||
}
|
||
.ar-date-range-tab em {
|
||
display: block;
|
||
font-style: normal;
|
||
font-size: 11px;
|
||
font-weight: 400;
|
||
color: ${COLOR_MUTED};
|
||
margin-top: 3px;
|
||
}
|
||
.ar-date-range-tab.active em {
|
||
color: ${COLOR_PRIMARY_DEEP};
|
||
font-weight: 500;
|
||
}
|
||
.ar-date-wheel-wrap {
|
||
position: relative;
|
||
height: 220px;
|
||
display: flex;
|
||
border-radius: 10px;
|
||
overflow: hidden;
|
||
background: ${COLOR_BG};
|
||
border: 1px solid ${COLOR_LINE};
|
||
}
|
||
.ar-date-wheel-highlight {
|
||
position: absolute;
|
||
left: 12px;
|
||
right: 12px;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
height: 40px;
|
||
border-top: 1px solid ${COLOR_LINE};
|
||
border-bottom: 1px solid ${COLOR_LINE};
|
||
pointer-events: none;
|
||
z-index: 2;
|
||
border-radius: 4px;
|
||
}
|
||
.ar-date-wheel-fade {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
height: 72px;
|
||
pointer-events: none;
|
||
z-index: 1;
|
||
}
|
||
.ar-date-wheel-fade.top {
|
||
top: 0;
|
||
background: linear-gradient(180deg, rgba(255,255,255,0.95) 0%, rgba(255,255,255,0) 100%);
|
||
}
|
||
.ar-date-wheel-fade.bottom {
|
||
bottom: 0;
|
||
background: linear-gradient(0deg, rgba(255,255,255,0.95) 0%, rgba(255,255,255,0) 100%);
|
||
}
|
||
.ar-date-wheel-col {
|
||
flex: 1;
|
||
height: 220px;
|
||
overflow-y: auto;
|
||
scroll-snap-type: y mandatory;
|
||
-webkit-overflow-scrolling: touch;
|
||
scrollbar-width: none;
|
||
}
|
||
.ar-date-wheel-col::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
.ar-date-wheel-col + .ar-date-wheel-col {
|
||
border-left: 1px solid ${COLOR_LINE};
|
||
}
|
||
.ar-date-wheel-pad {
|
||
height: 90px;
|
||
flex-shrink: 0;
|
||
}
|
||
.ar-date-wheel-item {
|
||
height: 40px;
|
||
line-height: 40px;
|
||
text-align: center;
|
||
scroll-snap-align: center;
|
||
font-size: 15px;
|
||
color: ${COLOR_MUTED};
|
||
user-select: none;
|
||
}
|
||
.ar-date-wheel-item.active {
|
||
color: ${COLOR_TEXT};
|
||
font-weight: 600;
|
||
font-size: 17px;
|
||
}
|
||
.ar-date-wheel-unit {
|
||
position: absolute;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
font-size: 13px;
|
||
color: ${COLOR_MUTED};
|
||
pointer-events: none;
|
||
z-index: 3;
|
||
font-weight: 500;
|
||
}
|
||
.ar-date-wheel-unit.y { left: 33.33%; margin-left: 28px; }
|
||
.ar-date-wheel-unit.m { left: 66.66%; margin-left: 18px; }
|
||
.ar-date-wheel-unit.d { right: 12px; }
|
||
.ar-plate-kb-mask {
|
||
position: absolute;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.45);
|
||
z-index: 30;
|
||
}
|
||
.ar-plate-kb {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
z-index: 31;
|
||
background: #ebedf0;
|
||
border-radius: 14px 14px 0 0;
|
||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||
box-shadow: 0 -8px 24px rgba(0, 0, 0, 0.12);
|
||
}
|
||
.ar-plate-kb-head {
|
||
padding: 12px 16px 10px;
|
||
background: ${COLOR_BG};
|
||
border-radius: 14px 14px 0 0;
|
||
border-bottom: 1px solid ${COLOR_LINE};
|
||
}
|
||
.ar-plate-kb-title {
|
||
font-size: 13px;
|
||
color: ${COLOR_MUTED};
|
||
margin-bottom: 10px;
|
||
text-align: center;
|
||
}
|
||
.ar-plate-cells {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 6px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.ar-plate-cell {
|
||
width: 34px;
|
||
height: 44px;
|
||
border: 1px solid ${COLOR_LINE};
|
||
border-radius: 6px;
|
||
background: ${COLOR_PAGE};
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
color: ${COLOR_TEXT};
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
}
|
||
.ar-plate-cell.active {
|
||
border-color: ${COLOR_PRIMARY_DEEP};
|
||
box-shadow: 0 0 0 2px rgba(0, 191, 165, 0.2);
|
||
background: ${COLOR_BG};
|
||
}
|
||
.ar-plate-cell.filled {
|
||
color: ${COLOR_PRIMARY_DEEP};
|
||
}
|
||
.ar-plate-cell-sep {
|
||
width: 6px;
|
||
height: 4px;
|
||
border-radius: 50%;
|
||
background: ${COLOR_MUTED};
|
||
opacity: 0.5;
|
||
flex-shrink: 0;
|
||
}
|
||
.ar-plate-kb-toolbar {
|
||
display: flex;
|
||
gap: 10px;
|
||
padding: 8px 12px;
|
||
background: #ebedf0;
|
||
}
|
||
.ar-plate-kb-tool {
|
||
flex: 1;
|
||
height: 40px;
|
||
border: none;
|
||
border-radius: 8px;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
}
|
||
.ar-plate-kb-tool--delete {
|
||
background: #d8dce3;
|
||
color: ${COLOR_TEXT};
|
||
}
|
||
.ar-plate-kb-tool--ok {
|
||
background: linear-gradient(135deg, ${COLOR_PRIMARY} 0%, ${COLOR_PRIMARY_DEEP} 100%);
|
||
color: #fff;
|
||
}
|
||
.ar-plate-keys {
|
||
display: grid;
|
||
grid-template-columns: repeat(8, 1fr);
|
||
gap: 6px;
|
||
padding: 0 10px 12px;
|
||
max-height: 220px;
|
||
overflow-y: auto;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
.ar-plate-key {
|
||
height: 42px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
background: ${COLOR_BG};
|
||
font-size: 17px;
|
||
font-weight: 600;
|
||
color: ${COLOR_TEXT};
|
||
cursor: pointer;
|
||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.06);
|
||
font-family: inherit;
|
||
padding: 0;
|
||
}
|
||
.ar-plate-key:active {
|
||
background: #dfe3e8;
|
||
}
|
||
.ar-filter-btn {
|
||
width: 42px;
|
||
height: 42px;
|
||
border-radius: 21px;
|
||
border: none;
|
||
background: ${COLOR_BG};
|
||
cursor: pointer;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
|
||
flex-shrink: 0;
|
||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||
}
|
||
.ar-filter-btn:active {
|
||
transform: scale(0.96);
|
||
}
|
||
.ar-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 0 14px 24px;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
.ar-card {
|
||
background: ${COLOR_BG};
|
||
border-radius: 14px;
|
||
padding: 14px 16px;
|
||
margin-bottom: 12px;
|
||
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.05);
|
||
border: 1px solid rgba(0,0,0,0.03);
|
||
cursor: pointer;
|
||
transition: transform 0.18s ease, box-shadow 0.18s ease;
|
||
}
|
||
.ar-card:active {
|
||
transform: scale(0.985);
|
||
box-shadow: 0 1px 6px rgba(15, 23, 42, 0.06);
|
||
}
|
||
.ar-card-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
margin-bottom: 10px;
|
||
}
|
||
.ar-plate-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
min-width: 0;
|
||
flex: 1;
|
||
}
|
||
.ar-plate {
|
||
font-size: 17px;
|
||
font-weight: 800;
|
||
color: ${COLOR_PRIMARY_DEEP};
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.ar-plate-tag {
|
||
margin: 0 !important;
|
||
font-size: 11px !important;
|
||
line-height: 18px !important;
|
||
border-radius: 4px !important;
|
||
font-weight: 600 !important;
|
||
}
|
||
.ar-plate-tag--saved {
|
||
color: ${COLOR_PRIMARY_DEEP} !important;
|
||
border-color: rgba(22, 209, 161, 0.35) !important;
|
||
background: rgba(22, 209, 161, 0.1) !important;
|
||
}
|
||
.ar-operate-foot-btns {
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
.ar-operate-foot-btns .ant-btn {
|
||
flex: 1;
|
||
height: 48px !important;
|
||
border-radius: 10px !important;
|
||
font-weight: 600;
|
||
}
|
||
.ar-card-head-right {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
flex-shrink: 0;
|
||
margin-left: 8px;
|
||
}
|
||
.ar-card-status {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 14px;
|
||
color: ${COLOR_MUTED};
|
||
font-weight: 500;
|
||
flex-shrink: 0;
|
||
}
|
||
.ar-kv {
|
||
display: flex;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
margin-top: 6px;
|
||
}
|
||
.ar-kv-label {
|
||
color: ${COLOR_MUTED};
|
||
flex: 0 0 72px;
|
||
}
|
||
.ar-kv-value {
|
||
color: ${COLOR_TEXT};
|
||
flex: 1;
|
||
font-weight: 500;
|
||
}
|
||
.ar-empty {
|
||
text-align: center;
|
||
padding: 48px 16px;
|
||
color: ${COLOR_MUTED};
|
||
font-size: 14px;
|
||
}
|
||
.ar-filter-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
min-height: 40px;
|
||
}
|
||
.ar-filter-label {
|
||
flex: 0 0 72px;
|
||
text-align: right;
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: ${COLOR_MUTED};
|
||
line-height: 1.35;
|
||
}
|
||
.ar-filter-control {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.ar-filter-control .ant-input,
|
||
.ar-filter-control .ant-select,
|
||
.ar-filter-control .ant-picker {
|
||
width: 100% !important;
|
||
}
|
||
.ar-drawer-foot {
|
||
display: flex;
|
||
gap: 10px;
|
||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
|
||
border-top: 1px solid ${COLOR_LINE};
|
||
}
|
||
.ar-operate-wrap {
|
||
position: relative;
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.ar-operate-scroll {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 12px 14px 100px;
|
||
-webkit-overflow-scrolling: touch;
|
||
}
|
||
.ar-ocr-mask {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 25;
|
||
background: rgba(255, 255, 255, 0.78);
|
||
backdrop-filter: blur(3px);
|
||
-webkit-backdrop-filter: blur(3px);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 24px;
|
||
touch-action: none;
|
||
}
|
||
.ar-ocr-mask-panel {
|
||
text-align: center;
|
||
padding: 28px 32px;
|
||
background: ${COLOR_BG};
|
||
border-radius: 14px;
|
||
box-shadow: 0 10px 40px rgba(15, 23, 42, 0.14);
|
||
border: 1px solid ${COLOR_LINE};
|
||
min-width: 220px;
|
||
max-width: 280px;
|
||
}
|
||
.ar-ocr-mask-title {
|
||
font-size: 16px;
|
||
font-weight: 700;
|
||
color: ${COLOR_TEXT};
|
||
margin-top: 14px;
|
||
}
|
||
.ar-ocr-mask-desc {
|
||
font-size: 13px;
|
||
color: ${COLOR_MUTED};
|
||
margin-top: 8px;
|
||
line-height: 1.45;
|
||
}
|
||
.ar-operate-foot .ant-btn-primary[disabled] {
|
||
background: #c9cdd4 !important;
|
||
border-color: #c9cdd4 !important;
|
||
color: #fff !important;
|
||
opacity: 1;
|
||
}
|
||
.ar-section {
|
||
background: ${COLOR_BG};
|
||
border-radius: 14px;
|
||
padding: 14px 16px;
|
||
margin-bottom: 12px;
|
||
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.05);
|
||
border: 1px solid rgba(0,0,0,0.03);
|
||
}
|
||
.ar-section--form {
|
||
padding: 14px 0 8px;
|
||
}
|
||
.ar-section--form .ar-section-title {
|
||
padding: 0 20px 10px;
|
||
margin-bottom: 0;
|
||
}
|
||
.ar-section-title-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0 20px 10px;
|
||
margin-bottom: 0;
|
||
}
|
||
.ar-section-title-row .ar-section-title-text {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: ${COLOR_TEXT};
|
||
}
|
||
.ar-section-fold {
|
||
border: none;
|
||
background: transparent;
|
||
color: ${COLOR_PRIMARY_DEEP};
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
padding: 4px 0;
|
||
}
|
||
.ar-section-title {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: ${COLOR_TEXT};
|
||
margin-bottom: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.ar-section-title .required {
|
||
color: ${COLOR_DANGER};
|
||
font-size: 14px;
|
||
}
|
||
.ar-form-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
min-height: 52px;
|
||
box-sizing: border-box;
|
||
position: relative;
|
||
border-bottom: none;
|
||
}
|
||
.ar-form-row::after {
|
||
content: '';
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
height: 1px;
|
||
background: ${COLOR_LINE};
|
||
pointer-events: none;
|
||
}
|
||
.ar-form-row:last-child::after {
|
||
display: none;
|
||
}
|
||
.ar-form-label {
|
||
flex: 0 0 92px;
|
||
text-align: left;
|
||
font-size: 14px;
|
||
color: ${COLOR_MUTED};
|
||
font-weight: 500;
|
||
line-height: 1.35;
|
||
flex-shrink: 0;
|
||
}
|
||
.ar-form-label.required::before {
|
||
content: '*';
|
||
color: ${COLOR_DANGER};
|
||
margin-right: 2px;
|
||
}
|
||
.ar-form-control {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
align-items: center;
|
||
}
|
||
.ar-form-control .ant-input,
|
||
.ar-form-control .ant-select,
|
||
.ar-form-control .ant-input-number,
|
||
.ar-form-control .ant-picker {
|
||
width: 100% !important;
|
||
max-width: 100%;
|
||
}
|
||
.ar-form-control .ant-select-selector,
|
||
.ar-form-control .ant-input,
|
||
.ar-form-control .ant-input-number-input-wrap,
|
||
.ar-form-control .ant-input-number {
|
||
min-height: 32px !important;
|
||
}
|
||
/* 隐藏式输入:无边框,点击即录入,内容右对齐 */
|
||
.ar-form-control .ant-input,
|
||
.ar-form-control .ant-input-number,
|
||
.ar-form-control .ant-input-number-input-wrap,
|
||
.ar-form-control textarea.ant-input {
|
||
border: none !important;
|
||
box-shadow: none !important;
|
||
background: transparent !important;
|
||
text-align: right !important;
|
||
padding-left: 0 !important;
|
||
padding-right: 0 !important;
|
||
}
|
||
.ar-form-control .ant-input-number-input {
|
||
text-align: right !important;
|
||
}
|
||
.ar-form-control .ant-input::placeholder,
|
||
.ar-form-control .ant-input-number-input::placeholder,
|
||
.ar-form-control textarea.ant-input::placeholder {
|
||
color: ${COLOR_MUTED} !important;
|
||
opacity: 1;
|
||
}
|
||
.ar-form-control .ant-input::placeholder,
|
||
.ar-form-control .ant-input-number-input::placeholder {
|
||
text-align: right;
|
||
}
|
||
.ar-form-control .ant-input:focus,
|
||
.ar-form-control .ant-input-number-focused,
|
||
.ar-form-control .ant-input-number:focus-within,
|
||
.ar-form-control textarea.ant-input:focus {
|
||
box-shadow: none !important;
|
||
background: rgba(22, 209, 161, 0.06) !important;
|
||
border-radius: 6px !important;
|
||
}
|
||
.ar-form-control .ant-select {
|
||
text-align: right;
|
||
}
|
||
.ar-form-control .ant-select .ant-select-selector {
|
||
border: none !important;
|
||
box-shadow: none !important;
|
||
background: transparent !important;
|
||
padding-left: 4px !important;
|
||
padding-right: 28px !important;
|
||
display: flex !important;
|
||
align-items: center !important;
|
||
justify-content: flex-end !important;
|
||
}
|
||
.ar-form-control .ant-select .ant-select-arrow {
|
||
position: absolute;
|
||
right: 0;
|
||
top: 50%;
|
||
transform: translateY(-50%);
|
||
width: 24px;
|
||
margin: 0;
|
||
pointer-events: none;
|
||
flex-shrink: 0;
|
||
}
|
||
.ar-form-control .ant-select .ant-select-selection-wrap,
|
||
.ar-form-control .ant-select .ant-select-selection-overflow {
|
||
flex: 1;
|
||
min-width: 0;
|
||
justify-content: flex-end;
|
||
}
|
||
.ar-form-control .ant-select .ant-select-selection-search {
|
||
inset-inline-start: auto !important;
|
||
inset-inline-end: 28px !important;
|
||
left: auto !important;
|
||
right: 28px !important;
|
||
width: auto !important;
|
||
max-width: calc(100% - 28px);
|
||
}
|
||
.ar-form-control .ant-select .ant-select-selection-search-input {
|
||
text-align: right !important;
|
||
}
|
||
.ar-form-control .ant-select-selection-item,
|
||
.ar-form-control .ant-select-selection-placeholder,
|
||
.ar-form-control .ant-select .ant-select-selection-selected-value,
|
||
.ar-form-control .ant-select .ant-select-selection__placeholder {
|
||
flex: 1;
|
||
min-width: 0;
|
||
text-align: right !important;
|
||
padding-inline-end: 4px !important;
|
||
padding-right: 4px !important;
|
||
margin-inline-end: 0 !important;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
justify-content: flex-end;
|
||
}
|
||
.ar-form-control .ant-select-selection-placeholder,
|
||
.ar-form-control .ant-select .ant-select-selection__placeholder {
|
||
color: ${COLOR_MUTED} !important;
|
||
}
|
||
.ar-form-control .ant-select-focused .ant-select-selector {
|
||
background: rgba(22, 209, 161, 0.06) !important;
|
||
border-radius: 6px !important;
|
||
}
|
||
/* Ant Design 4 下拉兼容 */
|
||
.ar-form-control .ant-select-selection {
|
||
border: none !important;
|
||
box-shadow: none !important;
|
||
background: transparent !important;
|
||
padding-right: 28px !important;
|
||
}
|
||
.ar-form-control .ant-select-selection__rendered {
|
||
margin-left: 0 !important;
|
||
margin-right: 0 !important;
|
||
text-align: right;
|
||
}
|
||
.ar-form-control .ant-select-selection-selected-value,
|
||
.ar-form-control .ant-select-selection__placeholder {
|
||
float: right !important;
|
||
max-width: calc(100% - 4px);
|
||
padding-right: 4px;
|
||
text-align: right !important;
|
||
}
|
||
.ar-form-control .ant-select-arrow {
|
||
right: 0 !important;
|
||
}
|
||
.ar-form-control .ant-input-number-handler-wrap {
|
||
display: none;
|
||
}
|
||
.ar-form-readonly {
|
||
box-sizing: border-box;
|
||
width: 100%;
|
||
min-height: 32px;
|
||
padding: 4px 0;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
font-size: 14px;
|
||
line-height: 1.5715;
|
||
color: ${COLOR_TEXT};
|
||
font-weight: 500;
|
||
word-break: break-all;
|
||
text-align: right;
|
||
background: transparent;
|
||
}
|
||
.ar-form-readonly.plate {
|
||
font-size: 17px;
|
||
font-weight: 800;
|
||
color: ${COLOR_PRIMARY_DEEP};
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.ar-form-readonly--multiline {
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
line-height: 1.45;
|
||
}
|
||
.ar-operate-scroll .ar-section {
|
||
text-align: left;
|
||
}
|
||
.ar-form-grid {
|
||
display: flex;
|
||
flex-direction: column;
|
||
}
|
||
.ar-form-grid .ar-form-row {
|
||
padding-left: 20px;
|
||
padding-right: 20px;
|
||
}
|
||
.ar-form-grid .ar-form-row::after {
|
||
left: 20px;
|
||
right: 20px;
|
||
}
|
||
.ar-form-grid .ar-form-control .ant-input-affix-wrapper,
|
||
.ar-form-grid .ar-form-control textarea.ant-input {
|
||
resize: none;
|
||
}
|
||
.ar-form-grid .ar-form-row--textarea textarea.ant-input {
|
||
height: 32px !important;
|
||
min-height: 32px !important;
|
||
line-height: 22px !important;
|
||
padding-top: 4px !important;
|
||
padding-bottom: 4px !important;
|
||
}
|
||
.ar-form-row--remark-block {
|
||
min-height: 72px;
|
||
align-items: flex-start;
|
||
padding-top: 8px;
|
||
padding-bottom: 10px;
|
||
}
|
||
.ar-form-row--remark-block .ar-form-control {
|
||
width: 100%;
|
||
justify-content: stretch;
|
||
}
|
||
.ar-form-row--remark-block textarea.ant-input {
|
||
width: 100% !important;
|
||
min-height: 52px !important;
|
||
height: 52px !important;
|
||
line-height: 22px !important;
|
||
padding: 4px 0 !important;
|
||
text-align: left !important;
|
||
resize: none !important;
|
||
}
|
||
.ar-form-row--remark-block textarea.ant-input::placeholder {
|
||
color: ${COLOR_MUTED} !important;
|
||
text-align: left;
|
||
}
|
||
.ar-photo-block {
|
||
padding: 4px 20px 16px;
|
||
}
|
||
.ar-photo-block-title {
|
||
font-size: 14px;
|
||
font-weight: 500;
|
||
color: ${COLOR_MUTED};
|
||
margin-bottom: 10px;
|
||
text-align: left;
|
||
}
|
||
.ar-photo-block-title.required::before {
|
||
content: '*';
|
||
color: ${COLOR_DANGER};
|
||
margin-right: 2px;
|
||
}
|
||
.ar-photo-hint {
|
||
margin-top: 8px;
|
||
font-size: 12px;
|
||
color: ${COLOR_MUTED};
|
||
text-align: left;
|
||
line-height: 1.4;
|
||
}
|
||
.ar-photo-empty {
|
||
margin: 0 20px 8px;
|
||
padding: 28px 12px;
|
||
text-align: center;
|
||
font-size: 13px;
|
||
color: ${COLOR_MUTED};
|
||
background: #f7f8fa;
|
||
border-radius: 10px;
|
||
border: 1px dashed ${COLOR_LINE};
|
||
}
|
||
.ar-photo-gallery {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, 1fr);
|
||
gap: 10px;
|
||
}
|
||
.ar-photo-upload-cell {
|
||
display: block;
|
||
width: 100%;
|
||
min-width: 0;
|
||
}
|
||
.ar-photo-upload-cell .ant-upload {
|
||
display: block;
|
||
width: 100%;
|
||
}
|
||
.ar-photo-upload-cell .ant-upload-select {
|
||
display: block !important;
|
||
width: 100% !important;
|
||
height: auto !important;
|
||
margin: 0 !important;
|
||
border: none !important;
|
||
background: transparent !important;
|
||
}
|
||
.ar-photo-slot {
|
||
position: relative;
|
||
width: 100%;
|
||
aspect-ratio: 1;
|
||
border-radius: 8px;
|
||
box-sizing: border-box;
|
||
}
|
||
.ar-photo-slot--add {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border: 1px dashed ${COLOR_LINE};
|
||
background: #fafafa;
|
||
color: ${COLOR_MUTED};
|
||
cursor: pointer;
|
||
gap: 2px;
|
||
}
|
||
.ar-photo-slot--add:active {
|
||
border-color: ${COLOR_PRIMARY};
|
||
background: #f0fdf9;
|
||
}
|
||
.ar-photo-add-icon {
|
||
font-size: 22px;
|
||
line-height: 1;
|
||
font-weight: 300;
|
||
}
|
||
.ar-photo-add-text {
|
||
font-size: 12px;
|
||
line-height: 1.2;
|
||
}
|
||
.ar-photo-item {
|
||
overflow: visible;
|
||
}
|
||
.ar-photo-item-thumb {
|
||
width: 100%;
|
||
height: 100%;
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
border: none;
|
||
padding: 0;
|
||
background: ${COLOR_PAGE};
|
||
cursor: pointer;
|
||
}
|
||
.ar-photo-item-thumb img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
}
|
||
.ar-photo-del {
|
||
position: absolute;
|
||
top: -6px;
|
||
right: -6px;
|
||
width: 20px;
|
||
height: 20px;
|
||
border-radius: 50%;
|
||
border: 2px solid #fff;
|
||
background: rgba(0, 0, 0, 0.72);
|
||
color: #fff;
|
||
font-size: 14px;
|
||
line-height: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
cursor: pointer;
|
||
padding: 0;
|
||
z-index: 2;
|
||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
|
||
}
|
||
.ar-photo-del:active {
|
||
background: ${COLOR_DANGER};
|
||
}
|
||
.ar-photo-preview-img {
|
||
width: 100%;
|
||
max-height: 70vh;
|
||
object-fit: contain;
|
||
}
|
||
.ar-ocr-banner {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 12px;
|
||
margin-bottom: 12px;
|
||
border-radius: 8px;
|
||
font-size: 13px;
|
||
line-height: 1.4;
|
||
}
|
||
.ar-ocr-banner--done {
|
||
background: #f2f3f5;
|
||
color: ${COLOR_MUTED};
|
||
}
|
||
.ar-ocr-banner--error {
|
||
background: rgba(245, 63, 63, 0.08);
|
||
color: ${COLOR_DANGER};
|
||
}
|
||
.ar-ocr-dot {
|
||
width: 8px;
|
||
height: 8px;
|
||
border-radius: 50%;
|
||
background: ${COLOR_PRIMARY};
|
||
flex-shrink: 0;
|
||
animation: ar-ocr-pulse 1s ease-in-out infinite;
|
||
}
|
||
@keyframes ar-ocr-pulse {
|
||
0%, 100% { opacity: 0.35; transform: scale(0.85); }
|
||
50% { opacity: 1; transform: scale(1); }
|
||
}
|
||
.ar-form-control .ar-form-date-trigger {
|
||
width: 100% !important;
|
||
max-width: 100%;
|
||
min-height: 32px !important;
|
||
border: none !important;
|
||
box-shadow: none !important;
|
||
background: transparent !important;
|
||
text-align: right !important;
|
||
padding-left: 0 !important;
|
||
padding-right: 0 !important;
|
||
cursor: pointer;
|
||
caret-color: transparent;
|
||
}
|
||
.ar-form-control .ar-form-date-trigger::placeholder {
|
||
color: ${COLOR_MUTED} !important;
|
||
opacity: 1;
|
||
text-align: right;
|
||
}
|
||
.ar-form-control .ar-form-date-trigger:focus {
|
||
box-shadow: none !important;
|
||
background: rgba(22, 209, 161, 0.06) !important;
|
||
border-radius: 6px !important;
|
||
}
|
||
.ar-form-control .ar-form-date-trigger[disabled] {
|
||
color: ${COLOR_MUTED};
|
||
cursor: not-allowed;
|
||
opacity: 0.65;
|
||
}
|
||
.ar-add-btn {
|
||
width: 100%;
|
||
box-sizing: border-box;
|
||
min-height: 48px;
|
||
padding: 12px 16px;
|
||
margin-bottom: 12px;
|
||
background: ${COLOR_BG};
|
||
border-radius: 10px;
|
||
border: 1px dashed ${COLOR_LINE};
|
||
font-family: inherit;
|
||
appearance: none;
|
||
-webkit-appearance: none;
|
||
cursor: pointer;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
text-align: center;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: ${COLOR_TEXT};
|
||
line-height: 1.4;
|
||
transition: border-color 0.2s ease, background 0.2s ease;
|
||
}
|
||
.ar-add-btn:active {
|
||
background: #f7f8fa;
|
||
}
|
||
.ar-add-btn.filled {
|
||
border-style: solid;
|
||
border-color: rgba(22, 209, 161, 0.4);
|
||
color: ${COLOR_PRIMARY_DEEP};
|
||
}
|
||
.ar-add-btn-sub {
|
||
margin-top: 4px;
|
||
font-size: 12px;
|
||
font-weight: 400;
|
||
color: ${COLOR_MUTED};
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.ar-operate-foot {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
padding: 10px 14px calc(10px + env(safe-area-inset-bottom, 0px));
|
||
background: ${COLOR_BG};
|
||
border-top: 1px solid ${COLOR_LINE};
|
||
box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.06);
|
||
}
|
||
.ar-sheet-body {
|
||
padding: 4px 0 8px;
|
||
max-height: 70vh;
|
||
overflow-y: auto;
|
||
}
|
||
.ar-prd-doc {
|
||
font-size: 13px;
|
||
line-height: 1.7;
|
||
color: ${COLOR_TEXT};
|
||
}
|
||
.ar-prd-meta {
|
||
font-size: 12px;
|
||
color: ${COLOR_MUTED};
|
||
margin-bottom: 12px;
|
||
padding-bottom: 10px;
|
||
border-bottom: 1px solid ${COLOR_LINE};
|
||
}
|
||
.ar-prd-h2 {
|
||
font-size: 15px;
|
||
font-weight: 700;
|
||
color: ${COLOR_TEXT};
|
||
margin: 18px 0 8px;
|
||
}
|
||
.ar-prd-h2:first-of-type {
|
||
margin-top: 4px;
|
||
}
|
||
.ar-prd-h3 {
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: ${COLOR_TEXT};
|
||
margin: 12px 0 6px;
|
||
}
|
||
.ar-prd-p {
|
||
margin: 0 0 8px;
|
||
color: ${COLOR_TEXT};
|
||
}
|
||
.ar-prd-ul, .ar-prd-ol {
|
||
margin: 0 0 10px;
|
||
padding-left: 20px;
|
||
}
|
||
.ar-prd-li {
|
||
margin-bottom: 5px;
|
||
}
|
||
.ar-prd-table-wrap {
|
||
overflow-x: auto;
|
||
margin: 8px 0 12px;
|
||
}
|
||
.ar-prd-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
font-size: 12px;
|
||
}
|
||
.ar-prd-table th,
|
||
.ar-prd-table td {
|
||
border: 1px solid ${COLOR_LINE};
|
||
padding: 6px 8px;
|
||
text-align: left;
|
||
vertical-align: top;
|
||
}
|
||
.ar-prd-table th {
|
||
background: #f7f8fa;
|
||
font-weight: 600;
|
||
white-space: nowrap;
|
||
}
|
||
.ar-prd-footnote {
|
||
margin-top: 14px;
|
||
padding-top: 10px;
|
||
border-top: 1px solid ${COLOR_LINE};
|
||
font-size: 12px;
|
||
color: ${COLOR_MUTED};
|
||
line-height: 1.55;
|
||
}
|
||
.ar-prd-highlight {
|
||
margin: 14px 0;
|
||
padding: 12px 14px;
|
||
background: rgba(22, 209, 161, 0.08);
|
||
border: 1px solid rgba(22, 209, 161, 0.35);
|
||
border-radius: 10px;
|
||
}
|
||
.ar-prd-highlight-title {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: ${COLOR_PRIMARY_DEEP};
|
||
margin-bottom: 8px;
|
||
}
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.ar-card, .ar-filter-btn, .ar-tab { transition: none; }
|
||
}
|
||
`;
|
||
|
||
const XLL_GREEN = '#7AB929';
|
||
const XLL_GREEN_DEEP = '#6AA322';
|
||
const XLL_GREEN_SOFT = 'rgba(122, 185, 41, 0.14)';
|
||
|
||
const EMBED_STYLE = `
|
||
.ar-embed-root {
|
||
flex: 1;
|
||
min-height: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
background: ${COLOR_PAGE};
|
||
position: relative;
|
||
}
|
||
.ar-embed-root .ar-tabs,
|
||
.ar-embed-root .ar-search-row { flex-shrink: 0; }
|
||
.ar-embed-root .ar-list,
|
||
.ar-embed-root .ar-operate-scroll,
|
||
.ar-embed-root .ar-history-scroll { flex: 1; min-height: 0; }
|
||
`;
|
||
|
||
const XLL_THEME_PATCH = `
|
||
.xll-module-theme .ar-tab.active { color: ${XLL_GREEN}; }
|
||
.xll-module-theme .ar-tab.active::after { background: ${XLL_GREEN}; }
|
||
.xll-module-theme .ar-filter-btn:active { border-color: ${XLL_GREEN}; color: ${XLL_GREEN}; }
|
||
.xll-module-theme .ar-card-action { color: ${XLL_GREEN}; }
|
||
.xll-module-theme .ar-add-btn { color: ${XLL_GREEN_DEEP}; }
|
||
.xll-module-theme .ar-mp-link { color: ${XLL_GREEN_DEEP}; }
|
||
.xll-module-theme .ar-form-control .ant-select-focused .ant-select-selector {
|
||
background: ${XLL_GREEN_SOFT} !important;
|
||
}
|
||
.xll-module-theme .ar-operate-foot .ant-btn-primary {
|
||
background: linear-gradient(135deg, ${XLL_GREEN} 0%, ${XLL_GREEN_DEEP} 100%) !important;
|
||
border: none !important;
|
||
}
|
||
`;
|
||
|
||
const IconFilter = () => (
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={COLOR_PRIMARY_DEEP} strokeWidth="2" strokeLinecap="round">
|
||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3" />
|
||
</svg>
|
||
);
|
||
|
||
const IconChevron = () => (
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||
<polyline points="9 18 15 12 9 6" />
|
||
</svg>
|
||
);
|
||
|
||
const IconBack = () => (
|
||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||
<polyline points="15 18 9 12 15 6" />
|
||
</svg>
|
||
);
|
||
|
||
const IconStatusSignal = () => (
|
||
<svg width="18" height="12" viewBox="0 0 18 12" fill="currentColor" aria-hidden="true">
|
||
<rect x="0" y="7" width="3" height="5" rx="0.5" opacity="0.35" />
|
||
<rect x="5" y="5" width="3" height="7" rx="0.5" opacity="0.55" />
|
||
<rect x="10" y="2" width="3" height="10" rx="0.5" opacity="0.75" />
|
||
<rect x="15" y="0" width="3" height="12" rx="0.5" />
|
||
</svg>
|
||
);
|
||
|
||
const IconStatusWifi = () => (
|
||
<svg width="16" height="12" viewBox="0 0 16 12" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true">
|
||
<path d="M8 10.5a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" fill="currentColor" stroke="none" />
|
||
<path d="M4.5 7.2a4.8 4.8 0 0 1 7 0" strokeLinecap="round" />
|
||
<path d="M1.5 4.2a8.5 8.5 0 0 1 13 0" strokeLinecap="round" />
|
||
</svg>
|
||
);
|
||
|
||
const IconStatusBattery = () => (
|
||
<svg width="26" height="12" viewBox="0 0 26 12" fill="none" aria-hidden="true">
|
||
<rect x="0.5" y="0.5" width="21" height="11" rx="2.5" stroke="currentColor" strokeOpacity="0.45" />
|
||
<rect x="2.5" y="2.5" width="16" height="7" rx="1.5" fill="currentColor" />
|
||
<path d="M23 4.5v3c.8-.45 1.3-1.15 1.3-1.5S23.8 4.95 23 4.5Z" fill="currentColor" fillOpacity="0.45" />
|
||
</svg>
|
||
);
|
||
|
||
const IphoneStatusBar = () => (
|
||
<div className="ar-status-bar">
|
||
<span className="ar-status-time">9:41</span>
|
||
<div className="ar-status-icons">
|
||
<IconStatusSignal />
|
||
<IconStatusWifi />
|
||
<IconStatusBattery />
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const MpCapsule = ({ onMore, onExit }) => (
|
||
<div className="ar-mp-capsule" role="group" aria-label="小程序菜单">
|
||
<button type="button" className="ar-mp-capsule-btn ar-mp-capsule-btn--more" onClick={onMore} aria-label="更多">
|
||
···
|
||
</button>
|
||
<span className="ar-mp-capsule-divider" aria-hidden="true" />
|
||
<button type="button" className="ar-mp-capsule-btn" onClick={onExit} aria-label="关闭小程序">
|
||
<span className="ar-mp-capsule-close-icon">×</span>
|
||
</button>
|
||
</div>
|
||
);
|
||
|
||
const MpNavBar = ({ title, showBack, onBack, showPrdLink, onPrdClick, onMore, onExit }) => (
|
||
<div className="ar-mp-navbar">
|
||
<div className="ar-mp-navbar-left">
|
||
{showBack ? (
|
||
<button type="button" className="ar-mp-back" onClick={onBack} aria-label="返回">
|
||
<IconBack />
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
<div className="ar-mp-navbar-title">{title}</div>
|
||
<div className="ar-mp-navbar-right">
|
||
{showPrdLink && (
|
||
<button type="button" className="ar-mp-link" onClick={onPrdClick}>
|
||
需求说明
|
||
</button>
|
||
)}
|
||
<MpCapsule onMore={onMore} onExit={onExit} />
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const MiniProgramChrome = ({ title, showBack, onBack, showPrdLink, onPrdClick }) => {
|
||
const handleMore = () => message.info('更多菜单(原型)');
|
||
const handleExit = () => message.warning('退出小程序(原型)');
|
||
|
||
return (
|
||
<div className="ar-chrome">
|
||
<IphoneStatusBar />
|
||
<MpNavBar
|
||
title={title}
|
||
showBack={showBack}
|
||
onBack={onBack}
|
||
showPrdLink={showPrdLink}
|
||
onPrdClick={onPrdClick}
|
||
onMore={handleMore}
|
||
onExit={handleExit}
|
||
/>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const FormField = ({ label, required, rowClassName, children }) => (
|
||
<div className={`ar-form-row${rowClassName ? ` ${rowClassName}` : ''}`}>
|
||
<span className={`ar-form-label${required ? ' required' : ''}`}>{label}</span>
|
||
<div className="ar-form-control">{children}</div>
|
||
</div>
|
||
);
|
||
|
||
const InfoRow = ({ label, children, valueClassName }) => (
|
||
<div className="ar-form-row">
|
||
<span className="ar-form-label">{label}</span>
|
||
<div className="ar-form-control">
|
||
<div className={`ar-form-readonly${valueClassName ? ` ${valueClassName}` : ''}`}>{children}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
const PlateKeyboardPanel = ({ open, value, onChange, onConfirm, onClose }) => {
|
||
const [activeIndex, setActiveIndex] = useState(0);
|
||
const plate = (value || '').toUpperCase();
|
||
|
||
useEffect(() => {
|
||
if (open) {
|
||
setActiveIndex(Math.min(plate.length, MAX_PLATE_INPUT_LEN - 1));
|
||
}
|
||
}, [open, plate.length]);
|
||
|
||
if (!open) return null;
|
||
|
||
const keys = getPlateKeysForIndex(activeIndex);
|
||
|
||
const handleKey = (key) => {
|
||
const arr = plate.split('');
|
||
arr[activeIndex] = key;
|
||
const next = arr.join('').slice(0, MAX_PLATE_INPUT_LEN);
|
||
onChange(next);
|
||
if (activeIndex < MAX_PLATE_INPUT_LEN - 1) {
|
||
setActiveIndex(activeIndex + 1);
|
||
}
|
||
};
|
||
|
||
const handleDelete = () => {
|
||
if (!plate.length) return;
|
||
const next = plate.slice(0, -1);
|
||
onChange(next);
|
||
setActiveIndex(Math.max(0, next.length));
|
||
};
|
||
|
||
const cells = [];
|
||
for (let i = 0; i < MAX_PLATE_INPUT_LEN; i += 1) {
|
||
if (i === 2) {
|
||
cells.push(<span key="sep" className="ar-plate-cell-sep" aria-hidden="true" />);
|
||
}
|
||
cells.push(
|
||
<button
|
||
key={`cell-${i}`}
|
||
type="button"
|
||
className={`ar-plate-cell${activeIndex === i ? ' active' : ''}${plate[i] ? ' filled' : ''}`}
|
||
onClick={() => setActiveIndex(i)}
|
||
aria-label={`车牌第${i + 1}位`}
|
||
>
|
||
{plate[i] || ''}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<div className="ar-plate-kb-mask" role="presentation" onClick={onClose} />
|
||
<div className="ar-plate-kb" role="dialog" aria-label="车牌号输入">
|
||
<div className="ar-plate-kb-head">
|
||
<div className="ar-plate-kb-title">请输入车牌号</div>
|
||
<div className="ar-plate-cells">{cells}</div>
|
||
</div>
|
||
<div className="ar-plate-kb-toolbar">
|
||
<button type="button" className="ar-plate-kb-tool ar-plate-kb-tool--delete" onClick={handleDelete}>
|
||
删除
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="ar-plate-kb-tool ar-plate-kb-tool--ok"
|
||
onClick={() => onConfirm(plate)}
|
||
>
|
||
完成
|
||
</button>
|
||
</div>
|
||
<div className="ar-plate-keys">
|
||
{keys.map((key) => (
|
||
<button key={`${activeIndex}-${key}`} type="button" className="ar-plate-key" onClick={() => handleKey(key)}>
|
||
{key}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</>
|
||
);
|
||
};
|
||
|
||
const PhotoUploadBlock = ({ label, fileList, onChange, maxPhotos = MAX_SERVICE_PHOTOS, required }) => {
|
||
const list = fileList || [];
|
||
const [previewOpen, setPreviewOpen] = useState(false);
|
||
const [previewUrl, setPreviewUrl] = useState('');
|
||
const canUpload = list.length < maxPhotos;
|
||
|
||
const handleChange = ({ fileList: nextList }) => {
|
||
onChange((nextList || []).slice(0, maxPhotos));
|
||
};
|
||
|
||
const handleRemove = (uid, e) => {
|
||
e.stopPropagation();
|
||
e.preventDefault();
|
||
onChange(list.filter((f) => f.uid !== uid));
|
||
};
|
||
|
||
const handlePreview = (file) => {
|
||
const url = getUploadPreviewUrl(file);
|
||
if (!url) return;
|
||
if (Image && typeof Image.preview === 'function') {
|
||
Image.preview({ src: url });
|
||
return;
|
||
}
|
||
setPreviewUrl(url);
|
||
setPreviewOpen(true);
|
||
};
|
||
|
||
return (
|
||
<div className="ar-photo-block">
|
||
<div className={`ar-photo-block-title${required ? ' required' : ''}`}>{label}</div>
|
||
<div className="ar-photo-gallery">
|
||
{canUpload && Upload ? (
|
||
<Upload
|
||
className="ar-photo-upload-cell"
|
||
showUploadList={false}
|
||
multiple
|
||
accept="image/*"
|
||
fileList={list}
|
||
beforeUpload={() => false}
|
||
onChange={handleChange}
|
||
>
|
||
<div className="ar-photo-slot ar-photo-slot--add">
|
||
<span className="ar-photo-add-icon">+</span>
|
||
<span className="ar-photo-add-text">上传</span>
|
||
</div>
|
||
</Upload>
|
||
) : null}
|
||
{list.map((file) => {
|
||
const url = getUploadPreviewUrl(file);
|
||
return (
|
||
<div key={file.uid} className="ar-photo-slot ar-photo-item">
|
||
<button
|
||
type="button"
|
||
className="ar-photo-item-thumb"
|
||
onClick={() => handlePreview(file)}
|
||
aria-label="预览照片"
|
||
>
|
||
{url ? <img src={url} alt="" /> : <span style={{ fontSize: 12, color: COLOR_MUTED }}>图片</span>}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="ar-photo-del"
|
||
aria-label="删除照片"
|
||
onClick={(e) => handleRemove(file.uid, e)}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
<div className="ar-photo-hint">最多上传 {maxPhotos} 张,支持批量选择</div>
|
||
<Modal
|
||
open={previewOpen}
|
||
footer={null}
|
||
onCancel={() => setPreviewOpen(false)}
|
||
centered
|
||
width="92%"
|
||
styles={{ body: { padding: 8, textAlign: 'center' } }}
|
||
destroyOnClose
|
||
>
|
||
{previewUrl ? <img className="ar-photo-preview-img" src={previewUrl} alt="预览" /> : null}
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/** 历史记录只读照片(仅预览,与办理页照片网格一致) */
|
||
const PhotoReadonlyGallery = ({ label, fileList, emptyText }) => {
|
||
const list = fileList || [];
|
||
const [previewOpen, setPreviewOpen] = useState(false);
|
||
const [previewUrl, setPreviewUrl] = useState('');
|
||
|
||
const handlePreview = (file) => {
|
||
const url = getUploadPreviewUrl(file);
|
||
if (!url) return;
|
||
if (Image && typeof Image.preview === 'function') {
|
||
Image.preview({ src: url });
|
||
return;
|
||
}
|
||
setPreviewUrl(url);
|
||
setPreviewOpen(true);
|
||
};
|
||
|
||
return (
|
||
<div className="ar-photo-block">
|
||
<div className="ar-photo-block-title">{label}</div>
|
||
{list.length > 0 ? (
|
||
<div className="ar-photo-gallery">
|
||
{list.map((file) => {
|
||
const url = getUploadPreviewUrl(file);
|
||
return (
|
||
<div key={file.uid} className="ar-photo-slot ar-photo-item">
|
||
<button
|
||
type="button"
|
||
className="ar-photo-item-thumb"
|
||
onClick={() => handlePreview(file)}
|
||
aria-label="预览照片"
|
||
>
|
||
{url ? <img src={url} alt="" /> : <span style={{ fontSize: 12, color: COLOR_MUTED }}>图片</span>}
|
||
</button>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className="ar-photo-empty">{emptyText || '暂无照片'}</div>
|
||
)}
|
||
<Modal
|
||
open={previewOpen}
|
||
footer={null}
|
||
onCancel={() => setPreviewOpen(false)}
|
||
centered
|
||
width="92%"
|
||
styles={{ body: { padding: 8, textAlign: 'center' } }}
|
||
destroyOnClose
|
||
>
|
||
{previewUrl ? <img className="ar-photo-preview-img" src={previewUrl} alt="预览" /> : null}
|
||
</Modal>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const FilterRow = ({ label, children }) => (
|
||
<div className="ar-filter-row">
|
||
<span className="ar-filter-label">{label}</span>
|
||
<div className="ar-filter-control">{children}</div>
|
||
</div>
|
||
);
|
||
|
||
/** 小程序风格省市级联:底部抽屉 + 双列选择 */
|
||
const MiniRegionPicker = ({ value, onChange, placeholder }) => {
|
||
const [open, setOpen] = useState(false);
|
||
const [draftProvince, setDraftProvince] = useState(null);
|
||
const [draftCity, setDraftCity] = useState(null);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
const province = value?.[0] || PROVINCE_LIST[0];
|
||
setDraftProvince(province);
|
||
const cities = PROVINCE_CITY_MAP[province] || [];
|
||
const city = value?.[1] && cities.includes(value[1]) ? value[1] : cities[0] || null;
|
||
setDraftCity(city);
|
||
}, [open, value]);
|
||
|
||
const cityList = draftProvince ? PROVINCE_CITY_MAP[draftProvince] || [] : [];
|
||
const displayText = formatProvinceCity(value);
|
||
|
||
const handleProvincePick = (province) => {
|
||
setDraftProvince(province);
|
||
const cities = PROVINCE_CITY_MAP[province] || [];
|
||
setDraftCity(cities[0] || null);
|
||
};
|
||
|
||
const handleConfirm = () => {
|
||
if (!draftProvince) {
|
||
setOpen(false);
|
||
return;
|
||
}
|
||
const city = draftCity || (PROVINCE_CITY_MAP[draftProvince] || [])[0];
|
||
onChange(city ? [draftProvince, city] : [draftProvince]);
|
||
setOpen(false);
|
||
};
|
||
|
||
const handleClear = () => {
|
||
onChange(null);
|
||
setOpen(false);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<Input
|
||
className="ar-region-trigger"
|
||
readOnly
|
||
value={displayText}
|
||
placeholder={placeholder || '请选择省市'}
|
||
onClick={() => setOpen(true)}
|
||
onFocus={(e) => e.target.blur()}
|
||
/>
|
||
<Drawer
|
||
title="选择运营区域"
|
||
placement="bottom"
|
||
height={360}
|
||
open={open}
|
||
onClose={() => setOpen(false)}
|
||
styles={{ body: { padding: '12px 16px 0' } }}
|
||
footer={
|
||
<div className="ar-drawer-foot">
|
||
<Button block size="large" onClick={handleClear} style={{ height: 46, borderRadius: 8 }}>
|
||
清空
|
||
</Button>
|
||
<Button
|
||
block
|
||
type="primary"
|
||
size="large"
|
||
onClick={handleConfirm}
|
||
style={{
|
||
height: 46,
|
||
borderRadius: 8,
|
||
background: `linear-gradient(135deg, ${COLOR_PRIMARY} 0%, ${COLOR_PRIMARY_DEEP} 100%)`,
|
||
border: 'none'
|
||
}}
|
||
>
|
||
确定
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="ar-region-drawer-hint">先选省份,再选城市</div>
|
||
<div className="ar-region-picker">
|
||
<div className="ar-region-col" role="listbox" aria-label="省份">
|
||
{PROVINCE_LIST.map((p) => (
|
||
<button
|
||
key={p}
|
||
type="button"
|
||
className={`ar-region-item${draftProvince === p ? ' active' : ''}`}
|
||
onClick={() => handleProvincePick(p)}
|
||
>
|
||
{p}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="ar-region-col" role="listbox" aria-label="城市">
|
||
{cityList.map((c) => (
|
||
<button
|
||
key={c}
|
||
type="button"
|
||
className={`ar-region-item${draftCity === c ? ' active' : ''}`}
|
||
onClick={() => setDraftCity(c)}
|
||
>
|
||
{c}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</Drawer>
|
||
</>
|
||
);
|
||
};
|
||
|
||
/** 小程序风格单选(办理人等) */
|
||
const MiniOptionPicker = ({ value, onChange, options, placeholder, title, clearLabel }) => {
|
||
const [open, setOpen] = useState(false);
|
||
const [draft, setDraft] = useState(value ?? '');
|
||
|
||
useEffect(() => {
|
||
if (open) setDraft(value ?? '');
|
||
}, [open, value]);
|
||
|
||
const selected = options.find((o) => o.value === (value ?? ''));
|
||
const displayText = selected && selected.value !== '' ? selected.label : '';
|
||
|
||
const handleConfirm = () => {
|
||
onChange(draft);
|
||
setOpen(false);
|
||
};
|
||
|
||
const handleClear = () => {
|
||
onChange('');
|
||
setOpen(false);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<Input
|
||
className="ar-mini-trigger"
|
||
readOnly
|
||
value={displayText}
|
||
placeholder={placeholder || '请选择'}
|
||
onClick={() => setOpen(true)}
|
||
onFocus={(e) => e.target.blur()}
|
||
/>
|
||
<Drawer
|
||
title={title || '请选择'}
|
||
placement="bottom"
|
||
height={320}
|
||
open={open}
|
||
onClose={() => setOpen(false)}
|
||
styles={{ body: { padding: '12px 16px 0' } }}
|
||
footer={
|
||
<div className="ar-drawer-foot">
|
||
<Button block size="large" onClick={handleClear} style={{ height: 46, borderRadius: 8 }}>
|
||
{clearLabel || '全部'}
|
||
</Button>
|
||
<Button
|
||
block
|
||
type="primary"
|
||
size="large"
|
||
onClick={handleConfirm}
|
||
style={{
|
||
height: 46,
|
||
borderRadius: 8,
|
||
background: `linear-gradient(135deg, ${COLOR_PRIMARY} 0%, ${COLOR_PRIMARY_DEEP} 100%)`,
|
||
border: 'none'
|
||
}}
|
||
>
|
||
确定
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="ar-picker-single" role="listbox">
|
||
{options.map((opt) => (
|
||
<button
|
||
key={opt.value || '__all__'}
|
||
type="button"
|
||
className={`ar-region-item${draft === opt.value ? ' active' : ''}`}
|
||
onClick={() => setDraft(opt.value)}
|
||
>
|
||
{opt.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</Drawer>
|
||
</>
|
||
);
|
||
};
|
||
|
||
const WHEEL_ITEM_H = 40;
|
||
const DATE_YEAR_MIN = 2018;
|
||
const DATE_YEAR_MAX = 2032;
|
||
|
||
const getDaysInMonth = (year, month) => new Date(year, month, 0).getDate();
|
||
|
||
const todayDateParts = () => {
|
||
const t = new Date();
|
||
return { year: t.getFullYear(), month: t.getMonth() + 1, day: t.getDate() };
|
||
};
|
||
|
||
const partsFromMomentValue = (val) => {
|
||
if (val && moment) {
|
||
const m = moment(val);
|
||
if (m.isValid()) return { year: m.year(), month: m.month() + 1, day: m.date() };
|
||
}
|
||
return todayDateParts();
|
||
};
|
||
|
||
const clampDateParts = (parts) => {
|
||
const maxDay = getDaysInMonth(parts.year, parts.month);
|
||
return { ...parts, day: Math.min(parts.day, maxDay) };
|
||
};
|
||
|
||
const partsToMoment = (parts) => {
|
||
if (!moment || !parts) return null;
|
||
const p = clampDateParts(parts);
|
||
const s = `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`;
|
||
const m = moment(s, 'YYYY-MM-DD');
|
||
return m.isValid() ? m : null;
|
||
};
|
||
|
||
const formatPartsLabel = (parts) => {
|
||
const p = clampDateParts(parts);
|
||
return `${p.year}-${String(p.month).padStart(2, '0')}-${String(p.day).padStart(2, '0')}`;
|
||
};
|
||
|
||
/** 微信小程序风格滚轮列(年 / 月 / 日) */
|
||
const DateWheelColumn = ({ items, value, onChange }) => {
|
||
const colRef = useRef(null);
|
||
const scrollTimer = useRef(null);
|
||
|
||
const scrollToValue = useCallback(
|
||
(v, smooth) => {
|
||
const el = colRef.current;
|
||
if (!el) return;
|
||
const idx = items.indexOf(v);
|
||
if (idx < 0) return;
|
||
el.scrollTo({ top: idx * WHEEL_ITEM_H, behavior: smooth ? 'smooth' : 'auto' });
|
||
},
|
||
[items]
|
||
);
|
||
|
||
useEffect(() => {
|
||
scrollToValue(value, false);
|
||
}, [value, items, scrollToValue]);
|
||
|
||
const handleScroll = () => {
|
||
const el = colRef.current;
|
||
if (!el) return;
|
||
if (scrollTimer.current) clearTimeout(scrollTimer.current);
|
||
scrollTimer.current = setTimeout(() => {
|
||
const idx = Math.round(el.scrollTop / WHEEL_ITEM_H);
|
||
const clamped = Math.max(0, Math.min(idx, items.length - 1));
|
||
const next = items[clamped];
|
||
if (next !== value) onChange(next);
|
||
scrollToValue(next, true);
|
||
}, 80);
|
||
};
|
||
|
||
return (
|
||
<div className="ar-date-wheel-col" ref={colRef} onScroll={handleScroll}>
|
||
<div className="ar-date-wheel-pad" aria-hidden="true" />
|
||
{items.map((item) => (
|
||
<div key={item} className={`ar-date-wheel-item${item === value ? ' active' : ''}`}>
|
||
{item}
|
||
</div>
|
||
))}
|
||
<div className="ar-date-wheel-pad" aria-hidden="true" />
|
||
</div>
|
||
);
|
||
};
|
||
|
||
/** 小程序风格单日选择(滚轮,精确到日) */
|
||
const MiniSingleDatePicker = ({
|
||
value,
|
||
onChange,
|
||
placeholder,
|
||
title,
|
||
hint,
|
||
disabled,
|
||
inForm
|
||
}) => {
|
||
const [open, setOpen] = useState(false);
|
||
const [draft, setDraft] = useState(todayDateParts);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setDraft(partsFromMomentValue(value));
|
||
}, [open, value]);
|
||
|
||
const years = useMemo(
|
||
() => Array.from({ length: DATE_YEAR_MAX - DATE_YEAR_MIN + 1 }, (_, i) => DATE_YEAR_MIN + i),
|
||
[]
|
||
);
|
||
const months = useMemo(() => Array.from({ length: 12 }, (_, i) => i + 1), []);
|
||
const days = useMemo(() => {
|
||
const max = getDaysInMonth(draft.year, draft.month);
|
||
return Array.from({ length: max }, (_, i) => i + 1);
|
||
}, [draft.year, draft.month]);
|
||
|
||
useEffect(() => {
|
||
const max = getDaysInMonth(draft.year, draft.month);
|
||
if (draft.day <= max) return;
|
||
setDraft((p) => ({ ...p, day: max }));
|
||
}, [draft.year, draft.month, draft.day]);
|
||
|
||
const displayText = formatSingleDateDisplay(value);
|
||
|
||
const handleConfirm = () => {
|
||
const m = partsToMoment(draft);
|
||
if (!m) {
|
||
message.warning('请选择日期');
|
||
return;
|
||
}
|
||
onChange(m.format('YYYY-MM-DD'));
|
||
setOpen(false);
|
||
};
|
||
|
||
const handleClear = () => {
|
||
onChange(null);
|
||
setOpen(false);
|
||
};
|
||
|
||
const inputCls = inForm ? 'ar-form-date-trigger' : 'ar-mini-trigger';
|
||
|
||
return (
|
||
<>
|
||
<Input
|
||
className={inputCls}
|
||
bordered={false}
|
||
readOnly
|
||
disabled={disabled}
|
||
value={displayText}
|
||
placeholder={placeholder || '请选择日期'}
|
||
onClick={() => {
|
||
if (disabled) return;
|
||
setOpen(true);
|
||
}}
|
||
onFocus={(e) => e.target.blur()}
|
||
/>
|
||
<Drawer
|
||
title={title || '选择日期'}
|
||
placement="bottom"
|
||
height={380}
|
||
open={open}
|
||
onClose={() => setOpen(false)}
|
||
styles={{ body: { padding: '12px 16px 0' } }}
|
||
footer={
|
||
<div className="ar-drawer-foot">
|
||
<Button block size="large" onClick={handleClear} style={{ height: 46, borderRadius: 8 }}>
|
||
清空
|
||
</Button>
|
||
<Button
|
||
block
|
||
type="primary"
|
||
size="large"
|
||
onClick={handleConfirm}
|
||
style={{
|
||
height: 46,
|
||
borderRadius: 8,
|
||
background: `linear-gradient(135deg, ${COLOR_PRIMARY} 0%, ${COLOR_PRIMARY_DEEP} 100%)`,
|
||
border: 'none'
|
||
}}
|
||
>
|
||
确定
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="ar-region-drawer-hint">{hint || '滑动滚轮选择年月日'}</div>
|
||
<div className="ar-date-wheel-wrap" key={`${draft.year}-${draft.month}`}>
|
||
<div className="ar-date-wheel-fade top" aria-hidden="true" />
|
||
<div className="ar-date-wheel-fade bottom" aria-hidden="true" />
|
||
<div className="ar-date-wheel-highlight" aria-hidden="true" />
|
||
<span className="ar-date-wheel-unit y">年</span>
|
||
<span className="ar-date-wheel-unit m">月</span>
|
||
<span className="ar-date-wheel-unit d">日</span>
|
||
<DateWheelColumn items={years} value={draft.year} onChange={(y) => setDraft((p) => clampDateParts({ ...p, year: y }))} />
|
||
<DateWheelColumn
|
||
items={months}
|
||
value={draft.month}
|
||
onChange={(mo) => setDraft((p) => clampDateParts({ ...p, month: mo }))}
|
||
/>
|
||
<DateWheelColumn items={days} value={draft.day} onChange={(d) => setDraft((p) => ({ ...p, day: d }))} />
|
||
</div>
|
||
</Drawer>
|
||
</>
|
||
);
|
||
};
|
||
|
||
/** 小程序风格开始-结束日期(滚轮,精确到日) */
|
||
const MiniDateRangePicker = ({ value, onChange, placeholder, title, hint }) => {
|
||
const [open, setOpen] = useState(false);
|
||
const [activeEdge, setActiveEdge] = useState('start');
|
||
const [draftStart, setDraftStart] = useState(todayDateParts);
|
||
const [draftEnd, setDraftEnd] = useState(todayDateParts);
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setActiveEdge('start');
|
||
setDraftStart(partsFromMomentValue(value?.[0]));
|
||
setDraftEnd(partsFromMomentValue(value?.[1]));
|
||
}, [open, value]);
|
||
|
||
const activeParts = activeEdge === 'start' ? draftStart : draftEnd;
|
||
|
||
const years = useMemo(
|
||
() => Array.from({ length: DATE_YEAR_MAX - DATE_YEAR_MIN + 1 }, (_, i) => DATE_YEAR_MIN + i),
|
||
[]
|
||
);
|
||
const months = useMemo(() => Array.from({ length: 12 }, (_, i) => i + 1), []);
|
||
const days = useMemo(() => {
|
||
const max = getDaysInMonth(activeParts.year, activeParts.month);
|
||
return Array.from({ length: max }, (_, i) => i + 1);
|
||
}, [activeParts.year, activeParts.month]);
|
||
|
||
useEffect(() => {
|
||
const max = getDaysInMonth(activeParts.year, activeParts.month);
|
||
if (activeParts.day <= max) return;
|
||
if (activeEdge === 'start') setDraftStart((p) => ({ ...p, day: max }));
|
||
else setDraftEnd((p) => ({ ...p, day: max }));
|
||
}, [activeParts.year, activeParts.month, activeParts.day, activeEdge]);
|
||
|
||
const patchActive = (patch) => {
|
||
const next = clampDateParts({ ...activeParts, ...patch });
|
||
if (activeEdge === 'start') setDraftStart(next);
|
||
else setDraftEnd(next);
|
||
};
|
||
|
||
const displayText = formatDateRangeDisplay(value);
|
||
|
||
const handleConfirm = () => {
|
||
const startM = partsToMoment(draftStart);
|
||
const endM = partsToMoment(draftEnd);
|
||
if (!startM || !endM) {
|
||
message.warning('请选择开始与结束日期');
|
||
return;
|
||
}
|
||
if (moment && moment(endM).isBefore(moment(startM), 'day')) {
|
||
message.warning('结束日期不能早于开始日期');
|
||
return;
|
||
}
|
||
onChange([startM, endM]);
|
||
setOpen(false);
|
||
};
|
||
|
||
const handleClear = () => {
|
||
onChange(null);
|
||
setOpen(false);
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<Input
|
||
className="ar-mini-trigger"
|
||
readOnly
|
||
value={displayText}
|
||
placeholder={placeholder || '请选择开始-结束日期'}
|
||
onClick={() => setOpen(true)}
|
||
onFocus={(e) => e.target.blur()}
|
||
/>
|
||
<Drawer
|
||
title={title || '选择日期范围'}
|
||
placement="bottom"
|
||
height={420}
|
||
open={open}
|
||
onClose={() => setOpen(false)}
|
||
styles={{ body: { padding: '12px 16px 0' } }}
|
||
footer={
|
||
<div className="ar-drawer-foot">
|
||
<Button block size="large" onClick={handleClear} style={{ height: 46, borderRadius: 8 }}>
|
||
清空
|
||
</Button>
|
||
<Button
|
||
block
|
||
type="primary"
|
||
size="large"
|
||
onClick={handleConfirm}
|
||
style={{
|
||
height: 46,
|
||
borderRadius: 8,
|
||
background: `linear-gradient(135deg, ${COLOR_PRIMARY} 0%, ${COLOR_PRIMARY_DEEP} 100%)`,
|
||
border: 'none'
|
||
}}
|
||
>
|
||
确定
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="ar-region-drawer-hint">
|
||
{hint || '切换开始/结束,滑动滚轮选择日期(精确到日)'}
|
||
</div>
|
||
<div className="ar-date-range-tabs" role="tablist">
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={activeEdge === 'start'}
|
||
className={`ar-date-range-tab${activeEdge === 'start' ? ' active' : ''}`}
|
||
onClick={() => setActiveEdge('start')}
|
||
>
|
||
开始
|
||
<em>{formatPartsLabel(draftStart)}</em>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={activeEdge === 'end'}
|
||
className={`ar-date-range-tab${activeEdge === 'end' ? ' active' : ''}`}
|
||
onClick={() => setActiveEdge('end')}
|
||
>
|
||
结束
|
||
<em>{formatPartsLabel(draftEnd)}</em>
|
||
</button>
|
||
</div>
|
||
<div className="ar-date-wheel-wrap" key={`${activeEdge}-${activeParts.year}-${activeParts.month}`}>
|
||
<div className="ar-date-wheel-fade top" aria-hidden="true" />
|
||
<div className="ar-date-wheel-fade bottom" aria-hidden="true" />
|
||
<div className="ar-date-wheel-highlight" aria-hidden="true" />
|
||
<span className="ar-date-wheel-unit y">年</span>
|
||
<span className="ar-date-wheel-unit m">月</span>
|
||
<span className="ar-date-wheel-unit d">日</span>
|
||
<DateWheelColumn items={years} value={activeParts.year} onChange={(y) => patchActive({ year: y })} />
|
||
<DateWheelColumn items={months} value={activeParts.month} onChange={(m) => patchActive({ month: m })} />
|
||
<DateWheelColumn items={days} value={activeParts.day} onChange={(d) => patchActive({ day: d })} />
|
||
</div>
|
||
</Drawer>
|
||
</>
|
||
);
|
||
};
|
||
|
||
/** 年审管理 · 产品需求说明(嵌入「需求说明」弹窗) */
|
||
const AnnualReviewPrdDoc = () => (
|
||
<div className="ar-prd-doc">
|
||
<Tag color="green" style={{ marginBottom: 8 }}>ONE-OS 小程序</Tag>
|
||
<div className="ar-prd-meta">
|
||
文档版本:V1.0 | 端:小程序 | 模块:运维 · 车辆年审
|
||
<br />
|
||
适用角色:现场办理人员、车队运维、区域运营
|
||
</div>
|
||
|
||
<div className="ar-prd-h2">一、背景与目标</div>
|
||
<p className="ar-prd-p">
|
||
车辆年审需在检验到期前完成办理,并同步更新行驶证检验有效期等信息。本模块为一线人员提供<strong>待办提醒、现场办理、草稿保存、历史查询</strong>能力,降低漏办与信息回填错误率,并与 Web 端年审/证照数据口径保持一致(原型阶段为模拟数据)。
|
||
</p>
|
||
<ul className="ar-prd-ul">
|
||
<li className="ar-prd-li">待办:聚焦「即将到期 / 已逾期」车辆,按紧急程度排序。</li>
|
||
<li className="ar-prd-li">办理:单页完成行驶证更新、检测站费用、可选二保/整备信息录入。</li>
|
||
<li className="ar-prd-li">历史:提交后归档,仅查看不可改,便于追溯与对账。</li>
|
||
<li className="ar-prd-li">
|
||
<strong>证照联动(重点)</strong>:年审办理中更新的行驶证照片、检验有效期,须在提交时<strong>全量同步</strong>至 Web 端「证照管理」对应车辆档案,保证台账唯一可信来源。
|
||
</li>
|
||
</ul>
|
||
|
||
<div className="ar-prd-highlight">
|
||
<div className="ar-prd-highlight-title">重点 · 与证照管理同步</div>
|
||
<p className="ar-prd-p" style={{ marginBottom: 8 }}>
|
||
年审小程序负责<strong>现场采集与办理</strong>;证照管理负责<strong>车辆证照台账建档与到期预警</strong>。两者通过车牌号(及 VIN 校验)关联同一车辆。
|
||
</p>
|
||
<ul className="ar-prd-ul" style={{ marginBottom: 0 }}>
|
||
<li className="ar-prd-li">同步时机:<strong>点击「提交」且校验通过后</strong>(保存草稿不同步,避免半成品覆盖台账)。</li>
|
||
<li className="ar-prd-li">同步范围:当前任务下<strong>全部最新行驶证照片</strong>(最多 4 张,以提交瞬间列表为准)+ <strong>检验有效期至</strong>(精确到日)。</li>
|
||
<li className="ar-prd-li">覆盖策略:<strong>全量覆盖</strong>证照管理中该车辆「行驶证」卡片的影像与「检验有效期至」,不以增量追加旧图。</li>
|
||
<li className="ar-prd-li">失败处理:同步失败则提交事务回滚,提示用户重试;禁止出现「年审已成功、证照未更新」。</li>
|
||
</ul>
|
||
</div>
|
||
|
||
<div className="ar-prd-h2">二、信息架构</div>
|
||
<ul className="ar-prd-ul">
|
||
<li className="ar-prd-li">列表首页:Tab「待处理」「历史记录」+ 车牌搜索 + 筛选。</li>
|
||
<li className="ar-prd-li">年审操作页(待办进入):可编辑、可保存草稿、可提交。</li>
|
||
<li className="ar-prd-li">年审记录页(历史进入):只读详情,结构与办理页一致。</li>
|
||
</ul>
|
||
|
||
<div className="ar-prd-h2">三、列表页 · 待处理</div>
|
||
<div className="ar-prd-h3">3.1 任务卡片字段</div>
|
||
<ul className="ar-prd-ul">
|
||
<li className="ar-prd-li">车牌号(主标题)、逾期/剩余天数标签(逾期为红色,剩余为橙色)。</li>
|
||
<li className="ar-prd-li">运营状态、运营区域(省-市)、到期时间。</li>
|
||
<li className="ar-prd-li">已保存草稿时,右侧展示「已保存」标签(位于箭头左侧)。</li>
|
||
</ul>
|
||
<div className="ar-prd-h3">3.2 排序规则(待办专用)</div>
|
||
<ol className="ar-prd-ol">
|
||
<li className="ar-prd-li">第一优先级:逾期天数越多越靠前。</li>
|
||
<li className="ar-prd-li">第二优先级:剩余天数越少越靠前。</li>
|
||
</ol>
|
||
<div className="ar-prd-h3">3.3 车牌搜索</div>
|
||
<p className="ar-prd-p">
|
||
点击搜索框唤起<strong>小程序风格车牌键盘</strong>(省 → 字母 → 字母数字),支持删除、完成;列表按车牌模糊匹配过滤。
|
||
</p>
|
||
<div className="ar-prd-h3">3.4 数据范围</div>
|
||
<p className="ar-prd-p">退出运营状态车辆不纳入待办与历史列表。</p>
|
||
|
||
<div className="ar-prd-h2">四、列表页 · 历史记录</div>
|
||
<ul className="ar-prd-ul">
|
||
<li className="ar-prd-li">卡片在待办字段基础上增加:办理人、完成时间(精确至分钟)。</li>
|
||
<li className="ar-prd-li">按完成时间倒序展示。</li>
|
||
<li className="ar-prd-li">点击卡片进入年审记录详情(只读),不可编辑、不可再次提交。</li>
|
||
</ul>
|
||
|
||
<div className="ar-prd-h2">五、筛选</div>
|
||
<p className="ar-prd-p">右侧抽屉筛选,确定后生效,支持重置。</p>
|
||
<div className="ar-prd-table-wrap">
|
||
<table className="ar-prd-table">
|
||
<thead>
|
||
<tr>
|
||
<th>筛选项</th>
|
||
<th>待处理</th>
|
||
<th>历史记录</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td>到期时间</td>
|
||
<td>支持</td>
|
||
<td>支持</td>
|
||
</tr>
|
||
<tr>
|
||
<td>运营区域</td>
|
||
<td>省市双列级联</td>
|
||
<td>省市双列级联</td>
|
||
</tr>
|
||
<tr>
|
||
<td>办理人</td>
|
||
<td>—</td>
|
||
<td>单选(含全部)</td>
|
||
</tr>
|
||
<tr>
|
||
<td>完成时间</td>
|
||
<td>—</td>
|
||
<td>开始-结束(日)</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p className="ar-prd-p">日期类筛选均采用<strong>微信小程序风格滚轮</strong>:范围选择支持开始/结束切换;精确到日。</p>
|
||
|
||
<div className="ar-prd-h2">六、年审操作页</div>
|
||
<p className="ar-prd-p">自待办卡片进入。顶部导航含返回;内容区与顶栏保留间距,底部固定「保存」「提交」。</p>
|
||
|
||
<div className="ar-prd-h3">6.1 车辆信息(只读)</div>
|
||
<p className="ar-prd-p">展示车牌号、品牌、型号、检验有效期、运营状态(库存类展示可运营/待运营细分)。</p>
|
||
|
||
<div className="ar-prd-h3">6.2 更新行驶证</div>
|
||
<ul className="ar-prd-ul">
|
||
<li className="ar-prd-li">
|
||
<strong>行驶证照片</strong>(必填):最多 4 张,3 列网格,首格上传,支持批量选择;可预览、删除。
|
||
</li>
|
||
<li className="ar-prd-li">
|
||
上传后触发 OCR(原型模拟):全页遮罩「识别中」,识别期间不可保存/提交。
|
||
</li>
|
||
<li className="ar-prd-li">
|
||
OCR 仅识别到<strong>年月</strong>时,检验有效期自动填入该月<strong>最后一天</strong>;识别车牌须与年审车牌一致,否则报错并禁止提交。
|
||
</li>
|
||
<li className="ar-prd-li">
|
||
<strong>检验有效期</strong>(必填):占位「上传照片自动识别」;识别完成后可点击滚轮日期组件手动调整年月日。
|
||
</li>
|
||
<li className="ar-prd-li">
|
||
用户在办理过程中增删改照片或调整有效期后,<strong>以提交时最终值</strong>为准写入证照管理(非 OCR 中间态、非仅首张图)。
|
||
</li>
|
||
</ul>
|
||
|
||
<div className="ar-prd-h3">6.3 同步至证照管理(字段映射)</div>
|
||
<div className="ar-prd-table-wrap">
|
||
<table className="ar-prd-table">
|
||
<thead>
|
||
<tr>
|
||
<th>年审小程序(提交快照)</th>
|
||
<th>证照管理 · 行驶证卡片</th>
|
||
<th>说明</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td>车牌号</td>
|
||
<td>关联主键</td>
|
||
<td>定位车辆;须与证照档案一致</td>
|
||
</tr>
|
||
<tr>
|
||
<td>行驶证照片列表</td>
|
||
<td>行驶证影像(最多 4 张)</td>
|
||
<td>全量替换为最新上传列表</td>
|
||
</tr>
|
||
<tr>
|
||
<td>检验有效期至</td>
|
||
<td>检验有效期至</td>
|
||
<td>日期 YYYY-MM-DD;与 OCR/手选结果一致</td>
|
||
</tr>
|
||
<tr>
|
||
<td>办理人、完成时间</td>
|
||
<td>操作日志 / 更新人、更新时间</td>
|
||
<td>留痕;影像与有效期以年审提交为准</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p className="ar-prd-p">
|
||
证照管理侧规则对齐:检验有效期支持「选月取月末」展示逻辑时,年审同步须已落库为<strong>具体日期</strong>(小程序 OCR 识别到月后已补全月末,再允许手调)。
|
||
</p>
|
||
|
||
<div className="ar-prd-h3">6.4 检测服务站信息</div>
|
||
<ul className="ar-prd-ul">
|
||
<li className="ar-prd-li">检测服务站:从<strong>检测服务站列表</strong>选择(可搜索)。</li>
|
||
<li className="ar-prd-li">费用(元):<strong>必填</strong>,保留两位小数。</li>
|
||
<li className="ar-prd-li">备注:多行文本,选填。</li>
|
||
</ul>
|
||
|
||
<div className="ar-prd-h3">6.5 二保信息(选填)</div>
|
||
<p className="ar-prd-p">默认折叠,点击「添加二保信息」展开内联卡片(非弹窗)。</p>
|
||
<ul className="ar-prd-ul">
|
||
<li className="ar-prd-li">二保服务站:从<strong>检测服务站列表</strong>选择(与检测站同源)。</li>
|
||
<li className="ar-prd-li">费用:仅当已选服务站时<strong>必填</strong>。</li>
|
||
<li className="ar-prd-li">备注、二保照片(最多 10 张,可批量)选填。</li>
|
||
</ul>
|
||
|
||
<div className="ar-prd-h3">6.6 整备服务站信息(选填)</div>
|
||
<ul className="ar-prd-ul">
|
||
<li className="ar-prd-li">整备服务站:从<strong>维修站列表</strong>选择。</li>
|
||
<li className="ar-prd-li">费用:仅当已选服务站时<strong>必填</strong>。</li>
|
||
<li className="ar-prd-li">备注、整备照片(最多 10 张,可批量)选填。</li>
|
||
</ul>
|
||
|
||
<div className="ar-prd-h2">七、保存与提交</div>
|
||
<div className="ar-prd-h3">7.1 保存(草稿)</div>
|
||
<ul className="ar-prd-ul">
|
||
<li className="ar-prd-li">随时保存当前表单(OCR 识别中除外),<strong>不做必填项校验</strong>,不关闭页面。</li>
|
||
<li className="ar-prd-li">待办列表展示「已保存」;再次进入自动恢复草稿。</li>
|
||
<li className="ar-prd-li">提交成功后清除该任务草稿。</li>
|
||
</ul>
|
||
<div className="ar-prd-h3">7.2 提交</div>
|
||
<ul className="ar-prd-ul">
|
||
<li className="ar-prd-li">按钮文案为「提交」,点击后校验全部必填项。</li>
|
||
<li className="ar-prd-li">
|
||
校验通过后服务端顺序建议:① 上传/确认影像 URL → ② <strong>同步证照管理(行驶证全量)</strong> → ③ 年审任务结案 → ④ 返回成功。
|
||
</li>
|
||
<li className="ar-prd-li">前端提示:「提交成功,行驶证已同步至证照管理」,约 1 秒后返回待办列表。</li>
|
||
<li className="ar-prd-li">任务转入历史记录,记录办理人、完成时间及完整办理快照(含同步后的证照数据)。</li>
|
||
</ul>
|
||
<div className="ar-prd-h3">7.3 提交校验清单</div>
|
||
<div className="ar-prd-table-wrap">
|
||
<table className="ar-prd-table">
|
||
<thead>
|
||
<tr>
|
||
<th>校验项</th>
|
||
<th>规则</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr>
|
||
<td>行驶证照片</td>
|
||
<td>至少 1 张</td>
|
||
</tr>
|
||
<tr>
|
||
<td>检验有效期</td>
|
||
<td>已填写</td>
|
||
</tr>
|
||
<tr>
|
||
<td>检测费用</td>
|
||
<td>必填</td>
|
||
</tr>
|
||
<tr>
|
||
<td>二保/整备费用</td>
|
||
<td>已选服务站则必填</td>
|
||
</tr>
|
||
<tr>
|
||
<td>OCR 状态</td>
|
||
<td>识别中不可提交;车牌不一致不可提交</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div className="ar-prd-h2">八、年审记录页(历史 · 只读)</div>
|
||
<ul className="ar-prd-ul">
|
||
<li className="ar-prd-li">模块顺序与办理页一致:车辆信息 → 更新行驶证 → 检测站 → 二保/整备(有数据才展示)。</li>
|
||
<li className="ar-prd-li">车辆信息下增加:办理人、完成时间。</li>
|
||
<li className="ar-prd-li">行驶证/二保/整备照片以缩略图网格展示,支持预览,无上传入口。</li>
|
||
<li className="ar-prd-li">所有字段只读展示,不提供编辑与提交。</li>
|
||
</ul>
|
||
|
||
<div className="ar-prd-h2">九、交互与视觉规范</div>
|
||
<ul className="ar-prd-ul">
|
||
<li className="ar-prd-li">整体模拟 iPhone 小程序壳:状态栏、导航栏、微信胶囊。</li>
|
||
<li className="ar-prd-li">主色:#16D1A1 / #00BFA5;表单为左标题右内容、隐藏边框右对齐输入。</li>
|
||
<li className="ar-prd-li">选择类:底部抽屉 + 滚轮/双列(省市、日期、单选)。</li>
|
||
<li className="ar-prd-li">车牌、日期交互对齐微信小程序常见模式。</li>
|
||
</ul>
|
||
|
||
<div className="ar-prd-h2">十、非功能与后续对接(建议)</div>
|
||
<ul className="ar-prd-ul">
|
||
<li className="ar-prd-li">正式环境需对接:任务列表 API、OCR 服务、图片上传 CDN、提交/草稿接口。</li>
|
||
<li className="ar-prd-li">
|
||
<strong>证照同步接口(必接)</strong>:POST 提交年审时携带 plateNo、inspectionValidUntil、photoUrls[];或由后端在结案事务内调用证照服务「覆盖行驶证」能力。
|
||
</li>
|
||
<li className="ar-prd-li">办理人取当前登录用户;完成时间取服务端时间。</li>
|
||
<li className="ar-prd-li">检测站/维修站列表与主数据或 Web 端配置同步。</li>
|
||
<li className="ar-prd-li">图片须先落 CDN 再写证照管理,禁止仅存临时 Blob;刷新后 Web/小程序均可预览。</li>
|
||
</ul>
|
||
|
||
<p className="ar-prd-footnote">
|
||
当前页面为可交互原型,业务数据、OCR 结果、站点列表均为模拟;行为与上述需求一致,供评审与开发拆票参考。
|
||
</p>
|
||
</div>
|
||
);
|
||
|
||
const AnnualReviewPanel = function AnnualReviewPanel({ embedded = false, theme = 'default', onBack, onOpenPrd }) {
|
||
const [mainTab, setMainTab] = useState('pending');
|
||
const [searchPlate, setSearchPlate] = useState('');
|
||
const [plateKbOpen, setPlateKbOpen] = useState(false);
|
||
const [plateKbDraft, setPlateKbDraft] = useState('');
|
||
const [filterOpen, setFilterOpen] = useState(false);
|
||
const [filterDraft, setFilterDraft] = useState({ ...DEFAULT_FILTER });
|
||
const [filterApplied, setFilterApplied] = useState({ ...DEFAULT_FILTER });
|
||
const [tasks, setTasks] = useState(() =>
|
||
MOCK_TASKS.map((t) =>
|
||
t.tab === 'history'
|
||
? { ...t, operateSnapshot: t.operateSnapshot || buildSampleHistorySnapshot(t) }
|
||
: t
|
||
)
|
||
);
|
||
const [prdOpen, setPrdOpen] = useState(false);
|
||
const [operateTask, setOperateTask] = useState(null);
|
||
const [historyViewTask, setHistoryViewTask] = useState(null);
|
||
const [inspectionForm, setInspectionForm] = useState({ ...EMPTY_INSPECTION_FORM });
|
||
const [m2Expanded, setM2Expanded] = useState(false);
|
||
const [m2Form, setM2Form] = useState({ ...EMPTY_SERVICE_FORM });
|
||
const [zbExpanded, setZbExpanded] = useState(false);
|
||
const [zbForm, setZbForm] = useState({ ...EMPTY_SERVICE_FORM });
|
||
const [licenseForm, setLicenseForm] = useState({ ...EMPTY_LICENSE_FORM });
|
||
const [operateDrafts, setOperateDrafts] = useState(() => loadOperateDrafts());
|
||
const licenseOcrTimerRef = useRef(null);
|
||
const themeClass = theme === 'xll' ? ' xll-module-theme' : '';
|
||
const embedStyles = `${PAGE_STYLE}${EMBED_STYLE}${theme === 'xll' ? XLL_THEME_PATCH : ''}`;
|
||
|
||
const resetOperateForms = () => {
|
||
if (licenseOcrTimerRef.current) {
|
||
clearTimeout(licenseOcrTimerRef.current);
|
||
licenseOcrTimerRef.current = null;
|
||
}
|
||
setInspectionForm({ ...EMPTY_INSPECTION_FORM });
|
||
setM2Expanded(false);
|
||
setM2Form({ ...EMPTY_SERVICE_FORM });
|
||
setZbExpanded(false);
|
||
setZbForm({ ...EMPTY_SERVICE_FORM });
|
||
setLicenseForm({ ...EMPTY_LICENSE_FORM });
|
||
};
|
||
|
||
const applyOperateDraft = (draft) => {
|
||
if (!draft) return;
|
||
setInspectionForm({ ...EMPTY_INSPECTION_FORM, ...draft.inspectionForm });
|
||
const lf = draft.licenseForm || {};
|
||
setLicenseForm({
|
||
...EMPTY_LICENSE_FORM,
|
||
photos: deserializeUploadFileList(lf.photos),
|
||
inspectionValidUntil: lf.inspectionValidUntil ?? null,
|
||
ocrStatus: lf.ocrStatus === 'recognizing' ? 'idle' : lf.ocrStatus || 'idle'
|
||
});
|
||
setM2Expanded(!!draft.m2Expanded);
|
||
const m2 = draft.m2Form || {};
|
||
setM2Form({
|
||
...EMPTY_SERVICE_FORM,
|
||
...m2,
|
||
photos: deserializeUploadFileList(m2.photos)
|
||
});
|
||
setZbExpanded(!!draft.zbExpanded);
|
||
const zb = draft.zbForm || {};
|
||
setZbForm({
|
||
...EMPTY_SERVICE_FORM,
|
||
...zb,
|
||
photos: deserializeUploadFileList(zb.photos)
|
||
});
|
||
};
|
||
|
||
const collectOperateDraft = () => ({
|
||
savedAt: new Date().toISOString(),
|
||
inspectionForm: { ...inspectionForm },
|
||
licenseForm: {
|
||
photos: serializeUploadFileList(licenseForm.photos),
|
||
inspectionValidUntil: licenseForm.inspectionValidUntil,
|
||
ocrStatus: licenseForm.ocrStatus === 'recognizing' ? 'idle' : licenseForm.ocrStatus
|
||
},
|
||
m2Expanded,
|
||
m2Form: { ...m2Form, photos: serializeUploadFileList(m2Form.photos) },
|
||
zbExpanded,
|
||
zbForm: { ...zbForm, photos: serializeUploadFileList(zbForm.photos) }
|
||
});
|
||
|
||
const upsertOperateDraft = (taskId, draft) => {
|
||
const next = { ...operateDrafts, [taskId]: draft };
|
||
setOperateDrafts(next);
|
||
persistOperateDrafts(next);
|
||
};
|
||
|
||
const removeOperateDraft = (taskId) => {
|
||
if (!operateDrafts[taskId]) return;
|
||
const next = { ...operateDrafts };
|
||
delete next[taskId];
|
||
setOperateDrafts(next);
|
||
persistOperateDrafts(next);
|
||
};
|
||
|
||
const openOperatePage = (task) => {
|
||
if (licenseOcrTimerRef.current) {
|
||
clearTimeout(licenseOcrTimerRef.current);
|
||
licenseOcrTimerRef.current = null;
|
||
}
|
||
setHistoryViewTask(null);
|
||
setOperateTask(task);
|
||
const draft = operateDrafts[task.id];
|
||
if (draft) applyOperateDraft(draft);
|
||
else resetOperateForms();
|
||
};
|
||
|
||
const closeOperatePage = () => {
|
||
setOperateTask(null);
|
||
resetOperateForms();
|
||
};
|
||
|
||
const openHistoryViewPage = (task) => {
|
||
setHistoryViewTask(task);
|
||
setOperateTask(null);
|
||
resetOperateForms();
|
||
};
|
||
|
||
const closeHistoryViewPage = () => {
|
||
setHistoryViewTask(null);
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!embedded || typeof window === 'undefined') return undefined;
|
||
window.__xllInspectionBack = () => {
|
||
if (historyViewTask) {
|
||
closeHistoryViewPage();
|
||
return true;
|
||
}
|
||
if (operateTask) {
|
||
closeOperatePage();
|
||
return true;
|
||
}
|
||
return false;
|
||
};
|
||
return () => { delete window.__xllInspectionBack; };
|
||
}, [embedded, historyViewTask, operateTask]);
|
||
|
||
const setInspection = (key, val) => setInspectionForm((p) => ({ ...p, [key]: val }));
|
||
const setM2 = (key, val) => setM2Form((p) => ({ ...p, [key]: val }));
|
||
const setZb = (key, val) => setZbForm((p) => ({ ...p, [key]: val }));
|
||
const setLicense = (key, val) => setLicenseForm((p) => ({ ...p, [key]: val }));
|
||
|
||
const runLicenseOcr = (task, photos) => {
|
||
if (licenseOcrTimerRef.current) clearTimeout(licenseOcrTimerRef.current);
|
||
setLicenseForm((p) => ({
|
||
...p,
|
||
ocrStatus: 'recognizing',
|
||
inspectionValidUntil: null
|
||
}));
|
||
licenseOcrTimerRef.current = setTimeout(() => {
|
||
const expectedPlate = task?.plateNo || '';
|
||
const recognizedPlate = mockOcrLicensePlate(task, photos);
|
||
if (!platesMatch(recognizedPlate, expectedPlate)) {
|
||
setLicenseForm((p) => ({
|
||
...p,
|
||
ocrStatus: 'error',
|
||
inspectionValidUntil: null
|
||
}));
|
||
message.error(
|
||
`识别车牌号与年审车牌号不一致(识别:${recognizedPlate || '—'},年审:${expectedPlate || '—'})`
|
||
);
|
||
licenseOcrTimerRef.current = null;
|
||
return;
|
||
}
|
||
const validUntil = mockOcrLicenseValidUntil(task);
|
||
setLicenseForm((p) => ({
|
||
...p,
|
||
inspectionValidUntil: validUntil,
|
||
ocrStatus: 'done'
|
||
}));
|
||
message.success('检验有效期识别完成');
|
||
licenseOcrTimerRef.current = null;
|
||
}, LICENSE_OCR_MOCK_MS);
|
||
};
|
||
|
||
const handleLicensePhotosChange = (fileList) => {
|
||
const prevLen = (licenseForm.photos || []).length;
|
||
const nextLen = (fileList || []).length;
|
||
if (nextLen === 0) {
|
||
if (licenseOcrTimerRef.current) {
|
||
clearTimeout(licenseOcrTimerRef.current);
|
||
licenseOcrTimerRef.current = null;
|
||
}
|
||
setLicenseForm({ ...EMPTY_LICENSE_FORM });
|
||
return;
|
||
}
|
||
setLicense('photos', fileList);
|
||
if (nextLen > prevLen) {
|
||
runLicenseOcr(operateTask, fileList);
|
||
}
|
||
};
|
||
|
||
const inspectionStationOptions = useMemo(() => toStationSelectOptions(readInspectionStationList()), []);
|
||
const repairStationOptions = useMemo(() => toStationSelectOptions(REPAIR_STATION_LIST), []);
|
||
|
||
const saveOperate = () => {
|
||
if (!operateTask) return;
|
||
if (licenseForm.ocrStatus === 'recognizing') {
|
||
message.warning('行驶证识别中,请稍候再保存');
|
||
return;
|
||
}
|
||
upsertOperateDraft(operateTask.id, collectOperateDraft());
|
||
message.success('已保存,可稍后继续填写');
|
||
};
|
||
|
||
const submitOperate = () => {
|
||
if (licenseForm.ocrStatus === 'recognizing') {
|
||
message.warning('行驶证识别中,请稍候');
|
||
return;
|
||
}
|
||
if ((licenseForm.photos || []).length && licenseForm.ocrStatus === 'error') {
|
||
message.error('行驶证车牌号识别不一致,请重新上传照片');
|
||
return;
|
||
}
|
||
if (!validateLicenseForm(licenseForm)) return;
|
||
if (isFormCostEmpty(inspectionForm.cost)) {
|
||
message.error('请填写检测费用');
|
||
return;
|
||
}
|
||
if (!validateStationCost(m2Form, '二保')) return;
|
||
if (!validateStationCost(zbForm, '整备')) return;
|
||
const snapshot = collectOperateDraft();
|
||
const completeTime = moment ? moment().format('YYYY-MM-DD HH:mm') : new Date().toISOString().slice(0, 16).replace('T', ' ');
|
||
const taskId = operateTask.id;
|
||
|
||
removeOperateDraft(taskId);
|
||
syncLicenseToCertificateManagement(operateTask, licenseForm, MOCK_CURRENT_HANDLER);
|
||
setTasks((prev) =>
|
||
prev.map((t) =>
|
||
t.id === taskId
|
||
? {
|
||
...t,
|
||
tab: 'history',
|
||
executor: MOCK_CURRENT_HANDLER,
|
||
executeTime: completeTime,
|
||
expireDate: licenseForm.inspectionValidUntil || t.expireDate,
|
||
operateSnapshot: snapshot
|
||
}
|
||
: t
|
||
)
|
||
);
|
||
message.success('提交成功,行驶证照片及检验有效期已同步至证照管理');
|
||
setTimeout(() => {
|
||
closeOperatePage();
|
||
}, 1000);
|
||
};
|
||
|
||
const enrichTasks = useMemo(
|
||
() =>
|
||
tasks.filter((t) => !isAnnualReviewExcluded(t)).map((t) => {
|
||
const daysLeft = t.daysLeft;
|
||
let urgency = 'normal';
|
||
if (daysLeft <= 0) urgency = 'danger';
|
||
else if (daysLeft <= 30) urgency = 'warn';
|
||
return {
|
||
...t,
|
||
urgency,
|
||
hasSavedDraft: t.tab === 'pending' && !!operateDrafts[t.id]
|
||
};
|
||
}),
|
||
[tasks, operateDrafts]
|
||
);
|
||
|
||
const filteredTasks = useMemo(() => {
|
||
const f = filterApplied;
|
||
const plateKey = (searchPlate || '').trim().toLowerCase();
|
||
|
||
const list = enrichTasks.filter((t) => {
|
||
if (t.tab !== mainTab) return false;
|
||
if (plateKey && !t.plateNo.toLowerCase().includes(plateKey)) return false;
|
||
if (f.provinceCity && f.provinceCity[0]) {
|
||
if (t.province !== f.provinceCity[0]) return false;
|
||
if (f.provinceCity[1] && t.city !== f.provinceCity[1]) return false;
|
||
}
|
||
if (f.expireRange && f.expireRange[0] && f.expireRange[1] && moment) {
|
||
const exp = moment(t.expireDate).startOf('day').valueOf();
|
||
const start = moment(f.expireRange[0]).startOf('day').valueOf();
|
||
const end = moment(f.expireRange[1]).endOf('day').valueOf();
|
||
if (exp < start || exp > end) return false;
|
||
}
|
||
if (mainTab === 'history') {
|
||
if (f.handler && t.executor !== f.handler) return false;
|
||
if (f.executeTimeRange && f.executeTimeRange[0] && f.executeTimeRange[1] && moment) {
|
||
const done = moment(t.executeTime);
|
||
if (!done.isValid()) return false;
|
||
const start = moment(f.executeTimeRange[0]).startOf('day').valueOf();
|
||
const end = moment(f.executeTimeRange[1]).endOf('day').valueOf();
|
||
const doneVal = done.valueOf();
|
||
if (doneVal < start || doneVal > end) return false;
|
||
}
|
||
}
|
||
return true;
|
||
});
|
||
|
||
if (mainTab === 'pending') return sortPendingTasks(list);
|
||
if (mainTab === 'history' && moment) {
|
||
return [...list].sort((a, b) => {
|
||
const ta = moment(a.executeTime);
|
||
const tb = moment(b.executeTime);
|
||
if (ta.isValid() && tb.isValid()) return tb.valueOf() - ta.valueOf();
|
||
return String(b.executeTime || '').localeCompare(String(a.executeTime || ''));
|
||
});
|
||
}
|
||
return list;
|
||
}, [enrichTasks, mainTab, searchPlate, filterApplied]);
|
||
|
||
const openPlateKeyboard = () => {
|
||
setPlateKbDraft(searchPlate);
|
||
setPlateKbOpen(true);
|
||
};
|
||
|
||
const closePlateKeyboard = () => {
|
||
setPlateKbOpen(false);
|
||
};
|
||
|
||
const confirmPlateKeyboard = (plate) => {
|
||
setSearchPlate((plate || '').trim().toUpperCase());
|
||
setPlateKbOpen(false);
|
||
};
|
||
|
||
const openFilter = () => {
|
||
setFilterDraft({ ...filterApplied });
|
||
setFilterOpen(true);
|
||
};
|
||
|
||
const applyFilter = () => {
|
||
setFilterApplied({ ...filterDraft });
|
||
setFilterOpen(false);
|
||
message.success('筛选已应用');
|
||
};
|
||
|
||
const resetFilter = () => {
|
||
setFilterDraft({ ...DEFAULT_FILTER });
|
||
};
|
||
|
||
const getPlateDaysTag = (task) => {
|
||
if (task.tab === 'history') return null;
|
||
if (task.daysLeft > 0) {
|
||
return { text: `剩余${task.daysLeft}天`, color: 'warning' };
|
||
}
|
||
return { text: `逾期${Math.abs(task.daysLeft)}天`, color: 'error' };
|
||
};
|
||
|
||
const handleTaskCardClick = (task) => {
|
||
if (task.tab === 'pending') {
|
||
openOperatePage(task);
|
||
return;
|
||
}
|
||
openHistoryViewPage(task);
|
||
};
|
||
|
||
const renderTaskCard = (task) => {
|
||
const daysTag = getPlateDaysTag(task);
|
||
|
||
return (
|
||
<div
|
||
key={task.id}
|
||
className="ar-card"
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={() => handleTaskCardClick(task)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter') handleTaskCardClick(task);
|
||
}}
|
||
>
|
||
<div className="ar-card-head">
|
||
<div className="ar-plate-row">
|
||
<span className="ar-plate">{task.plateNo}</span>
|
||
{daysTag && (
|
||
<Tag className="ar-plate-tag" color={daysTag.color}>
|
||
{daysTag.text}
|
||
</Tag>
|
||
)}
|
||
</div>
|
||
<div className="ar-card-head-right">
|
||
{task.hasSavedDraft && (
|
||
<Tag className="ar-plate-tag ar-plate-tag--saved">已保存</Tag>
|
||
)}
|
||
<span className="ar-card-status" aria-hidden="true">
|
||
<IconChevron />
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="ar-kv">
|
||
<span className="ar-kv-label">运营状态</span>
|
||
<span className="ar-kv-value">{task.operateStatus}</span>
|
||
</div>
|
||
<div className="ar-kv">
|
||
<span className="ar-kv-label">运营区域</span>
|
||
<span className="ar-kv-value">{formatTaskRegion(task)}</span>
|
||
</div>
|
||
<div className="ar-kv">
|
||
<span className="ar-kv-label">到期时间</span>
|
||
<span className="ar-kv-value">{task.expireDate}</span>
|
||
</div>
|
||
{task.tab === 'history' && (
|
||
<>
|
||
<div className="ar-kv">
|
||
<span className="ar-kv-label">办理人</span>
|
||
<span className="ar-kv-value">{task.executor || '—'}</span>
|
||
</div>
|
||
<div className="ar-kv">
|
||
<span className="ar-kv-label">完成时间</span>
|
||
<span className="ar-kv-value">{formatCompleteTime(task.executeTime)}</span>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const renderHistoryDetailPage = () => {
|
||
if (!historyViewTask) return null;
|
||
const task = historyViewTask;
|
||
const snap = getHistorySnapshot(task);
|
||
const licensePhotos = deserializeUploadFileList(snap.licenseForm?.photos);
|
||
const licenseValid = snap.licenseForm?.inspectionValidUntil || '—';
|
||
const insp = snap.inspectionForm || {};
|
||
const m2 = snap.m2Form || {};
|
||
const zb = snap.zbForm || {};
|
||
const showM2 = snap.m2Expanded || hasServiceContent(m2);
|
||
const showZb = snap.zbExpanded || hasServiceContent(zb);
|
||
|
||
return (
|
||
<div className="ar-operate-scroll">
|
||
<div className="ar-section ar-section--form">
|
||
<div className="ar-section-title">车辆信息</div>
|
||
<div className="ar-form-grid">
|
||
<InfoRow label="车牌号" valueClassName="plate">
|
||
{task.plateNo}
|
||
</InfoRow>
|
||
<InfoRow label="品牌">{task.brand}</InfoRow>
|
||
<InfoRow label="型号">{task.model}</InfoRow>
|
||
<InfoRow label="检验有效期">{task.expireDate}</InfoRow>
|
||
<InfoRow label="运营状态">{formatOperateStatusDisplay(task)}</InfoRow>
|
||
<InfoRow label="办理人">{task.executor || '—'}</InfoRow>
|
||
<InfoRow label="完成时间">{formatCompleteTime(task.executeTime)}</InfoRow>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="ar-section ar-section--form">
|
||
<div className="ar-section-title">更新行驶证</div>
|
||
<PhotoReadonlyGallery label="行驶证照片" fileList={licensePhotos} />
|
||
<div className="ar-form-grid" style={{ marginTop: 4 }}>
|
||
<InfoRow label="检验有效期">{licenseValid}</InfoRow>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="ar-section ar-section--form">
|
||
<div className="ar-section-title">检测服务站信息</div>
|
||
<div className="ar-form-grid">
|
||
<InfoRow label="检测服务站">{insp.station || '—'}</InfoRow>
|
||
<InfoRow label="费用(元)">{formatDisplayMoney(insp.cost)}</InfoRow>
|
||
{insp.remark ? (
|
||
<div className="ar-form-row">
|
||
<span className="ar-form-label">备注</span>
|
||
<div className="ar-form-control">
|
||
<div className="ar-form-readonly ar-form-readonly--multiline">{insp.remark}</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
|
||
{showM2 ? (
|
||
<div className="ar-section ar-section--form">
|
||
<div className="ar-section-title">二保信息</div>
|
||
<div className="ar-form-grid">
|
||
<InfoRow label="二保服务站">{m2.station || '—'}</InfoRow>
|
||
<InfoRow label="费用(元)">{formatDisplayMoney(m2.cost)}</InfoRow>
|
||
{m2.remark ? (
|
||
<div className="ar-form-row">
|
||
<span className="ar-form-label">备注</span>
|
||
<div className="ar-form-control">
|
||
<div className="ar-form-readonly ar-form-readonly--multiline">{m2.remark}</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<PhotoReadonlyGallery label="二保照片" fileList={deserializeUploadFileList(m2.photos)} />
|
||
</div>
|
||
) : null}
|
||
|
||
{showZb ? (
|
||
<div className="ar-section ar-section--form">
|
||
<div className="ar-section-title">整备服务站信息</div>
|
||
<div className="ar-form-grid">
|
||
<InfoRow label="整备服务站">{zb.station || '—'}</InfoRow>
|
||
<InfoRow label="费用(元)">{formatDisplayMoney(zb.cost)}</InfoRow>
|
||
{zb.remark ? (
|
||
<div className="ar-form-row">
|
||
<span className="ar-form-label">备注</span>
|
||
<div className="ar-form-control">
|
||
<div className="ar-form-readonly ar-form-readonly--multiline">{zb.remark}</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
<PhotoReadonlyGallery label="整备照片" fileList={deserializeUploadFileList(zb.photos)} />
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const renderOperatePage = () => {
|
||
if (!operateTask) return null;
|
||
const task = operateTask;
|
||
|
||
const licenseRecognizing = licenseForm.ocrStatus === 'recognizing';
|
||
|
||
return (
|
||
<div className="ar-operate-wrap">
|
||
<div className="ar-operate-scroll">
|
||
<div className="ar-section ar-section--form">
|
||
<div className="ar-section-title">车辆信息</div>
|
||
<div className="ar-form-grid">
|
||
<InfoRow label="车牌号" valueClassName="plate">{task.plateNo}</InfoRow>
|
||
<InfoRow label="品牌">{task.brand}</InfoRow>
|
||
<InfoRow label="型号">{task.model}</InfoRow>
|
||
<InfoRow label="检验有效期">{task.expireDate}</InfoRow>
|
||
<InfoRow label="运营状态">{formatOperateStatusDisplay(task)}</InfoRow>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="ar-section ar-section--form">
|
||
<div className="ar-section-title">更新行驶证</div>
|
||
{licenseForm.ocrStatus === 'error' && (
|
||
<div className="ar-ocr-banner ar-ocr-banner--error" role="alert">
|
||
识别车牌号与年审车牌号不一致,请重新上传行驶证照片
|
||
</div>
|
||
)}
|
||
{licenseForm.ocrStatus === 'done' && licenseForm.inspectionValidUntil && (
|
||
<div className="ar-ocr-banner ar-ocr-banner--done">
|
||
已根据行驶证照片识别检验有效期(精确到月,已自动补全至月末)
|
||
</div>
|
||
)}
|
||
<PhotoUploadBlock
|
||
label="行驶证照片"
|
||
required
|
||
fileList={licenseForm.photos}
|
||
onChange={handleLicensePhotosChange}
|
||
maxPhotos={MAX_LICENSE_PHOTOS}
|
||
/>
|
||
<div className="ar-form-grid" style={{ marginTop: 4 }}>
|
||
<FormField label="检验有效期" required>
|
||
<MiniSingleDatePicker
|
||
inForm
|
||
value={licenseForm.inspectionValidUntil}
|
||
onChange={(v) => setLicense('inspectionValidUntil', v)}
|
||
placeholder="上传照片自动识别"
|
||
title="检验有效期"
|
||
hint="可调整年月日,精确到日"
|
||
disabled={licenseForm.ocrStatus === 'recognizing'}
|
||
/>
|
||
</FormField>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="ar-section ar-section--form">
|
||
<div className="ar-section-title">检测服务站信息</div>
|
||
<div className="ar-form-grid">
|
||
<FormField label="检测服务站">
|
||
<Select
|
||
bordered={false}
|
||
showSearch
|
||
allowClear
|
||
placeholder="请选择检测服务站"
|
||
value={inspectionForm.station || undefined}
|
||
onChange={(v) => setInspection('station', v || '')}
|
||
options={inspectionStationOptions}
|
||
/>
|
||
</FormField>
|
||
<FormField label="费用(元)" required>
|
||
<InputNumber
|
||
bordered={false}
|
||
controls={false}
|
||
min={0}
|
||
precision={2}
|
||
placeholder="请输入费用"
|
||
value={inspectionForm.cost === '' ? null : Number(inspectionForm.cost)}
|
||
onChange={(v) => setInspection('cost', v == null ? '' : String(v))}
|
||
/>
|
||
</FormField>
|
||
<div className="ar-form-row ar-form-row--remark-block">
|
||
<div className="ar-form-control">
|
||
{TextArea ? (
|
||
<TextArea
|
||
bordered={false}
|
||
rows={2}
|
||
placeholder="请输入检测备注信息"
|
||
value={inspectionForm.remark}
|
||
onChange={(e) => setInspection('remark', e.target.value)}
|
||
/>
|
||
) : (
|
||
<Input
|
||
bordered={false}
|
||
placeholder="请输入检测备注信息"
|
||
value={inspectionForm.remark}
|
||
onChange={(e) => setInspection('remark', e.target.value)}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{!m2Expanded ? (
|
||
<button
|
||
type="button"
|
||
className={`ar-add-btn${
|
||
m2Form.station || m2Form.cost || m2Form.remark || (m2Form.photos || []).length ? ' filled' : ''
|
||
}`}
|
||
onClick={() => setM2Expanded(true)}
|
||
>
|
||
添加二保信息
|
||
</button>
|
||
) : (
|
||
<div className="ar-section ar-section--form">
|
||
<div className="ar-section-title-row">
|
||
<span className="ar-section-title-text">二保信息</span>
|
||
<button type="button" className="ar-section-fold" onClick={() => setM2Expanded(false)}>
|
||
收起
|
||
</button>
|
||
</div>
|
||
<div className="ar-form-grid">
|
||
<FormField label="二保服务站">
|
||
<Select
|
||
bordered={false}
|
||
showSearch
|
||
allowClear
|
||
placeholder="请选择二保服务站"
|
||
value={m2Form.station || undefined}
|
||
onChange={(v) => setM2('station', v || '')}
|
||
options={inspectionStationOptions}
|
||
/>
|
||
</FormField>
|
||
<FormField label="费用(元)" required={!!m2Form.station}>
|
||
<InputNumber
|
||
bordered={false}
|
||
controls={false}
|
||
min={0}
|
||
precision={2}
|
||
placeholder={m2Form.station ? '请选择站点后必填' : '请输入费用'}
|
||
value={m2Form.cost === '' ? null : Number(m2Form.cost)}
|
||
onChange={(v) => setM2('cost', v == null ? '' : String(v))}
|
||
/>
|
||
</FormField>
|
||
<div className="ar-form-row ar-form-row--remark-block">
|
||
<div className="ar-form-control">
|
||
{TextArea ? (
|
||
<TextArea
|
||
bordered={false}
|
||
rows={2}
|
||
placeholder="请输入二保备注信息"
|
||
value={m2Form.remark}
|
||
onChange={(e) => setM2('remark', e.target.value)}
|
||
/>
|
||
) : (
|
||
<Input
|
||
bordered={false}
|
||
placeholder="请输入二保备注信息"
|
||
value={m2Form.remark}
|
||
onChange={(e) => setM2('remark', e.target.value)}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<PhotoUploadBlock
|
||
label="二保照片"
|
||
fileList={m2Form.photos}
|
||
onChange={(fl) => setM2('photos', fl)}
|
||
/>
|
||
</div>
|
||
)}
|
||
|
||
{!zbExpanded ? (
|
||
<button
|
||
type="button"
|
||
className={`ar-add-btn${
|
||
zbForm.station || zbForm.cost || zbForm.remark || (zbForm.photos || []).length ? ' filled' : ''
|
||
}`}
|
||
onClick={() => setZbExpanded(true)}
|
||
>
|
||
添加整备服务站信息
|
||
</button>
|
||
) : (
|
||
<div className="ar-section ar-section--form">
|
||
<div className="ar-section-title-row">
|
||
<span className="ar-section-title-text">整备服务站信息</span>
|
||
<button type="button" className="ar-section-fold" onClick={() => setZbExpanded(false)}>
|
||
收起
|
||
</button>
|
||
</div>
|
||
<div className="ar-form-grid">
|
||
<FormField label="整备服务站">
|
||
<Select
|
||
bordered={false}
|
||
showSearch
|
||
allowClear
|
||
placeholder="请从维修站列表选择"
|
||
value={zbForm.station || undefined}
|
||
onChange={(v) => setZb('station', v || '')}
|
||
options={repairStationOptions}
|
||
/>
|
||
</FormField>
|
||
<FormField label="费用(元)" required={!!zbForm.station}>
|
||
<InputNumber
|
||
bordered={false}
|
||
controls={false}
|
||
min={0}
|
||
precision={2}
|
||
placeholder={zbForm.station ? '请选择站点后必填' : '请输入费用'}
|
||
value={zbForm.cost === '' ? null : Number(zbForm.cost)}
|
||
onChange={(v) => setZb('cost', v == null ? '' : String(v))}
|
||
/>
|
||
</FormField>
|
||
<div className="ar-form-row ar-form-row--remark-block">
|
||
<div className="ar-form-control">
|
||
{TextArea ? (
|
||
<TextArea
|
||
bordered={false}
|
||
rows={2}
|
||
placeholder="请输入整备备注信息"
|
||
value={zbForm.remark}
|
||
onChange={(e) => setZb('remark', e.target.value)}
|
||
/>
|
||
) : (
|
||
<Input
|
||
bordered={false}
|
||
placeholder="请输入整备备注信息"
|
||
value={zbForm.remark}
|
||
onChange={(e) => setZb('remark', e.target.value)}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<PhotoUploadBlock
|
||
label="整备照片"
|
||
fileList={zbForm.photos}
|
||
onChange={(fl) => setZb('photos', fl)}
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="ar-operate-foot">
|
||
<div className="ar-operate-foot-btns">
|
||
<Button size="large" disabled={licenseRecognizing} onClick={saveOperate}>
|
||
保存
|
||
</Button>
|
||
<Button
|
||
type="primary"
|
||
size="large"
|
||
disabled={licenseRecognizing}
|
||
onClick={submitOperate}
|
||
style={{
|
||
fontWeight: 700,
|
||
background: licenseRecognizing
|
||
? undefined
|
||
: `linear-gradient(135deg, ${COLOR_PRIMARY} 0%, ${COLOR_PRIMARY_DEEP} 100%)`,
|
||
border: 'none'
|
||
}}
|
||
>
|
||
提交
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{licenseRecognizing && (
|
||
<div className="ar-ocr-mask" role="alertdialog" aria-busy="true" aria-live="polite" aria-label="行驶证识别中">
|
||
<div className="ar-ocr-mask-panel">
|
||
<span className="ar-ocr-dot" aria-hidden="true" />
|
||
<div className="ar-ocr-mask-title">识别中</div>
|
||
<div className="ar-ocr-mask-desc">正在识别行驶证信息,请稍候完成后再提交</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const phoneMain = (
|
||
<>
|
||
{historyViewTask ? (
|
||
renderHistoryDetailPage()
|
||
) : operateTask ? (
|
||
renderOperatePage()
|
||
) : (
|
||
<>
|
||
<div className="ar-tabs" role="tablist">
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={mainTab === 'pending'}
|
||
className={`ar-tab${mainTab === 'pending' ? ' active' : ''}`}
|
||
onClick={() => setMainTab('pending')}
|
||
>
|
||
待处理
|
||
</button>
|
||
<button
|
||
type="button"
|
||
role="tab"
|
||
aria-selected={mainTab === 'history'}
|
||
className={`ar-tab${mainTab === 'history' ? ' active' : ''}`}
|
||
onClick={() => setMainTab('history')}
|
||
>
|
||
历史记录
|
||
</button>
|
||
</div>
|
||
|
||
<div className="ar-search-row">
|
||
<div
|
||
className="ar-search-box"
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={openPlateKeyboard}
|
||
onKeyDown={(e) => e.key === 'Enter' && openPlateKeyboard()}
|
||
>
|
||
{!searchPlate ? (
|
||
<span className="ar-search-placeholder" aria-hidden="true">
|
||
请输入车牌号
|
||
</span>
|
||
) : null}
|
||
<input
|
||
readOnly
|
||
value={searchPlate}
|
||
aria-label="车牌号搜索"
|
||
onFocus={(e) => {
|
||
e.target.blur();
|
||
openPlateKeyboard();
|
||
}}
|
||
/>
|
||
</div>
|
||
<button type="button" className="ar-filter-btn" aria-label="打开筛选" onClick={openFilter}>
|
||
<IconFilter />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="ar-list">
|
||
{filteredTasks.length === 0 ? (
|
||
<div className="ar-empty">暂无符合条件的年审任务<br />请调整搜索或筛选条件</div>
|
||
) : (
|
||
filteredTasks.map(renderTaskCard)
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
<PlateKeyboardPanel
|
||
open={plateKbOpen && !operateTask && !historyViewTask}
|
||
value={plateKbDraft}
|
||
onChange={setPlateKbDraft}
|
||
onConfirm={confirmPlateKeyboard}
|
||
onClose={closePlateKeyboard}
|
||
/>
|
||
</>
|
||
);
|
||
|
||
const moduleOverlays = (
|
||
<>
|
||
<Drawer
|
||
title="筛选"
|
||
placement="right"
|
||
width={Math.min(340, typeof window !== 'undefined' ? window.innerWidth * 0.86 : 320)}
|
||
open={filterOpen}
|
||
onClose={() => setFilterOpen(false)}
|
||
styles={{ body: { padding: '12px 16px 0' } }}
|
||
footer={
|
||
<div className="ar-drawer-foot">
|
||
<Button block size="large" onClick={resetFilter} style={{ height: 46, borderRadius: 8 }}>
|
||
重置
|
||
</Button>
|
||
<Button
|
||
block
|
||
type="primary"
|
||
size="large"
|
||
onClick={applyFilter}
|
||
style={{
|
||
height: 46,
|
||
borderRadius: 8,
|
||
background: theme === 'xll'
|
||
? `linear-gradient(135deg, ${XLL_GREEN} 0%, ${XLL_GREEN_DEEP} 100%)`
|
||
: `linear-gradient(135deg, ${COLOR_PRIMARY} 0%, ${COLOR_PRIMARY_DEEP} 100%)`,
|
||
border: 'none'
|
||
}}
|
||
>
|
||
确定
|
||
</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<FilterRow label="到期时间">
|
||
<MiniDateRangePicker
|
||
value={filterDraft.expireRange}
|
||
onChange={(v) => setFilterDraft((p) => ({ ...p, expireRange: v }))}
|
||
placeholder="请选择开始-结束日期"
|
||
title="选择到期时间"
|
||
hint="切换开始/结束,滑动选择到期日期(精确到日)"
|
||
/>
|
||
</FilterRow>
|
||
<FilterRow label="运营区域">
|
||
<MiniRegionPicker
|
||
value={filterDraft.provinceCity}
|
||
onChange={(v) => setFilterDraft((p) => ({ ...p, provinceCity: v }))}
|
||
placeholder="请选择省市"
|
||
/>
|
||
</FilterRow>
|
||
{mainTab === 'history' && (
|
||
<>
|
||
<div className="ar-filter-section-label">办理记录筛选</div>
|
||
<FilterRow label="办理人">
|
||
<MiniOptionPicker
|
||
value={filterDraft.handler}
|
||
onChange={(v) => setFilterDraft((p) => ({ ...p, handler: v }))}
|
||
options={HANDLER_OPTIONS}
|
||
placeholder="请选择办理人"
|
||
title="选择办理人"
|
||
clearLabel="全部"
|
||
/>
|
||
</FilterRow>
|
||
<FilterRow label="完成时间">
|
||
<MiniDateRangePicker
|
||
value={filterDraft.executeTimeRange}
|
||
onChange={(v) => setFilterDraft((p) => ({ ...p, executeTimeRange: v }))}
|
||
placeholder="请选择开始-结束日期"
|
||
title="选择完成时间"
|
||
hint="切换开始/结束,滑动选择完成日期(精确到日)"
|
||
/>
|
||
</FilterRow>
|
||
</>
|
||
)}
|
||
</Drawer>
|
||
|
||
<Modal
|
||
open={embedded ? false : prdOpen}
|
||
title="年审管理 · 产品需求说明"
|
||
onCancel={() => setPrdOpen(false)}
|
||
footer={[
|
||
<Button
|
||
key="ok"
|
||
type="primary"
|
||
onClick={() => setPrdOpen(false)}
|
||
style={{
|
||
background: theme === 'xll' ? XLL_GREEN_DEEP : COLOR_PRIMARY_DEEP,
|
||
borderColor: theme === 'xll' ? XLL_GREEN_DEEP : COLOR_PRIMARY_DEEP,
|
||
}}
|
||
>
|
||
知道了
|
||
</Button>
|
||
]}
|
||
width={420}
|
||
centered
|
||
bodyStyle={{ maxHeight: '72vh', overflowY: 'auto', paddingTop: 8 }}
|
||
>
|
||
<AnnualReviewPrdDoc />
|
||
</Modal>
|
||
</>
|
||
);
|
||
|
||
if (embedded) {
|
||
return (
|
||
<div className={`ar-embed-root${themeClass}`}>
|
||
<style>{embedStyles}</style>
|
||
{phoneMain}
|
||
{moduleOverlays}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="ar-mini-root">
|
||
<style>{PAGE_STYLE}</style>
|
||
<div className="ar-phone">
|
||
<MiniProgramChrome
|
||
title={historyViewTask ? '年审记录' : operateTask ? '年审操作' : '年审'}
|
||
showBack={!!operateTask || !!historyViewTask}
|
||
onBack={historyViewTask ? closeHistoryViewPage : closeOperatePage}
|
||
showPrdLink={!operateTask && !historyViewTask}
|
||
onPrdClick={() => (onOpenPrd ? onOpenPrd() : setPrdOpen(true))}
|
||
/>
|
||
<div className="ar-embed-root">{phoneMain}</div>
|
||
</div>
|
||
{moduleOverlays}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
const Component = function AnnualReviewMiniApp() {
|
||
return <AnnualReviewPanel embedded={false} />;
|
||
};
|
||
|
||
if (typeof window !== 'undefined') {
|
||
window.Component = Component;
|
||
window.ONEOS_MP_EMBED = window.ONEOS_MP_EMBED || {};
|
||
window.ONEOS_MP_EMBED.AnnualReviewPanel = AnnualReviewPanel;
|
||
}
|
||
|
||
export default Component;
|