新增任务管理与报警管理前端,并接入车队健康数据。
运营菜单下挂地图监控/任务/报警入口,车辆卡片与任务列表同步展示运行态。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
/** 从反射 KV / 投影数值解析电量,统一成 0–1 比例(未知返回 null)。 */
|
||||
|
||||
const SOC_KEY_RE = /(^|_)(Soc|SOC|batterySoc|BatterySoc|电量)$/i
|
||||
|
||||
export function parseSocNumber(raw: unknown): number | null {
|
||||
if (raw == null || raw === '') return null
|
||||
if (typeof raw === 'number') {
|
||||
if (!Number.isFinite(raw)) return null
|
||||
return normalizeSocRatio(raw)
|
||||
}
|
||||
const s = String(raw).trim().replace(/%$/, '')
|
||||
const n = Number(s)
|
||||
if (!Number.isFinite(n)) return null
|
||||
return normalizeSocRatio(n)
|
||||
}
|
||||
|
||||
/** 接受 0–1 或 0–100,输出 0–1。 */
|
||||
export function normalizeSocRatio(n: number): number {
|
||||
const ratio = n > 1 ? n / 100 : n
|
||||
return Math.max(0, Math.min(1, ratio))
|
||||
}
|
||||
|
||||
export function socToPercent(ratio: number): number {
|
||||
return Math.max(0, Math.min(100, Math.round(ratio <= 1 ? ratio * 100 : ratio)))
|
||||
}
|
||||
|
||||
export function extractSocFromKv(
|
||||
rows: Array<{ key?: string | null; value?: string | null }> | null | undefined
|
||||
): number | null {
|
||||
if (!rows?.length) return null
|
||||
const preferred = ['车体_Soc', 'Soc', 'batterySoc', 'BatterySoc', '电量']
|
||||
for (const key of preferred) {
|
||||
const hit = rows.find((r) => r.key === key)
|
||||
const parsed = parseSocNumber(hit?.value)
|
||||
if (parsed != null) return parsed
|
||||
}
|
||||
for (const r of rows) {
|
||||
if (!r.key || !SOC_KEY_RE.test(r.key)) continue
|
||||
const parsed = parseSocNumber(r.value)
|
||||
if (parsed != null) return parsed
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function extractSocFromFieldMap(map: Record<string, string> | null | undefined): number | null {
|
||||
if (!map) return null
|
||||
const preferred = ['车体_Soc', 'Soc', 'batterySoc', 'BatterySoc', '电量', 'battery']
|
||||
for (const key of preferred) {
|
||||
const parsed = parseSocNumber(map[key])
|
||||
if (parsed != null) return parsed
|
||||
}
|
||||
for (const [key, value] of Object.entries(map)) {
|
||||
if (!SOC_KEY_RE.test(key)) continue
|
||||
const parsed = parseSocNumber(value)
|
||||
if (parsed != null) return parsed
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Car, CarState } from '@/types/car'
|
||||
import { extractSocFromKv, normalizeSocRatio, parseSocNumber } from '@/utils/batterySoc'
|
||||
|
||||
type Kv = { key?: string | null; value?: string | null }
|
||||
|
||||
function kv(rows: Kv[] | null | undefined, ...keys: string[]): string {
|
||||
if (!rows?.length) return ''
|
||||
for (const key of keys) {
|
||||
const hit = rows.find((r) => r.key === key)
|
||||
if (hit?.value != null && hit.value !== '') return String(hit.value)
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* SimpleLite /projection/cars 会把「正常但未初始化」「导航失联」等一律标成 fault,
|
||||
* 与地图标签/车体 AlarmLevel 不一致。这里用 lstatus + 车体 status 重新推导。
|
||||
*/
|
||||
export function deriveCarState(car: Pick<Car, 'state' | 'lstatus'>, statusRows?: Kv[] | null): CarState {
|
||||
const lstatus = (car.lstatus ?? '').trim()
|
||||
const alarmLevelRaw = kv(statusRows, '车体_AlarmLevel', 'AlarmLevel')
|
||||
const alarmInfo = kv(statusRows, '车体_AlarmInfo', 'AlarmInfo').trim()
|
||||
const riskAlarm = kv(statusRows, '车体_RiskPositionAlarm', 'RiskPositionAlarm')
|
||||
const drive = kv(statusRows, '车体_driveStatus', 'driveStatus')
|
||||
const openCharge = kv(statusRows, '车体_OpenChargeByClumsy', 'OpenChargeByClumsy')
|
||||
|
||||
const alarmLevel = Number(alarmLevelRaw)
|
||||
const hasAlarm =
|
||||
(Number.isFinite(alarmLevel) && alarmLevel > 0) ||
|
||||
(/true/i.test(riskAlarm)) ||
|
||||
(alarmInfo.length > 0 && !/^\/$|^-$|^none$/i.test(alarmInfo))
|
||||
|
||||
// 明确健康:以「正常」开头(含「正常但未初始化」)→ 绝不是故障
|
||||
if (/^正常/.test(lstatus)) {
|
||||
if (/充电/.test(lstatus)) return 'charging'
|
||||
if (/暂停|挂起/.test(lstatus)) return 'paused'
|
||||
if (/离线/.test(lstatus)) return 'offline'
|
||||
if (hasAlarm) return 'fault'
|
||||
if (/charg|充电/i.test(openCharge) && /true/i.test(openCharge)) return 'charging'
|
||||
if (/Drive(Run|Move|Busy)|Running|运行/i.test(drive)) return 'running'
|
||||
if (/DriveStop|Idle|空闲|停止/i.test(drive)) return 'idle'
|
||||
// 未初始化但仍「正常」:视为空闲,而不是故障
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
if (/离线|失联/.test(lstatus)) return 'offline'
|
||||
if (/充电/.test(lstatus)) return 'charging'
|
||||
if (/暂停|挂起/.test(lstatus)) return 'paused'
|
||||
if (hasAlarm || (/故障|异常|检修/.test(lstatus) && !/正常/.test(lstatus))) return 'fault'
|
||||
if (/运行|工作|执行|忙/.test(lstatus)) return 'running'
|
||||
if (/空闲|待机/.test(lstatus)) return 'idle'
|
||||
|
||||
// 有车体 status 时,别盲信投影里的 fault
|
||||
if (statusRows?.length) {
|
||||
if (hasAlarm) return 'fault'
|
||||
if (/Drive(Run|Move|Busy)|Running/i.test(drive)) return 'running'
|
||||
if (/DriveStop|Idle/i.test(drive)) return 'idle'
|
||||
}
|
||||
|
||||
// 投影已给非 fault 时保留;fault 且无佐证则降为 idle,避免「全员故障」误报
|
||||
if (car.state && car.state !== 'fault') return car.state
|
||||
return 'idle'
|
||||
}
|
||||
|
||||
export function applyRuntimeEnrichment(car: Car, statusRows?: Kv[] | null): Car {
|
||||
const socFromStatus = extractSocFromKv(statusRows)
|
||||
let batterySoc: number
|
||||
if (socFromStatus != null) {
|
||||
batterySoc = normalizeSocRatio(socFromStatus)
|
||||
} else if (statusRows != null) {
|
||||
// 已拉到 status 但无 Soc(如纯模拟车)→ 清掉投影写死的 0.8,避免假电量
|
||||
batterySoc = 0
|
||||
} else {
|
||||
batterySoc = parseSocNumber(car.batterySoc) ?? 0
|
||||
}
|
||||
|
||||
return {
|
||||
...car,
|
||||
batterySoc,
|
||||
state: deriveCarState(car, statusRows)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export const dateShortcuts = [
|
||||
{ text: '今天', value: () => { const s = formatDate(new Date()); return [s, s] as [string, string] } },
|
||||
{ text: '近 7 天', value: () => rangeDays(6) },
|
||||
{ text: '近 30 天', value: () => rangeDays(29) }
|
||||
]
|
||||
|
||||
export function rangeDays(n: number): [string, string] {
|
||||
const end = new Date()
|
||||
const start = new Date()
|
||||
start.setDate(end.getDate() - n)
|
||||
return [formatDate(start), formatDate(end)]
|
||||
}
|
||||
|
||||
export function formatDate(d: Date): string {
|
||||
const p = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`
|
||||
}
|
||||
|
||||
export function parseTime(raw?: string | null): number | null {
|
||||
if (!raw) return null
|
||||
const t = Date.parse(raw)
|
||||
return Number.isFinite(t) ? t : null
|
||||
}
|
||||
|
||||
export function formatTime(raw?: string | null): string {
|
||||
const t = parseTime(raw)
|
||||
if (t == null) return '—'
|
||||
const d = new Date(t)
|
||||
const p = (n: number) => String(n).padStart(2, '0')
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
||||
}
|
||||
|
||||
export function sortByTime(a?: string | null, b?: string | null) {
|
||||
return (parseTime(a) ?? 0) - (parseTime(b) ?? 0)
|
||||
}
|
||||
|
||||
export function isToday(raw?: string | null): boolean {
|
||||
const t = parseTime(raw)
|
||||
if (t == null) return false
|
||||
const d = new Date(t)
|
||||
const now = new Date()
|
||||
return d.getFullYear() === now.getFullYear() && d.getMonth() === now.getMonth() && d.getDate() === now.getDate()
|
||||
}
|
||||
Reference in New Issue
Block a user