优化小车卡片界面和车队编队的功能,优化首页菜单的快捷入口功能

This commit is contained in:
18086616529
2026-06-24 16:14:15 +08:00
parent 88c688c0df
commit 5fe85dc891
25 changed files with 2638 additions and 454 deletions
@@ -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
}
}