This commit is contained in:
ArtoriasWu
2026-06-24 16:25:58 +08:00
38 changed files with 1479 additions and 680 deletions
@@ -1,26 +1,50 @@
<template>
<div class="status-page mg-content">
<el-card shadow="never" class="status-card">
<el-card shadow="never" class="status-card" v-loading="loading">
<template #header>
<span>SimpleLite Service Status</span>
<el-tag type="success" effect="dark" style="margin-left: 8px">Web-Enabled (Mock)</el-tag>
<span>服务状态</span>
<el-tag v-if="health" type="success" effect="dark" style="margin-left: 8px">在线</el-tag>
<el-tag v-else-if="!loading" type="danger" effect="dark" style="margin-left: 8px">不可达</el-tag>
</template>
<el-descriptions :column="2" border>
<el-descriptions-item label="模式">Web-Enabled</el-descriptions-item>
<el-descriptions-item label="启动时间">{{ startTime }}</el-descriptions-item>
<el-descriptions-item label="运行时长">{{ uptime }}</el-descriptions-item>
<el-descriptions-item label="节点角色"><el-tag type="success">Active (ROSE)</el-tag></el-descriptions-item>
<el-descriptions-item label="WebAPI">http://0.0.0.0:7001 <el-tag size="small">OK</el-tag></el-descriptions-item>
<el-descriptions-item label="WebSocket">ws://0.0.0.0:7002 <el-tag size="small">OK</el-tag></el-descriptions-item>
<el-descriptions-item label="webVRender">http://0.0.0.0:8223 <el-tag size="small" type="success">OK</el-tag></el-descriptions-item>
<el-descriptions-item label="Platform.Server">:8080 (pid=12345)</el-descriptions-item>
<el-descriptions-item label="在线 Vue 客户端">admin=3, monitor=4</el-descriptions-item>
<el-descriptions-item label="调度循环 / 任务">50 Hz · 14 / 32</el-descriptions-item>
<el-descriptions :column="2" border title="MiGu.Server">
<el-descriptions-item label="状态">
<el-tag :type="health ? 'success' : 'danger'" size="small">{{ health ? 'ok' : 'unreachable' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="启动时间">{{ serverStartTime || '—' }}</el-descriptions-item>
<el-descriptions-item label="运行时长" :span="2">{{ serverUptime || '—' }}</el-descriptions-item>
</el-descriptions>
<el-descriptions v-if="sl" :column="2" border title="SimpleLite" class="status-block">
<el-descriptions-item label="托管启用">
<el-tag :type="sl.enabled ? 'success' : 'info'" size="small">{{ sl.enabled ? '是' : '否' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="进程运行中">
<el-tag :type="sl.isRunning ? 'success' : 'info'" size="small">{{ sl.isRunning ? '是' : '否(或外部启动)' }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="启动模式">{{ sl.lastLaunchMode || '—' }}</el-descriptions-item>
<el-descriptions-item :label="`投影端口 :${sl.projectionPort}`">
<el-tag :type="sl.projectionPortReachable ? 'success' : 'danger'" size="small">
{{ sl.projectionPortReachable ? '可达' : '不可达' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="前往站点 API">
<el-tag :type="gotoSiteTagType" size="small">{{ gotoSiteLabel }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="可执行文件">
<el-tag :type="sl.executableExists ? 'success' : 'warning'" size="small">
{{ sl.executableExists ? '已找到' : '未找到' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item v-if="sl.deployHint" label="部署提示" :span="2">
{{ sl.deployHint }}
</el-descriptions-item>
</el-descriptions>
<el-alert v-if="error" :title="error" type="warning" :closable="false" class="status-block" />
<div class="status-actions">
<el-button>查看日志</el-button>
<el-button type="warning" plain>重启 Web</el-button>
<el-button type="danger" plain>关闭服务</el-button>
<el-button :loading="loading" @click="refresh">刷新</el-button>
<el-button @click="back">返回</el-button>
</div>
</el-card>
@@ -28,28 +52,89 @@
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import http from '@/api/http'
import { useAuthStore } from '@/stores/auth'
interface HealthInfo {
status: string
startTime: string
uptimeSec: number
}
interface SimpleLiteDiagnostics {
enabled: boolean
isRunning: boolean
lastLaunchMode?: string | null
projectionPort: number
projectionPortReachable: boolean
gotoSiteApiAvailable?: boolean | null
executableExists: boolean
deployHint?: string | null
}
const router = useRouter()
const startTime = new Date().toLocaleString('zh-CN')
const uptime = ref('00:00:00')
const auth = useAuthStore()
const loading = ref(false)
const health = ref<HealthInfo | null>(null)
const sl = ref<SimpleLiteDiagnostics | null>(null)
const error = ref('')
let timer: number | undefined
const t0 = Date.now()
function tick() {
const ms = Date.now() - t0
const s = Math.floor(ms / 1000)
const serverStartTime = computed(() =>
health.value ? new Date(health.value.startTime).toLocaleString('zh-CN') : ''
)
const serverUptime = computed(() => {
if (!health.value) return ''
const s = Math.max(0, Math.floor(health.value.uptimeSec))
const h = String(Math.floor(s / 3600)).padStart(2, '0')
const m = String(Math.floor((s % 3600) / 60)).padStart(2, '0')
const ss = String(s % 60).padStart(2, '0')
uptime.value = `${h}:${m}:${ss}`
return `${h}:${m}:${ss}`
})
const gotoSiteLabel = computed(() => {
const v = sl.value?.gotoSiteApiAvailable
if (v === true) return '可用'
if (v === false) return '缺失(旧版 DLL'
return '未知'
})
const gotoSiteTagType = computed(() => {
const v = sl.value?.gotoSiteApiAvailable
if (v === true) return 'success'
if (v === false) return 'warning'
return 'info'
})
async function refresh() {
loading.value = true
error.value = ''
try {
// /status 是 public 页:未登录只拉匿名 /health,不调需登录的 simplelite 诊断
// (401 会触发全局拦截器强制跳转登录页)。
const requests: [Promise<{ data: HealthInfo }>, Promise<{ data: SimpleLiteDiagnostics }> | null] = [
http.get<HealthInfo>('/health'),
auth.token ? http.get<SimpleLiteDiagnostics>('/health/simplelite') : null
]
const [h, d] = await Promise.allSettled([requests[0], requests[1] ?? Promise.reject(new Error('skipped'))])
health.value = h.status === 'fulfilled' ? h.value.data : null
sl.value = d.status === 'fulfilled' ? d.value.data : null
if (h.status === 'rejected') error.value = '无法连接 MiGu.Server/api/health'
else if (auth.token && d.status === 'rejected') error.value = 'SimpleLite 诊断获取失败(/api/health/simplelite'
} finally {
loading.value = false
}
}
onMounted(() => {
tick()
timer = window.setInterval(tick, 1000)
void refresh()
// 10s 轮询:服务端对 goto-site 探测有 60s 缓存,此频率不会对 SimpleLite 产生压力。
timer = window.setInterval(() => void refresh(), 10_000)
})
onUnmounted(() => {
@@ -62,5 +147,6 @@ function back() { router.back() }
<style scoped>
.status-page { min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px; }
.status-card { width: 720px; }
.status-block { margin-top: 16px; }
.status-actions { display: flex; gap: 8px; margin-top: 16px; justify-content: flex-end; }
</style>
@@ -1,75 +0,0 @@
<template>
<PermissionGuard widget-id="CadToolbar">
<el-card shadow="never">
<template #header><span>CAD 工具栏cad.tool.run</span></template>
<el-tabs v-model="active">
<el-tab-pane v-for="g in groups" :key="g.key" :name="g.key" :label="g.label">
<el-row :gutter="12">
<el-col v-for="t in g.tools" :key="t.id" :span="6" style="margin-bottom: 12px">
<el-card shadow="hover" class="tool-card" @click="run(t)">
<el-icon size="24" class="tool-icon"><Tools /></el-icon>
<div class="tool-name">{{ t.label }}</div>
<div class="tool-desc">{{ t.desc }}</div>
</el-card>
</el-col>
</el-row>
</el-tab-pane>
</el-tabs>
</el-card>
</PermissionGuard>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Tools } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import PermissionGuard from '@/components/PermissionGuard.vue'
interface CadTool { id: string; label: string; desc: string }
const active = ref('Scene')
const groups: Array<{ key: string; label: string; tools: CadTool[] }> = [
{
key: 'Scene', label: '场景 (Scene)',
tools: [
{ id: 'grid', label: '生成网格', desc: '按间距生成站点网格' },
{ id: 'mirror', label: '镜像', desc: '镜像复制选中对象' },
{ id: 'align', label: '对齐', desc: '横向/纵向对齐多选' },
{ id: 'distribute', label: '等距分布', desc: '在两端之间均匀分布站点' }
]
},
{
key: 'Car', label: '车辆 (Car)',
tools: [
{ id: 'spawn', label: '批量创建', desc: '从模板批量创建车辆' },
{ id: 'reset', label: '回原点', desc: '将选中车辆送回原点' }
]
},
{
key: 'Project', label: '工程 (Project)',
tools: [
{ id: 'validate', label: '工程校验', desc: '检查轨道连通性 / 重复站点' },
{ id: 'snapshot', label: '快照', desc: '输出 problems/simplelite-scene-*.json' }
]
}
]
function run(t: CadTool) {
ElMessage.info(`占位:执行 CAD 工具 [${t.id}] ${t.label}`)
}
</script>
<style scoped>
.tool-card { cursor: pointer; text-align: center; padding: 8px; }
.tool-name { font-weight: 600; margin-top: 6px; }
.tool-desc { color: var(--mg-text-dim); font-size: 12px; margin-top: 4px; }
.tool-icon {
color: var(--mg-accent);
filter: drop-shadow(0 0 8px rgba(var(--mg-accent-rgb), 0.55));
}
.tool-card:hover .tool-icon {
color: var(--mg-text-light);
filter: drop-shadow(0 0 14px rgba(var(--mg-primary-hover-rgb), 0.8));
}
</style>
@@ -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; }
@@ -176,6 +176,7 @@ import {
promptSaveMode,
resolveCurrentMapName
} from '@/utils/projectSaveFlow'
import { defaultVrHost } from '@/utils/vrender'
import {
reflectionApi,
normalizeViewportPayload,
@@ -190,7 +191,7 @@ import {
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223'
const vrHost = defaultVrHost()
// 当前正在编辑的「固定文件夹地图名」。从地图管理页带 ?map=<name> 进入时载入;
// 决定保存时的默认名称与「是否替换原地图」确认逻辑。新建地图(?new=1)时为空。
@@ -153,7 +153,7 @@ async function loadMapContent(name: string) {
viewingPath.value = ''
try {
const r = await mapsApi.readContent(name)
viewingPath.value = r.path
viewingPath.value = r.fileName
viewingJsonRaw.value = r.content
} catch (err) {
ElMessage.error(`加载地图配置失败:${(err as Error).message}`)
@@ -100,6 +100,7 @@ import type { DeliveryTask } from '@/types/delivery'
import type { SelectedObjectRef } from '@/types/workbench'
import { fetchMonitorConfigCached, invalidateMonitorConfigCache } from '@/utils/monitorConfigCache'
import type { MapFocusKind } from '@/utils/mapObjectFocus'
import { defaultVrHost } from '@/utils/vrender'
defineProps<{
/** 只读模式(运营端复用 MapMonitorView 时传 true):3D 不可编辑,选中信息面板动作改用运维白名单。 */
@@ -109,7 +110,7 @@ defineProps<{
const auth = useAuthStore()
const route = useRoute()
const router = useRouter()
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223'
const vrHost = defaultVrHost()
const cars = ref<Car[]>([])
const missions = ref<Mission[]>([])
@@ -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;