新增任务管理与报警管理前端,并接入车队健康数据。

运营菜单下挂地图监控/任务/报警入口,车辆卡片与任务列表同步展示运行态。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zhaowei.huang
2026-07-26 11:31:52 +08:00
co-authored by Cursor
parent 7056cf0872
commit 6986370e9d
23 changed files with 2097 additions and 78 deletions
@@ -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)
}
/** 接受 01 或 0100,输出 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()
}