优化小车卡片界面和车队编队的功能,优化首页菜单的快捷入口功能
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { fetchQuickEntryKeys, saveQuickEntryKeys } from '@/api/dashboardQuickEntries'
|
||||
import {
|
||||
defaultQuickKeys, getQuickEntryCatalog, MAX_QUICK_ENTRIES,
|
||||
normalizeQuickKeys, resolveQuickEntry, type QuickEntryDef
|
||||
} from '@/config/quickEntries'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { Scope } from '@/types/auth'
|
||||
|
||||
export function useDashboardQuickEntries() {
|
||||
const auth = useAuthStore()
|
||||
const keys = ref<string[]>([])
|
||||
const loading = ref(false)
|
||||
const usingDefaults = ref(true)
|
||||
const pickerOpen = ref(false)
|
||||
|
||||
const scope = computed(() => auth.scope ?? 'Platform')
|
||||
const userId = computed(() => auth.user?.id ?? '')
|
||||
|
||||
/** 空列表仅在「尚未自定义」(usingDefaults) 时回退系统默认;用户主动清空则保持为空 */
|
||||
function effectiveKeys(): string[] {
|
||||
if (keys.value.length > 0) return keys.value
|
||||
return usingDefaults.value ? defaultQuickKeys(scope.value as Scope) : []
|
||||
}
|
||||
|
||||
function filterByPermission(list: string[]): string[] {
|
||||
return list.filter((key) => {
|
||||
const def = resolveQuickEntry(key, scope.value as Scope)
|
||||
return def && auth.hasPage(def.pageKey)
|
||||
})
|
||||
}
|
||||
|
||||
const resolvedEntries = computed<QuickEntryDef[]>(() => {
|
||||
return filterByPermission(effectiveKeys())
|
||||
.slice(0, MAX_QUICK_ENTRIES)
|
||||
.map((k) => resolveQuickEntry(k, scope.value as Scope))
|
||||
.filter((d): d is QuickEntryDef => !!d)
|
||||
})
|
||||
|
||||
const pinnedKeys = computed(() => filterByPermission(effectiveKeys()))
|
||||
|
||||
/** 弹窗展示全部可访问菜单(含已固定项,已固定项在弹窗内置灰不可选) */
|
||||
const pickerCatalog = computed(() =>
|
||||
getQuickEntryCatalog(scope.value as Scope).filter((item) => auth.hasPage(item.pageKey))
|
||||
)
|
||||
|
||||
const canAddMore = computed(() => pinnedKeys.value.length < MAX_QUICK_ENTRIES)
|
||||
|
||||
const availableToAdd = computed(() => {
|
||||
const current = new Set(pinnedKeys.value)
|
||||
return pickerCatalog.value.filter((item) => !current.has(item.key))
|
||||
})
|
||||
|
||||
async function load() {
|
||||
if (!userId.value) {
|
||||
keys.value = defaultQuickKeys(scope.value as Scope)
|
||||
usingDefaults.value = true
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const dto = await fetchQuickEntryKeys(userId.value, scope.value)
|
||||
const loaded = normalizeQuickKeys(dto.keys)
|
||||
keys.value = loaded.length > 0
|
||||
? loaded
|
||||
: (dto.usingDefaults ? defaultQuickKeys(scope.value as Scope) : [])
|
||||
usingDefaults.value = dto.usingDefaults
|
||||
} catch (e) {
|
||||
keys.value = defaultQuickKeys(scope.value as Scope)
|
||||
usingDefaults.value = true
|
||||
ElMessage.warning(`加载快捷入口失败:${e instanceof Error ? e.message : String(e)}`)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function persist(nextKeys: string[]) {
|
||||
if (!userId.value) {
|
||||
keys.value = nextKeys
|
||||
return
|
||||
}
|
||||
try {
|
||||
const dto = await saveQuickEntryKeys(userId.value, scope.value, normalizeQuickKeys(nextKeys))
|
||||
keys.value = normalizeQuickKeys(dto.keys)
|
||||
usingDefaults.value = dto.usingDefaults
|
||||
} catch (e) {
|
||||
ElMessage.error(`保存快捷入口失败:${e instanceof Error ? e.message : String(e)}`)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
async function addKey(key: string) {
|
||||
const base = [...effectiveKeys()]
|
||||
if (base.includes(key)) {
|
||||
ElMessage.info('该菜单已在快捷入口中')
|
||||
return
|
||||
}
|
||||
if (base.length >= MAX_QUICK_ENTRIES) {
|
||||
ElMessage.warning(`快捷入口最多 ${MAX_QUICK_ENTRIES} 个`)
|
||||
return
|
||||
}
|
||||
await persist([...base, key])
|
||||
ElMessage.success('已添加快捷入口')
|
||||
}
|
||||
|
||||
async function removeKey(key: string) {
|
||||
const base = [...effectiveKeys()]
|
||||
await persist(base.filter((k) => k !== key))
|
||||
ElMessage.success('已移除快捷入口')
|
||||
}
|
||||
|
||||
async function swapKeys(keyA: string, keyB: string) {
|
||||
if (keyA === keyB || keyA === 'add' || keyB === 'add') return
|
||||
const list = [...pinnedKeys.value]
|
||||
const i = list.indexOf(keyA)
|
||||
const j = list.indexOf(keyB)
|
||||
if (i < 0 || j < 0 || i === j) return
|
||||
;[list[i], list[j]] = [list[j], list[i]]
|
||||
await persist(list)
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
pickerOpen.value = true
|
||||
}
|
||||
|
||||
watch([userId, scope], () => { void load() }, { immediate: true })
|
||||
|
||||
return {
|
||||
keys,
|
||||
loading,
|
||||
usingDefaults,
|
||||
pickerOpen,
|
||||
resolvedEntries,
|
||||
pickerCatalog,
|
||||
pinnedKeys,
|
||||
availableToAdd,
|
||||
canAddMore,
|
||||
load,
|
||||
addKey,
|
||||
removeKey,
|
||||
swapKeys,
|
||||
openPicker
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { ref } from 'vue'
|
||||
|
||||
const LONG_PRESS_MS = 450
|
||||
const PRE_DRAG_MOVE_PX = 10
|
||||
|
||||
export interface QuickDragTile {
|
||||
key: string
|
||||
label: string
|
||||
icon: unknown
|
||||
primary?: boolean
|
||||
}
|
||||
|
||||
export function useQuickEntryDragSwap(
|
||||
swapKeys: (keyA: string, keyB: string) => Promise<void>
|
||||
) {
|
||||
const dragKey = ref<string | null>(null)
|
||||
const hoverTargetKey = ref<string | null>(null)
|
||||
const ghostPos = ref({ x: 0, y: 0 })
|
||||
|
||||
let pressTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let suppressClick = false
|
||||
let active = false
|
||||
|
||||
function clearPressTimer() {
|
||||
if (pressTimer) {
|
||||
clearTimeout(pressTimer)
|
||||
pressTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function findTargetKey(clientX: number, clientY: number, sourceKey: string): string | null {
|
||||
const el = document.elementFromPoint(clientX, clientY)
|
||||
const tile = el?.closest('[data-quick-key]') as HTMLElement | null
|
||||
const key = tile?.dataset.quickKey
|
||||
if (!key || key === 'add' || key === sourceKey) return null
|
||||
return key
|
||||
}
|
||||
|
||||
function onPointerDown(item: QuickDragTile, e: PointerEvent) {
|
||||
if (item.key === 'add' || e.button !== 0) return
|
||||
|
||||
const target = e.currentTarget as HTMLElement
|
||||
const startX = e.clientX
|
||||
const startY = e.clientY
|
||||
let dragging = false
|
||||
|
||||
clearPressTimer()
|
||||
active = true
|
||||
|
||||
const cleanup = () => {
|
||||
clearPressTimer()
|
||||
active = false
|
||||
dragging = false
|
||||
window.removeEventListener('pointermove', onMove)
|
||||
window.removeEventListener('pointerup', onUp)
|
||||
window.removeEventListener('pointercancel', onUp)
|
||||
}
|
||||
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
if (!dragging) {
|
||||
const dx = ev.clientX - startX
|
||||
const dy = ev.clientY - startY
|
||||
if (dx * dx + dy * dy > PRE_DRAG_MOVE_PX * PRE_DRAG_MOVE_PX) {
|
||||
clearPressTimer()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
ghostPos.value = { x: ev.clientX, y: ev.clientY }
|
||||
hoverTargetKey.value = findTargetKey(ev.clientX, ev.clientY, item.key)
|
||||
}
|
||||
|
||||
const onUp = async (ev: PointerEvent) => {
|
||||
clearPressTimer()
|
||||
|
||||
if (dragging) {
|
||||
suppressClick = true
|
||||
const from = item.key
|
||||
const to = hoverTargetKey.value ?? findTargetKey(ev.clientX, ev.clientY, from)
|
||||
dragKey.value = null
|
||||
hoverTargetKey.value = null
|
||||
if (to) {
|
||||
try {
|
||||
await swapKeys(from, to)
|
||||
} catch {
|
||||
/* persist failed */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cleanup()
|
||||
}
|
||||
|
||||
pressTimer = setTimeout(() => {
|
||||
pressTimer = null
|
||||
dragging = true
|
||||
suppressClick = false
|
||||
dragKey.value = item.key
|
||||
ghostPos.value = { x: e.clientX, y: e.clientY }
|
||||
hoverTargetKey.value = null
|
||||
try {
|
||||
target.setPointerCapture(e.pointerId)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, LONG_PRESS_MS)
|
||||
|
||||
window.addEventListener('pointermove', onMove)
|
||||
window.addEventListener('pointerup', onUp)
|
||||
window.addEventListener('pointercancel', onUp)
|
||||
}
|
||||
|
||||
function shouldSuppressClick(): boolean {
|
||||
if (!suppressClick) return false
|
||||
suppressClick = false
|
||||
return true
|
||||
}
|
||||
|
||||
return {
|
||||
dragKey,
|
||||
hoverTargetKey,
|
||||
ghostPos,
|
||||
onPointerDown,
|
||||
shouldSuppressClick
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { computed, toValue, type MaybeRefOrGetter } from 'vue'
|
||||
import type { VehicleCardModel } from '@/types/car'
|
||||
|
||||
export interface VehicleCardStateOptions {
|
||||
vehicle: MaybeRefOrGetter<VehicleCardModel>
|
||||
}
|
||||
|
||||
const stateLabels: Record<string, string> = {
|
||||
idle: '空闲',
|
||||
running: '运行',
|
||||
charging: '充电',
|
||||
paused: '暂停',
|
||||
fault: '故障',
|
||||
offline: '离线'
|
||||
}
|
||||
|
||||
function formatMissionIdSuffix(missionId?: string | number | null): string {
|
||||
if (missionId == null) return ''
|
||||
const id = String(missionId).trim()
|
||||
if (!id || id === '0') return ''
|
||||
return `-${id}`
|
||||
}
|
||||
|
||||
function appendMissionId(base: string, missionId?: string | number | null): string {
|
||||
const suffix = formatMissionIdSuffix(missionId)
|
||||
return suffix ? `${base}${suffix}` : base
|
||||
}
|
||||
|
||||
export function useVehicleCardState(opts: VehicleCardStateOptions) {
|
||||
const vehicle = computed(() => toValue(opts.vehicle))
|
||||
|
||||
const stateLabel = computed(() => stateLabels[vehicle.value.state] ?? vehicle.value.state)
|
||||
|
||||
const batteryPct = computed(() => {
|
||||
const raw = vehicle.value.batterySoc ?? 0
|
||||
const pct = raw > 1 ? raw : raw * 100
|
||||
return Math.max(0, Math.min(100, Math.round(pct)))
|
||||
})
|
||||
|
||||
const batteryTone = computed(() => {
|
||||
const p = batteryPct.value
|
||||
if (p < 20) return 'tone-danger'
|
||||
if (p < 50) return 'tone-warning'
|
||||
return 'tone-success'
|
||||
})
|
||||
|
||||
const switchOn = computed(() => vehicle.value.maintenanceMode === 'online')
|
||||
|
||||
const statusPill = computed(() => {
|
||||
const v = vehicle.value
|
||||
if (v.reachable === false) return { label: '不可达', tone: 'tone-danger' }
|
||||
if (v.isAlarmActive) return { label: '报警中', tone: 'tone-danger' }
|
||||
if (v.maintenanceMode === 'offline') return { label: '下线维护', tone: 'tone-warning' }
|
||||
if (v.maintenanceMode === 'repair') return { label: '现场检修', tone: 'tone-warning' }
|
||||
if (v.maintenanceMode === 'blown') return { label: '返厂检修', tone: 'tone-danger' }
|
||||
if (v.state === 'fault') return { label: '故障', tone: 'tone-danger' }
|
||||
if (v.state === 'running') return { label: '运行中', tone: 'tone-success' }
|
||||
if (v.state === 'charging') return { label: '充电中', tone: 'tone-info' }
|
||||
if (v.state === 'offline') return { label: '离线', tone: 'tone-idle' }
|
||||
return { label: stateLabel.value, tone: 'tone-idle' }
|
||||
})
|
||||
|
||||
const missionIdSuffix = computed(() => formatMissionIdSuffix(vehicle.value.missionId))
|
||||
|
||||
const statusDisplayLabel = computed(() =>
|
||||
appendMissionId(statusPill.value.label, vehicle.value.missionId)
|
||||
)
|
||||
|
||||
const runtimeStatusLabel = computed(() =>
|
||||
appendMissionId(vehicle.value.lstatus ?? stateLabel.value, vehicle.value.missionId)
|
||||
)
|
||||
|
||||
const accentTone = computed(() => statusPill.value.tone)
|
||||
|
||||
const latencyLabel = computed(() => {
|
||||
const ms = vehicle.value.latencyMs
|
||||
if (ms == null) return '—'
|
||||
if (vehicle.value.reachable === false) return '超时'
|
||||
return `${ms} ms`
|
||||
})
|
||||
|
||||
const latencyClass = computed(() => {
|
||||
const ms = vehicle.value.latencyMs
|
||||
if (vehicle.value.reachable === false) return 'val-danger'
|
||||
if (ms != null && ms > 80) return 'val-warn'
|
||||
return ''
|
||||
})
|
||||
|
||||
const faultLabel = computed(() => {
|
||||
const v = vehicle.value.faultRatePercent
|
||||
if (v == null) return '—'
|
||||
return `${v.toFixed(2)}%`
|
||||
})
|
||||
|
||||
const faultClass = computed(() => {
|
||||
const v = vehicle.value.faultRatePercent ?? 0
|
||||
if (v >= 5) return 'val-danger'
|
||||
if (v >= 1) return 'val-warn'
|
||||
return ''
|
||||
})
|
||||
|
||||
const cpuLabel = computed(() => {
|
||||
const v = vehicle.value.cpuPercent
|
||||
return v != null ? `${Math.round(v)}%` : '—'
|
||||
})
|
||||
|
||||
const memLabel = computed(() => {
|
||||
const v = vehicle.value.memPercent
|
||||
return v != null ? `${Math.round(v)}%` : '—'
|
||||
})
|
||||
|
||||
const cpuChipClass = computed(() => {
|
||||
const v = vehicle.value.cpuPercent
|
||||
if (v == null) return ''
|
||||
if (v >= 90) return 'val-danger'
|
||||
if (v >= 75) return 'val-warn'
|
||||
return 'val-ok'
|
||||
})
|
||||
|
||||
const memChipClass = computed(() => {
|
||||
const v = vehicle.value.memPercent
|
||||
if (v == null) return ''
|
||||
if (v >= 90) return 'val-danger'
|
||||
if (v >= 75) return 'val-warn'
|
||||
return 'val-ok'
|
||||
})
|
||||
|
||||
return {
|
||||
stateLabel,
|
||||
batteryPct,
|
||||
batteryTone,
|
||||
switchOn,
|
||||
statusPill,
|
||||
missionIdSuffix,
|
||||
statusDisplayLabel,
|
||||
runtimeStatusLabel,
|
||||
accentTone,
|
||||
latencyLabel,
|
||||
latencyClass,
|
||||
faultLabel,
|
||||
faultClass,
|
||||
cpuLabel,
|
||||
memLabel,
|
||||
cpuChipClass,
|
||||
memChipClass
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { computed, onMounted, onUnmounted, ref, shallowRef } from 'vue'
|
||||
import { listCars } from '@/api/projection'
|
||||
import { listCars, listMissions } from '@/api/projection'
|
||||
import { fetchFleetHealth } from '@/api/fleetHealth'
|
||||
import { useProjectionStream } from '@/composables/useProjectionStream'
|
||||
import type { Car, FleetHealthRow, VehicleCardModel } from '@/types/car'
|
||||
import type { Mission, MissionStatus } from '@/types/mission'
|
||||
|
||||
const CAR_POLL_MS = 5000
|
||||
const HEALTH_POLL_MS = 20000
|
||||
@@ -15,9 +16,41 @@ function inferMaintenanceMode(car: Car): VehicleCardModel['maintenanceMode'] {
|
||||
return 'online'
|
||||
}
|
||||
|
||||
function mergeCarHealth(car: Car, health?: FleetHealthRow): VehicleCardModel {
|
||||
function missionStatusOrder(status: MissionStatus): number {
|
||||
if (status === 'running') return 0
|
||||
if (status === 'paused') return 1
|
||||
if (status === 'assigned') return 2
|
||||
return 3
|
||||
}
|
||||
|
||||
function resolveMissionId(car: Car, missions: Mission[]): string | undefined {
|
||||
if (car.missionId) {
|
||||
const id = String(car.missionId).trim()
|
||||
if (id && id !== '0') return id
|
||||
}
|
||||
|
||||
const rawKey = car.rawId != null ? String(car.rawId) : undefined
|
||||
let best: Mission | undefined
|
||||
|
||||
for (const mission of missions) {
|
||||
if (!mission.carId) continue
|
||||
if (mission.carId !== car.id && mission.carId !== rawKey) continue
|
||||
if (mission.status !== 'running' && mission.status !== 'paused' && mission.status !== 'assigned') {
|
||||
continue
|
||||
}
|
||||
if (!best || missionStatusOrder(mission.status) < missionStatusOrder(best.status)) {
|
||||
best = mission
|
||||
}
|
||||
}
|
||||
|
||||
return best?.id
|
||||
}
|
||||
|
||||
function mergeCarHealth(car: Car, health?: FleetHealthRow, missions: Mission[] = []): VehicleCardModel {
|
||||
const missionId = resolveMissionId(car, missions)
|
||||
return {
|
||||
...car,
|
||||
missionId,
|
||||
ip: health?.ip ?? car.ip,
|
||||
onboardUrl: health?.onboardUrl ?? car.onboardUrl ?? (car.ip ? `http://${car.ip}:8081` : undefined),
|
||||
latencyMs: health?.latencyMs,
|
||||
@@ -32,6 +65,7 @@ function mergeCarHealth(car: Car, health?: FleetHealthRow): VehicleCardModel {
|
||||
|
||||
export function useVehicleHub() {
|
||||
const cars = shallowRef<Car[]>([])
|
||||
const missions = shallowRef<Mission[]>([])
|
||||
const healthRows = shallowRef<FleetHealthRow[]>([])
|
||||
const loading = ref(false)
|
||||
const healthLoading = ref(false)
|
||||
@@ -52,7 +86,7 @@ export function useVehicleHub() {
|
||||
cars.value.map((car) => {
|
||||
const rawId = car.rawId ?? parseInt(car.id.replace(/\D/g, ''), 10)
|
||||
const health = Number.isFinite(rawId) ? healthByCarId.value.get(rawId) : undefined
|
||||
return mergeCarHealth(car, health)
|
||||
return mergeCarHealth(car, health, missions.value)
|
||||
})
|
||||
)
|
||||
|
||||
@@ -67,7 +101,9 @@ export function useVehicleHub() {
|
||||
async function loadCars() {
|
||||
loading.value = true
|
||||
try {
|
||||
cars.value = await listCars()
|
||||
const [carList, missionList] = await Promise.all([listCars(), listMissions()])
|
||||
cars.value = carList
|
||||
missions.value = missionList
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -101,12 +137,13 @@ export function useVehicleHub() {
|
||||
|
||||
stream.on((evt) => {
|
||||
if (evt.kind === 'car-state' && evt.payload && typeof evt.payload === 'object') {
|
||||
const p = evt.payload as { rawId?: number; id?: string; state?: string; lstatus?: string }
|
||||
const p = evt.payload as { rawId?: number; id?: string; state?: string; lstatus?: string; missionId?: string }
|
||||
const rawId = p.rawId
|
||||
if (rawId != null) {
|
||||
patchCarFromStream(rawId, {
|
||||
state: p.state as Car['state'],
|
||||
lstatus: p.lstatus
|
||||
lstatus: p.lstatus,
|
||||
missionId: p.missionId
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { setVehicleMaintenance, type VehicleMaintenanceMode } from '@/api/vehicleOps'
|
||||
|
||||
export const MAINTENANCE_OPTIONS: { value: VehicleMaintenanceMode; label: string }[] = [
|
||||
{ value: 'online', label: '上线' },
|
||||
{ value: 'offline', label: '下线维护' },
|
||||
{ value: 'repair', label: '现场检修' },
|
||||
{ value: 'blown', label: '返厂检修' }
|
||||
]
|
||||
|
||||
export function maintenanceModeLabel(mode?: VehicleMaintenanceMode): string {
|
||||
return MAINTENANCE_OPTIONS.find((o) => o.value === mode)?.label ?? '上线'
|
||||
}
|
||||
|
||||
export async function confirmAndApplyMaintenance(
|
||||
rawId: number | undefined,
|
||||
mode: VehicleMaintenanceMode,
|
||||
prevMode: VehicleMaintenanceMode
|
||||
): Promise<boolean> {
|
||||
if (!Number.isFinite(rawId)) return false
|
||||
if (mode === prevMode) return false
|
||||
|
||||
try {
|
||||
if (mode === 'blown') {
|
||||
await ElMessageBox.confirm('返厂检修将停止调度并清空站点,确认?', '危险操作', {
|
||||
type: 'error',
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
} else if (mode === 'repair') {
|
||||
await ElMessageBox.confirm('现场检修:不调度但仍刷新状态,确认?', '维护确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
} else if (mode === 'online' || mode === 'offline') {
|
||||
await ElMessageBox.confirm(
|
||||
mode === 'online' ? '确认将车辆上线?' : '确认将车辆下线维护?',
|
||||
'维护确认',
|
||||
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }
|
||||
)
|
||||
}
|
||||
|
||||
const ok = await setVehicleMaintenance(rawId!, mode)
|
||||
if (ok) {
|
||||
ElMessage.success('维护状态已更新')
|
||||
return true
|
||||
}
|
||||
ElMessage.error('维护操作失败')
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user