Files
ONE-OS/ONE-OS小程序/年审管理.jsx

4353 lines
130 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 【重要】必须使用 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';
/** 兼容 Axhubantd 可能是 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 &nbsp;|&nbsp; 小程序 &nbsp;|&nbsp; 模块运维 · 车辆年审
<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">正式环境需对接任务列表 APIOCR 服务图片上传 CDN提交/草稿接口</li>
<li className="ar-prd-li">
<strong>证照同步接口必接</strong>POST plateNoinspectionValidUntilphotoUrls[]
</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;