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

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
@@ -44,22 +44,44 @@
<span>智能调度 · 一站式平台</span>
</div>
<h1 class="hero-title">迷毂智能调度平台</h1>
<p class="hero-desc">以下是系统快捷入口也可以通过点击添加功能进行调整</p>
<p class="hero-desc">长按图标拖动可交换位置点击添加新增入口</p>
<div class="quick-grid">
<button
v-for="(item, idx) in quickEntries"
:key="item.key"
class="quick-item"
:class="{ primary: item.primary }"
:style="{ animationDelay: `${idx * 60}ms` }"
:title="item.hint ?? item.label"
@click="onQuickClick(item)">
<span class="quick-icon">
<el-icon :size="22"><component :is="item.icon" /></el-icon>
</span>
<span class="quick-label">{{ item.label }}</span>
</button>
<div class="quick-grid" :class="{ 'is-dragging': dragKey }">
<div
v-for="(row, rowIdx) in quickEntryRowList"
:key="rowIdx"
class="quick-row"
>
<button
v-for="(item, idx) in row"
:key="item.key"
type="button"
class="quick-item"
:class="{
primary: item.primary,
'is-add': item.key === 'add',
'is-dragging-source': dragKey === item.key,
'is-drag-hover-target': hoverTargetKey === item.key
}"
:data-quick-key="item.key"
:style="{ animationDelay: `${(rowIdx === 0 ? 0 : quickEntryRows.top.length) + idx * 60}ms` }"
:title="quickItemTitle(item)"
@pointerdown="onQuickPointerDown(item, $event)"
@click="onQuickClick(item)"
>
<span
v-if="item.key !== 'add'"
class="quick-item-remove"
title="移除"
@pointerdown.stop
@click.stop="onRemoveQuick(item.key)"
>×</span>
<span class="quick-icon">
<el-icon :size="22"><component :is="item.icon" /></el-icon>
</span>
<span class="quick-label">{{ item.label }}</span>
</button>
</div>
</div>
</div>
@@ -334,18 +356,41 @@
</el-card>
</section>
</div>
<QuickEntryPickerDialog
v-model="pickerOpen"
:items="pickerCatalog"
:pinned-keys="pinnedKeys"
:can-add="canAddMore"
@pick="onAddQuick" />
<Teleport to="body">
<div
v-if="dragKey && dragGhostItem"
class="quick-drag-ghost"
:style="{ left: `${ghostPos.x}px`, top: `${ghostPos.y}px` }"
>
<span class="quick-icon">
<el-icon :size="22"><component :is="dragGhostItem.icon" /></el-icon>
</span>
<span class="quick-label">{{ dragGhostItem.label }}</span>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import * as echarts from 'echarts'
import { ElMessage } from 'element-plus'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
Avatar, Bell, Box, Coordinate, Cpu, Lightning,
List, Operation, PieChart, Plus, Setting,
Tools, TrendCharts, Van, Warning
Bell, Box, Coordinate, Cpu, Lightning,
Operation, PieChart, Plus,
TrendCharts, Van
} from '@element-plus/icons-vue'
import QuickEntryPickerDialog from '@/components/dashboard/QuickEntryPickerDialog.vue'
import { useDashboardQuickEntries } from '@/composables/useDashboardQuickEntries'
import { useQuickEntryDragSwap } from '@/composables/useQuickEntryDragSwap'
import { listCars, listMissions, listSites, listTracks } from '@/api/projection'
import type { Site, Track } from '@/types/map'
import type { Car } from '@/types/car'
@@ -353,6 +398,112 @@ import type { Mission } from '@/types/mission'
const router = useRouter()
const {
resolvedEntries,
pickerCatalog,
pinnedKeys,
pickerOpen,
canAddMore,
openPicker,
addKey,
removeKey,
swapKeys
} = useDashboardQuickEntries()
interface QuickTile {
key: string
label: string
icon: unknown
hint?: string
primary?: boolean
path?: string
}
const displayQuickEntries = computed<QuickTile[]>(() => {
const items: QuickTile[] = resolvedEntries.value.map((e) => ({
key: e.key,
label: e.label,
icon: e.icon,
hint: e.hint,
primary: e.primary,
path: e.path
}))
if (canAddMore.value) {
items.push({
key: 'add',
label: '添加',
icon: Plus,
hint: '从菜单添加快捷入口'
})
}
return items
})
/** 上下两行均衡分布:5 个 → 上 3 / 下 2,6 个 → 上 3 / 下 3 */
const quickEntryRows = computed(() => {
const all = displayQuickEntries.value
const topCount = Math.ceil(all.length / 2)
return {
top: all.slice(0, topCount),
bottom: all.slice(topCount)
}
})
const quickEntryRowList = computed(() => {
const { top, bottom } = quickEntryRows.value
return bottom.length ? [top, bottom] : [top]
})
const {
dragKey,
hoverTargetKey,
ghostPos,
onPointerDown: onQuickPointerDown,
shouldSuppressClick
} = useQuickEntryDragSwap(async (from, to) => {
await swapKeys(from, to)
ElMessage.success('已交换位置')
})
const dragGhostItem = computed(() => {
if (!dragKey.value) return null
return displayQuickEntries.value.find((i) => i.key === dragKey.value) ?? null
})
function quickItemTitle(item: QuickTile): string {
if (item.key === 'add') return item.hint ?? item.label
if (dragKey.value === item.key) return '拖动到目标图标上松开以交换位置'
return item.hint ?? item.label
}
async function onQuickClick(item: QuickTile) {
if (shouldSuppressClick()) return
if (item.key === 'add') {
openPicker()
return
}
if (item.path) router.push(item.path)
}
async function onAddQuick(key: string) {
await addKey(key)
}
async function onRemoveQuick(key: string) {
try {
await ElMessageBox.confirm('确定从快捷入口移除此项?', '移除快捷入口', {
type: 'warning',
confirmButtonText: '移除',
cancelButtonText: '取消'
})
await removeKey(key)
} catch {
/* cancel */
}
}
const sites = ref<Site[]>([])
const tracks = ref<Track[]>([])
const cars = ref<Car[]>([])
@@ -388,23 +539,6 @@ const activeMissionCount = computed(() =>
missions.value.filter((m) => m.status === 'running' || m.status === 'assigned').length
)
// ─── 快捷入口 ───
interface QuickEntry { key: string; label: string; icon: unknown; hint?: string; primary?: boolean; to?: string; action?: () => void }
const quickEntries = computed<QuickEntry[]>(() => [
{ key: 'platform-config', label: '平台配置', icon: Setting, primary: true, hint: '进入地图编辑 / 平台搭建(map-editor', to: '/admin/map-editor' },
{ key: 'mission', label: '任务编排', icon: Operation, to: '/admin/task-templates' },
{ key: 'cars', label: 'AGV 配置', icon: Van, to: '/admin/cars' },
{ key: 'auth', label: '权限配置', icon: Avatar, to: '/admin/config/system-center?tab=auth' },
{ key: 'system', label: '系统配置', icon: Tools, to: '/admin/config/system-center?tab=system' },
{ key: 'ops', label: '异常处理', icon: Warning, to: '/admin/config/ops-center?tab=ops' },
{ key: 'tasks', label: '任务管理', icon: List, to: '/admin/config/strategy?tab=task' },
{ key: 'add', label: '添加', icon: Plus, hint: '自定义快捷入口(待实现)', action: () => { ElMessage.info('自定义快捷入口 — 即将在 v1.8 中开放') } }
])
function onQuickClick(item: QuickEntry) {
if (item.action) item.action()
else if (item.to) router.push(item.to)
}
// ─── KPI(每张卡片含 trend + sparkline ───
type KpiTone = 'info' | 'success' | 'warning' | 'danger' | 'idle'
interface KpiTrend { dir: 'up' | 'down' | 'flat'; arrow: '↑' | '↓' | '→'; text: string }
@@ -830,12 +964,19 @@ onUnmounted(() => {
letter-spacing: 0.8px;
}
/* ───── 快捷入口栅格(按截图 8 项,两行 4 列 ───── */
/* ───── 快捷入口:上下两行均衡左对齐(最多 16 项 + 添加 ───── */
.quick-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 14px;
max-width: 1080px;
}
.quick-row {
display: flex;
flex-wrap: nowrap;
justify-content: flex-start;
gap: 14px 18px;
max-width: 540px;
}
.quick-item {
appearance: none;
@@ -843,6 +984,8 @@ onUnmounted(() => {
border: 1px solid rgba(var(--mg-accent-rgb), 0.35);
border-radius: 14px;
padding: 14px 10px 12px;
width: 118px;
flex: 0 0 auto;
display: flex; flex-direction: column; align-items: center; gap: 8px;
cursor: pointer;
color: #fff;
@@ -854,6 +997,84 @@ onUnmounted(() => {
0 4px 12px rgba(0, 0, 0, 0.25),
0 0 0 1px rgba(255, 255, 255, 0.06) inset;
}
.quick-item-remove {
position: absolute;
top: 6px;
right: 8px;
width: 18px;
height: 18px;
border-radius: 50%;
border: none;
background: rgba(0, 0, 0, 0.35);
color: rgba(255, 255, 255, 0.85);
font-size: 14px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
opacity: 0;
transition: opacity .2s, background .2s;
z-index: 2;
}
.quick-item:hover .quick-item-remove { opacity: 1; }
.quick-item-remove:hover {
background: rgba(var(--mg-status-danger-rgb), 0.75);
color: #fff;
}
.quick-item.is-add .quick-item-remove { display: none; }
.quick-grid.is-dragging {
user-select: none;
}
.quick-grid.is-dragging .quick-item:not(.is-add) {
cursor: grabbing;
}
.quick-item.is-dragging-source {
opacity: 0.35;
transform: scale(0.96);
}
.quick-item.is-drag-hover-target {
border-color: rgba(var(--mg-accent-rgb), 0.95);
background: rgba(var(--mg-primary-rgb), 0.35);
transform: translateY(-2px) scale(1.05);
box-shadow:
0 0 0 2px rgba(var(--mg-accent-rgb), 0.65),
0 12px 28px rgba(var(--mg-primary-rgb), 0.45);
}
.quick-drag-ghost {
position: fixed;
z-index: 9999;
pointer-events: none;
transform: translate(-50%, -50%);
width: 118px;
padding: 14px 10px 12px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
border-radius: 14px;
border: 1px solid rgba(var(--mg-accent-rgb), 0.85);
background: rgba(var(--mg-primary-rgb), 0.92);
color: #fff;
box-shadow:
0 16px 40px rgba(0, 0, 0, 0.45),
0 0 0 2px rgba(var(--mg-accent-rgb), 0.5);
}
.quick-drag-ghost .quick-icon {
width: 44px;
height: 44px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.12);
}
.quick-drag-ghost .quick-label {
font-size: 12px;
font-weight: 500;
text-align: center;
line-height: 1.3;
}
@keyframes quick-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
@@ -898,11 +1119,19 @@ onUnmounted(() => {
0 0 0 1px rgba(255, 255, 255, 0.35) inset;
}
.quick-label {
width: 100%;
font-size: 13px;
font-weight: 500;
line-height: 1.35;
text-align: center;
letter-spacing: 1.2px;
color: #fff;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4);
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.quick-item.primary {
border-color: rgba(var(--mg-accent-rgb), 0.75);
@@ -1551,8 +1780,12 @@ onUnmounted(() => {
.bento-agv { grid-column: span 12; }
.bento-ratio { grid-column: span 12; }
}
@media (max-width: 900px) {
.quick-item { width: 104px; }
.quick-row { gap: 10px 12px; flex-wrap: wrap; max-width: 100%; }
}
@media (max-width: 720px) {
.quick-grid { grid-template-columns: repeat(2, 1fr); }
.quick-item { width: 92px; }
.bento-kpi { grid-column: span 12; }
.agv-row { flex-direction: column; align-items: stretch; }
.agv-chart { width: 100%; height: 220px; }
@@ -58,20 +58,58 @@
<el-button size="small" :icon="Refresh" :loading="loading || healthLoading" @click="refreshAll">
刷新
</el-button>
<el-radio-group
v-model="viewMode"
size="small"
class="view-toggle"
@change="viewModeTouched = true"
>
<el-radio-button value="grid">卡片</el-radio-button>
<el-radio-button value="list">列表</el-radio-button>
</el-radio-group>
</div>
</div>
<div v-loading="loading && !cardModels.length" class="card-grid">
<VehicleHealthCard
v-for="v in filteredCards"
:key="v.id"
:vehicle="v"
:selected="selectedId === v.id"
:can-write="canWrite"
@select="selectedId = $event"
@maintenance-changed="refreshAll"
/>
<el-empty v-if="!filteredCards.length && !loading" description="无匹配车辆" />
<div
v-loading="loading && !cardModels.length"
class="vehicle-scroll"
:class="viewMode === 'list' ? 'is-list' : 'is-grid'"
>
<div v-if="viewMode === 'list' && sortedCards.length" class="list-head">
<span>车辆</span>
<span>状态</span>
<span>电量</span>
<span>IP</span>
<span>延迟</span>
<span>故障率</span>
<span>群组</span>
<span class="head-actions">操作</span>
</div>
<template v-if="viewMode === 'grid'">
<VehicleHealthCard
v-for="v in sortedCards"
:key="v.id"
:vehicle="v"
:selected="selectedId === v.id"
:can-write="canWrite"
@select="selectedId = $event"
@maintenance-changed="refreshAll"
/>
</template>
<template v-else>
<VehicleHealthRow
v-for="v in sortedCards"
:key="v.id"
:vehicle="v"
:selected="selectedId === v.id"
:can-write="canWrite"
@select="selectedId = $event"
@maintenance-changed="refreshAll"
/>
</template>
<el-empty v-if="!sortedCards.length && !loading" description="无匹配车辆" />
</div>
<FleetAllocationPanel :cars="cardModels" :can-write="canWrite" @saved="onFleetSaved" />
@@ -101,13 +139,14 @@ import { useRoute, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, ArrowDown } from '@element-plus/icons-vue'
import VehicleHealthCard from '@/components/fleet/VehicleHealthCard.vue'
import VehicleHealthRow from '@/components/fleet/VehicleHealthRow.vue'
import FleetAllocationPanel from '@/components/fleet/FleetAllocationPanel.vue'
import VehicleMaintenanceView from '@/views/admin/config/VehicleMaintenanceView.vue'
import FleetLifecycleView from '@/views/admin/config/FleetLifecycleView.vue'
import { useVehicleHub } from '@/composables/useVehicleHub'
import { useFleetGroups } from '@/composables/useFleetGroups'
import { setVehicleMaintenance, type VehicleMaintenanceMode } from '@/api/vehicleOps'
import type { CarState } from '@/types/car'
import type { CarState, VehicleCardModel } from '@/types/car'
import { useAuthStore } from '@/stores/auth'
const auth = useAuthStore()
@@ -147,6 +186,10 @@ const {
const { groups: fleetGroups, reload: reloadFleetGroups, fleetNameForCarId, regionForCarId } = useFleetGroups()
onMounted(() => void reloadFleetGroups())
const DENSE_THRESHOLD = 12
const viewMode = ref<'grid' | 'list'>('grid')
const viewModeTouched = ref(false)
const search = ref('')
const filterState = ref<CarState | ''>('')
const filterFleet = ref('')
@@ -191,11 +234,37 @@ const filteredCards = computed(() => {
})
})
function vehicleSortPriority(v: VehicleCardModel): number {
if (v.isAlarmActive) return 0
if (v.reachable === false) return 1
if (v.state === 'fault') return 2
if (v.maintenanceMode && v.maintenanceMode !== 'online') return 3
if (v.state === 'running') return 4
if (v.state === 'charging') return 5
return 6
}
const sortedCards = computed(() =>
[...filteredCards.value].sort((a, b) => {
const d = vehicleSortPriority(a) - vehicleSortPriority(b)
return d !== 0 ? d : a.name.localeCompare(b.name, 'zh-CN')
})
)
watch(
() => sortedCards.value.length,
(n) => {
if (viewModeTouched.value) return
viewMode.value = n > DENSE_THRESHOLD ? 'list' : 'grid'
},
{ immediate: true }
)
const selectedIds = computed(() => (selectedId.value ? [selectedId.value] : []))
async function onBatchCommand(cmd: string) {
const mode = cmd as VehicleMaintenanceMode
const targets = filteredCards.value.filter((c) => selectedId.value ? c.id === selectedId.value : true)
const targets = sortedCards.value.filter((c) => selectedId.value ? c.id === selectedId.value : true)
if (!targets.length) return
try {
await ElMessageBox.confirm(`${targets.length} 辆车执行「${cmd}」?`, '批量维护', { type: 'warning' })
@@ -317,16 +386,61 @@ async function onBatchCommand(cmd: string) {
width: 160px;
}
.card-grid {
.view-toggle {
margin-left: auto;
}
.vehicle-scroll {
flex: 1;
min-height: 0;
overflow: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px;
align-content: start;
padding-bottom: 8px;
}
.vehicle-scroll.is-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
grid-auto-rows: max-content;
gap: 14px;
align-content: start;
}
.vehicle-scroll.is-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.list-head {
display: grid;
grid-template-columns: minmax(140px, 1.4fr) 72px 80px 100px 64px 64px 56px 88px;
gap: 12px;
align-items: center;
padding: 4px 12px 4px 14px;
font-size: 10px;
font-weight: 600;
color: var(--mg-text-muted, rgba(255, 255, 255, 0.45));
letter-spacing: 0.3px;
flex-shrink: 0;
position: sticky;
top: 0;
z-index: 1;
background: rgba(var(--mg-bg-card-darker-rgb, 20, 12, 48), 0.92);
backdrop-filter: blur(8px);
border-radius: 8px;
}
.list-head .head-actions {
text-align: right;
}
@media (max-width: 900px) {
.list-head { display: none; }
.vehicle-scroll.is-grid {
grid-template-columns: 1fr;
}
}
.footnote {
flex-shrink: 0;
margin: 0;