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

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
+9
View File
@@ -212,6 +212,15 @@ public sealed class RbacStore
lock (_gate) { var u = FindByName(username); return u is null ? null : Clone(u); }
}
public RbacUser? FindUserById(string id)
{
lock (_gate)
{
var u = _snapshot.Users.FirstOrDefault(x => x.Id == id);
return u is null ? null : Clone(u);
}
}
/// <summary>当前用户可登录的 scope 集合(其角色覆盖的 scope<c>*</c> 角色覆盖全部)。</summary>
public List<string> UsableScopes(RbacUser user)
{
@@ -0,0 +1,54 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MiGu.Server.Dashboard;
namespace MiGu.Server.Controllers;
[ApiController]
[Authorize]
[Route("api/dashboard")]
public class DashboardController : ControllerBase
{
private readonly DashboardShortcutService _shortcuts;
public DashboardController(DashboardShortcutService shortcuts) => _shortcuts = shortcuts;
public sealed record SaveQuickEntriesRequest(List<string>? Keys);
[HttpGet("quick-entries")]
public async Task<IActionResult> GetQuickEntries(CancellationToken ct)
{
var (userId, scope, err) = ResolveSession();
if (err != null) return err;
var result = await _shortcuts.GetAsync(userId!, scope!, ct);
return Ok(new { keys = result.Keys, usingDefaults = result.UsingDefaults });
}
[HttpPut("quick-entries")]
public async Task<IActionResult> SaveQuickEntries(
[FromBody] SaveQuickEntriesRequest req, CancellationToken ct)
{
var (userId, scope, err) = ResolveSession();
if (err != null) return err;
var result = await _shortcuts.SaveAsync(userId!, scope!, req.Keys, ct);
return Ok(new { keys = result.Keys, usingDefaults = result.UsingDefaults });
}
private (string? UserId, string? Scope, IActionResult? Error) ResolveSession()
{
var userId = User.FindFirstValue(JwtRegisteredClaimNames.Sub)
?? User.FindFirstValue(ClaimTypes.NameIdentifier);
if (string.IsNullOrWhiteSpace(userId))
return (null, null, Unauthorized(new { message = "未识别用户" }));
var scope = User.FindFirstValue("scope");
if (string.IsNullOrWhiteSpace(scope))
return (null, null, BadRequest(new { message = "会话缺少 scope" }));
return (userId, scope, null);
}
}
@@ -0,0 +1,99 @@
using MiGu.Server.Auth;
namespace MiGu.Server.Dashboard;
/// <summary>
/// Dashboard 快捷入口 key 白名单。key 与前端 <c>quickEntries.ts</c> 对齐;
/// <see cref="PageKey"/> 用于 RBAC 校验(用户须有权访问对应页面)。
/// </summary>
public sealed record ShortcutDef(string Key, string PageKey, string Scope);
public static class DashboardShortcutCatalog
{
/// <summary>旧版快捷 key(别名)→ 菜单 key。保存时归一化,避免与菜单项重复。</summary>
private static readonly Dictionary<string, string> LegacyKeyAliases =
new(StringComparer.OrdinalIgnoreCase)
{
["platform-config"] = "admin-map-editor",
["mission"] = "admin-task-templates",
["cars"] = "admin-cars",
["auth"] = "admin-config-system-center",
["system"] = "admin-config-system-center",
["ops"] = "admin-config-ops-center",
["tasks"] = "admin-config-strategy",
};
private static readonly ShortcutDef[] PlatformShortcuts =
[
new("admin-dashboard", "admin-dashboard", PageCatalog.ScopePlatform),
new("admin-map-monitor", "admin-map-monitor", PageCatalog.ScopePlatform),
new("admin-maps", "admin-maps", PageCatalog.ScopePlatform),
new("admin-map-editor", "admin-map-editor", PageCatalog.ScopePlatform),
new("admin-project-properties", "admin-project-properties", PageCatalog.ScopePlatform),
new("admin-tracks", "admin-tracks", PageCatalog.ScopePlatform),
new("admin-cars", "admin-cars", PageCatalog.ScopePlatform),
new("admin-processes", "admin-processes", PageCatalog.ScopePlatform),
new("admin-scripts", "admin-scripts", PageCatalog.ScopePlatform),
new("admin-task-templates", "admin-task-templates", PageCatalog.ScopePlatform),
new("admin-simple-fields", "admin-simple-fields", PageCatalog.ScopePlatform),
new("admin-config-strategy", "admin-config-strategy", PageCatalog.ScopePlatform),
new("admin-vehicle-hub", "admin-vehicle-hub", PageCatalog.ScopePlatform),
new("admin-config-facility", "admin-config-facility", PageCatalog.ScopePlatform),
new("admin-config-business", "admin-config-business", PageCatalog.ScopePlatform),
new("admin-config-ops-center", "admin-config-ops-center", PageCatalog.ScopePlatform),
new("admin-config-system-center", "admin-config-system-center", PageCatalog.ScopePlatform),
];
private static readonly ShortcutDef[] MonitorShortcuts =
[
new("monitor-dashboard", "monitor-dashboard", PageCatalog.ScopeMonitor),
new("monitor-vehicle-hub", "monitor-vehicle-hub", PageCatalog.ScopeMonitor),
new("monitor-map", "monitor-map", PageCatalog.ScopeMonitor),
new("monitor-ops", "monitor-ops", PageCatalog.ScopeMonitor),
new("monitor-notes", "monitor-notes", PageCatalog.ScopeMonitor),
];
private static readonly Dictionary<string, ShortcutDef> ByKey =
PlatformShortcuts.Concat(MonitorShortcuts)
.ToDictionary(s => s.Key, s => s, StringComparer.OrdinalIgnoreCase);
public static readonly int MaxKeysPerUser = 16;
public static readonly IReadOnlyList<string> DefaultPlatformKeys =
[
"admin-map-editor",
"admin-task-templates",
"admin-cars",
"admin-config-system-center",
"admin-config-ops-center",
"admin-config-strategy"
];
public static readonly IReadOnlyList<string> DefaultMonitorKeys =
["monitor-vehicle-hub", "monitor-map", "monitor-ops"];
private static readonly HashSet<string> ExcludedKeys =
new(StringComparer.OrdinalIgnoreCase) { "admin-dashboard", "monitor-dashboard" };
public static bool IsValidKey(string key) =>
!ExcludedKeys.Contains(key) && ByKey.ContainsKey(key);
public static ShortcutDef? TryGet(string key) =>
ByKey.TryGetValue(key, out var def) ? def : null;
public static IReadOnlyList<string> DefaultKeysForScope(string scope) =>
string.Equals(scope, PageCatalog.ScopeMonitor, StringComparison.OrdinalIgnoreCase)
? DefaultMonitorKeys
: DefaultPlatformKeys;
public static bool KeyMatchesScope(string key, string scope)
{
var def = TryGet(key);
return def != null && string.Equals(def.Scope, scope, StringComparison.OrdinalIgnoreCase);
}
public static string PageKeyFor(string key) => TryGet(NormalizeKey(key))?.PageKey ?? NormalizeKey(key);
public static string NormalizeKey(string key) =>
LegacyKeyAliases.TryGetValue(key.Trim(), out var canon) ? canon : key.Trim();
}
@@ -0,0 +1,146 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using MiGu.Server.Auth;
using MiGu.Server.Persistence;
namespace MiGu.Server.Dashboard;
public sealed class DashboardShortcutService
{
private readonly PlatformDbContext _db;
private readonly RbacStore _rbac;
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
public DashboardShortcutService(PlatformDbContext db, RbacStore rbac)
{
_db = db;
_rbac = rbac;
}
public sealed record QuickEntriesResult(IReadOnlyList<string> Keys, bool UsingDefaults);
public async Task<QuickEntriesResult> GetAsync(string userId, string scope, CancellationToken ct = default)
{
scope = NormalizeScope(scope);
var allowed = AllowedPages(userId, scope);
var row = await _db.UserDashboardShortcuts
.AsNoTracking()
.FirstOrDefaultAsync(x => x.UserId == userId && x.Scope == scope, ct);
if (row == null)
{
var defaults = FilterKeys(DashboardShortcutCatalog.DefaultKeysForScope(scope), scope, allowed);
return new QuickEntriesResult(
defaults.Take(DashboardShortcutCatalog.MaxKeysPerUser).ToList(),
UsingDefaults: true);
}
var keys = ParseKeys(row.KeysJson);
var filtered = FilterKeys(keys, scope, allowed)
.Take(DashboardShortcutCatalog.MaxKeysPerUser)
.ToList();
return new QuickEntriesResult(filtered, UsingDefaults: false);
}
public async Task<QuickEntriesResult> SaveAsync(
string userId, string scope, IReadOnlyList<string>? keys, CancellationToken ct = default)
{
scope = NormalizeScope(scope);
var allowed = AllowedPages(userId, scope);
var sanitized = FilterKeys(Deduplicate(keys ?? []), scope, allowed);
if (sanitized.Count > DashboardShortcutCatalog.MaxKeysPerUser)
sanitized = sanitized.Take(DashboardShortcutCatalog.MaxKeysPerUser).ToList();
var row = await _db.UserDashboardShortcuts
.FirstOrDefaultAsync(x => x.UserId == userId && x.Scope == scope, ct);
var json = JsonSerializer.Serialize(sanitized, JsonOpts);
var now = DateTimeOffset.UtcNow;
if (row == null)
{
_db.UserDashboardShortcuts.Add(new UserDashboardShortcut
{
UserId = userId,
Scope = scope,
KeysJson = json,
UpdatedAt = now
});
}
else
{
row.KeysJson = json;
row.UpdatedAt = now;
}
await _db.SaveChangesAsync(ct);
return new QuickEntriesResult(sanitized, UsingDefaults: false);
}
private HashSet<string> AllowedPages(string userId, string scope)
{
var user = _rbac.FindUserById(userId);
if (user == null || !user.Enabled)
return new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var eff = _rbac.ComputeEffective(user, scope);
return eff.Pages.ToHashSet(StringComparer.OrdinalIgnoreCase);
}
private static List<string> FilterKeys(
IEnumerable<string> keys, string scope, HashSet<string> allowedPages)
{
var outKeys = new List<string>();
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var raw in keys)
{
var key = DashboardShortcutCatalog.NormalizeKey(raw ?? "");
if (string.IsNullOrEmpty(key)) continue;
if (!DashboardShortcutCatalog.IsValidKey(key)) continue;
if (!DashboardShortcutCatalog.KeyMatchesScope(key, scope)) continue;
if (seen.Contains(key)) continue;
var pageKey = DashboardShortcutCatalog.PageKeyFor(key);
if (!allowedPages.Contains(pageKey)) continue;
seen.Add(key);
outKeys.Add(key);
}
return outKeys;
}
private static List<string> Deduplicate(IReadOnlyList<string> keys)
{
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var list = new List<string>();
foreach (var k in keys)
{
if (string.IsNullOrWhiteSpace(k)) continue;
var t = k.Trim();
if (seen.Add(t)) list.Add(t);
}
return list;
}
private static List<string> ParseKeys(string json)
{
try
{
return JsonSerializer.Deserialize<List<string>>(json, JsonOpts) ?? [];
}
catch
{
return [];
}
}
private static string NormalizeScope(string scope) =>
string.Equals(scope, PageCatalog.ScopeMonitor, StringComparison.OrdinalIgnoreCase)
? PageCatalog.ScopeMonitor
: PageCatalog.ScopePlatform;
}
@@ -0,0 +1,10 @@
namespace MiGu.Server.Dashboard;
/// <summary>用户 Dashboard 快捷入口配置(按 user + scope 一行)。</summary>
public sealed class UserDashboardShortcut
{
public string UserId { get; set; } = "";
public string Scope { get; set; } = "";
public string KeysJson { get; set; } = "[]";
public DateTimeOffset UpdatedAt { get; set; }
}
@@ -1,5 +1,6 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MiGu.Server.Dashboard;
using MiGu.Server.Wms;
using MiGu.Server.SimpleFields;
@@ -18,6 +19,7 @@ public sealed class PlatformDbContext : DbContext
public DbSet<ContainerLocationHistory> ContainerLocationHistories => Set<ContainerLocationHistory>();
public DbSet<ContainerMaterialHistory> ContainerMaterialHistories => Set<ContainerMaterialHistory>();
public DbSet<SimpleField> SimpleFields => Set<SimpleField>();
public DbSet<UserDashboardShortcut> UserDashboardShortcuts => Set<UserDashboardShortcut>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -59,6 +61,21 @@ public sealed class PlatformDbContext : DbContext
modelBuilder.Entity<ContainerMaterialHistory>().Property(x => x.QuantityDelta).HasPrecision(18, 4);
ConfigureSimpleField(modelBuilder);
ConfigureUserDashboardShortcut(modelBuilder);
}
private static void ConfigureUserDashboardShortcut(ModelBuilder modelBuilder)
{
var e = modelBuilder.Entity<UserDashboardShortcut>();
e.ToTable("user_dashboard_shortcuts");
e.HasKey(x => new { x.UserId, x.Scope });
e.Property(x => x.UserId).HasColumnName("user_id").HasMaxLength(64);
e.Property(x => x.Scope).HasColumnName("scope").HasMaxLength(32);
e.Property(x => x.KeysJson).HasColumnName("keys_json").HasColumnType("text");
var dateTime = new ValueConverter<DateTimeOffset, string>(
v => v.UtcDateTime.ToString("O"),
v => DateTimeOffset.Parse(v));
e.Property(x => x.UpdatedAt).HasColumnName("updated_at").HasConversion(dateTime).HasMaxLength(40);
}
private static void ConfigureSimpleField(ModelBuilder modelBuilder)
@@ -41,6 +41,7 @@ public static class PlatformPersistence
services.AddScoped<WmsReferenceValidator>();
services.AddScoped<WmsService>();
services.AddScoped<SimpleFieldService>();
services.AddScoped<MiGu.Server.Dashboard.DashboardShortcutService>();
return services;
}
@@ -51,6 +52,7 @@ public static class PlatformPersistence
await db.Database.EnsureCreatedAsync();
// EnsureCreated 只在「库文件不存在」时建表;已有 platform.db 时新增实体不会自动补表。
await EnsureSimpleFieldsTableAsync(db);
await EnsureUserDashboardShortcutsTableAsync(db);
}
/// <summary>为已存在的数据库补建 simple_fields 表(幂等)。</summary>
@@ -99,6 +101,30 @@ public static class PlatformPersistence
}
}
/// <summary>为已存在的数据库补建 user_dashboard_shortcuts 表(幂等)。</summary>
private static async Task EnsureUserDashboardShortcutsTableAsync(PlatformDbContext db)
{
if (db.Database.IsSqlite())
{
await db.Database.ExecuteSqlRawAsync("""
CREATE TABLE IF NOT EXISTS user_dashboard_shortcuts (
user_id TEXT NOT NULL,
scope TEXT NOT NULL,
keys_json TEXT NOT NULL DEFAULT '[]',
updated_at TEXT NOT NULL,
CONSTRAINT PK_user_dashboard_shortcuts PRIMARY KEY (user_id, scope)
);
""");
return;
}
if (!await TableExistsAsync(db, "user_dashboard_shortcuts"))
{
var creator = db.GetService<Microsoft.EntityFrameworkCore.Storage.IRelationalDatabaseCreator>();
await creator.CreateTablesAsync();
}
}
/// <summary>
/// 检查表是否存在
/// </summary>
Binary file not shown.
@@ -0,0 +1,51 @@
import http from '@/api/http'
export interface QuickEntriesDto {
keys: string[]
usingDefaults: boolean
}
const MOCK_STORAGE_KEY = 'simple.mock.dashboard.quickEntries'
function mockStorageKey(scope: string, userId: string) {
return `${MOCK_STORAGE_KEY}.${scope}.${userId}`
}
function isMock() {
return import.meta.env.VITE_USE_MOCK === 'true'
}
function mockLoad(scope: string, userId: string): QuickEntriesDto | null {
try {
const raw = localStorage.getItem(mockStorageKey(scope, userId))
return raw ? (JSON.parse(raw) as QuickEntriesDto) : null
} catch {
return null
}
}
function mockSave(scope: string, userId: string, dto: QuickEntriesDto) {
try {
localStorage.setItem(mockStorageKey(scope, userId), JSON.stringify(dto))
} catch { /* ignore */ }
}
export async function fetchQuickEntryKeys(userId: string, scope: string): Promise<QuickEntriesDto> {
if (isMock()) {
return mockLoad(scope, userId) ?? { keys: [], usingDefaults: true }
}
const { data } = await http.get<QuickEntriesDto>('/dashboard/quick-entries')
return data
}
export async function saveQuickEntryKeys(
userId: string, scope: string, keys: string[]
): Promise<QuickEntriesDto> {
if (isMock()) {
const dto: QuickEntriesDto = { keys, usingDefaults: false }
mockSave(scope, userId, dto)
return dto
}
const { data } = await http.put<QuickEntriesDto>('/dashboard/quick-entries', { keys })
return data
}
@@ -0,0 +1,211 @@
<template>
<el-dialog
v-model="visible"
title="添加快捷入口"
width="960px"
append-to-body
destroy-on-close
class="quick-entry-dialog"
@closed="emit('closed')">
<p class="qed-desc">
从下方选择常用菜单页固定到总览快捷入口总览最多 {{ maxCount }} 已固定 {{ pinnedCount }}
</p>
<div v-if="!items.length" class="qed-empty">暂无可添加的菜单页</div>
<div v-else class="qed-grid">
<button
v-for="item in items"
:key="item.key"
type="button"
class="qed-chip"
:class="{ 'is-pinned': isPinned(item.key), 'is-disabled': isDisabled(item.key) }"
:title="chipTitle(item)"
:disabled="isDisabled(item.key)"
@click="onPick(item.key)">
<span class="qed-chip-icon">
<el-icon :size="22"><component :is="item.icon" /></el-icon>
</span>
<span class="qed-chip-label">{{ item.label }}</span>
<span v-if="isPinned(item.key)" class="qed-chip-badge">已固定</span>
</button>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage } from 'element-plus'
import { MAX_QUICK_ENTRIES, type QuickEntryDef } from '@/config/quickEntries'
const props = defineProps<{
modelValue: boolean
items: QuickEntryDef[]
pinnedKeys?: string[]
canAdd?: boolean
maxCount?: number
}>()
const emit = defineEmits<{
'update:modelValue': [boolean]
pick: [key: string]
closed: []
}>()
const maxCount = computed(() => props.maxCount ?? MAX_QUICK_ENTRIES)
const pinnedSet = computed(() => new Set(props.pinnedKeys ?? []))
const pinnedCount = computed(() => pinnedSet.value.size)
const visible = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v)
})
function isPinned(key: string) {
return pinnedSet.value.has(key)
}
function isDisabled(key: string) {
return isPinned(key)
}
function chipTitle(item: QuickEntryDef) {
if (isPinned(item.key)) return `${item.label}(已在快捷入口中)`
if (props.canAdd === false) return `${item.label}(快捷入口已满,请先移除再添加)`
return item.hint ?? item.label
}
function onPick(key: string) {
if (isPinned(key)) return
if (props.canAdd === false) {
ElMessage.warning(`快捷入口已满(最多 ${maxCount.value} 个),请先移除已有项`)
return
}
emit('pick', key)
visible.value = false
}
</script>
<style scoped>
.qed-desc {
margin: 0 0 16px;
font-size: 13px;
color: var(--qed-text-muted, rgba(45, 27, 105, 0.72));
letter-spacing: 0.4px;
}
.qed-empty {
padding: 32px 0;
text-align: center;
color: var(--qed-text-muted, rgba(45, 27, 105, 0.55));
font-size: 13px;
}
.qed-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 14px 12px;
}
.qed-chip {
appearance: none;
cursor: pointer;
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(var(--mg-accent-rgb), 0.35);
border-radius: 14px;
padding: 14px 8px 12px;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: var(--qed-text, #2d1b69);
transition: all .22s cubic-bezier(.25, .8, .25, 1);
position: relative;
}
.qed-chip:hover:not(:disabled) {
border-color: rgba(var(--mg-accent-rgb), 0.85);
background: #fff;
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(var(--mg-primary-rgb), 0.22);
}
.qed-chip.is-pinned,
.qed-chip.is-disabled {
cursor: not-allowed;
opacity: 0.55;
background: rgba(45, 27, 105, 0.04);
}
.qed-chip-icon {
width: 44px;
height: 44px;
flex-shrink: 0;
border-radius: 12px;
background: linear-gradient(135deg,
rgba(var(--mg-primary-hover-rgb), 0.55) 0%,
rgba(var(--mg-primary-rgb), 0.42) 100%);
border: 1px solid rgba(var(--mg-accent-rgb), 0.45);
display: inline-flex;
align-items: center;
justify-content: center;
color: #fff;
box-shadow:
0 6px 16px rgba(var(--mg-primary-rgb), 0.28),
0 0 0 1px rgba(255, 255, 255, 0.18) inset;
}
.qed-chip-label {
width: 100%;
font-size: 13px;
font-weight: 500;
line-height: 1.35;
text-align: center;
color: var(--qed-text, #2d1b69);
letter-spacing: 0.6px;
word-break: keep-all;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.qed-chip-badge {
position: absolute;
top: 6px;
right: 6px;
font-size: 9px;
padding: 1px 5px;
border-radius: 6px;
background: rgba(var(--mg-primary-rgb), 0.12);
color: rgba(45, 27, 105, 0.72);
letter-spacing: 0;
}
@media (max-width: 960px) {
.qed-grid { grid-template-columns: repeat(4, 1fr); }
}
</style>
<style>
/* el-dialog teleport 到 body,需非 scoped;兼容 fame-lavender 白底弹窗 */
.quick-entry-dialog.el-dialog,
.quick-entry-dialog .el-dialog {
--qed-text: #2d1b69;
--qed-text-muted: rgba(45, 27, 105, 0.72);
background: #fff !important;
border: 1px solid rgba(var(--mg-accent-rgb), 0.28);
border-radius: 16px;
box-shadow: 0 24px 48px rgba(45, 27, 105, 0.18);
}
.quick-entry-dialog .el-dialog__header {
border-bottom: 1px solid rgba(var(--mg-accent-rgb), 0.14);
margin-right: 0;
padding-bottom: 14px;
}
.quick-entry-dialog .el-dialog__title {
color: #2d1b69 !important;
font-weight: 600;
letter-spacing: 1px;
}
.quick-entry-dialog .el-dialog__headerbtn .el-dialog__close {
color: rgba(45, 27, 105, 0.55);
}
.quick-entry-dialog .el-dialog__body {
color: #2d1b69;
padding-top: 14px;
}
</style>
@@ -1,18 +1,25 @@
<template>
<section class="fleet-alloc">
<section class="fleet-alloc" :class="{ 'is-collapsed': collapsed }">
<div class="fa-header">
<span class="fa-title">车队分配</span>
<el-tag size="small" type="info" effect="plain">区域管理</el-tag>
<span class="fa-sub">将车辆分配到车队并设定车队名称 / 区域 / 楼层</span>
<button type="button" class="fa-fold" :aria-expanded="!collapsed" @click="toggleCollapsed">
<el-icon class="fold-chevron" :class="{ 'is-collapsed': collapsed }"><ArrowDown /></el-icon>
<span class="fa-title">车队分配</span>
</button>
<el-tag v-if="collapsed" size="small" type="info" effect="plain">{{ fleets.length }} 个车队</el-tag>
<el-tag v-else size="small" type="info" effect="plain">区域管理</el-tag>
<span v-if="!collapsed" class="fa-sub">将车辆分配到车队并设定车队名称 / 区域 / 楼层</span>
<div class="spacer" />
<el-button size="small" :icon="Refresh" :loading="loading" @click="reload(true)">重载</el-button>
<el-button size="small" :icon="Plus" :disabled="!canWrite" @click="addFleet">新建车队</el-button>
<el-button size="small" type="primary" :icon="Check" :loading="saving" :disabled="!canWrite || !dirty" @click="save">
保存
</el-button>
<template v-if="!collapsed">
<el-button size="small" :icon="Refresh" :loading="loading" @click="reload(true)">重载</el-button>
<el-button size="small" :icon="Plus" :disabled="!canWrite" @click="addFleet">新建车队</el-button>
<el-button size="small" type="primary" :icon="Check" :loading="saving" :disabled="!canWrite || !dirty" @click="save">
保存
</el-button>
</template>
<el-button v-else size="small" text type="primary" @click="toggleCollapsed">展开</el-button>
</div>
<div v-loading="loading" class="fa-body">
<div v-show="!collapsed" v-loading="loading" class="fa-body">
<el-empty v-if="!fleets.length" description="暂无车队,点击「新建车队」开始分配" />
<div v-for="(fleet, idx) in fleets" :key="fleet.id" class="fleet-card">
@@ -77,7 +84,7 @@
</div>
</div>
<p v-if="unknownCarIds.length" class="fa-note">
<p v-if="!collapsed && unknownCarIds.length" class="fa-note">
提示以下已分配的车辆 ID 不在当前在册车辆中{{ unknownCarIds.join('') }}
</p>
</section>
@@ -85,15 +92,19 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { Refresh, Check, Plus, Delete } from '@element-plus/icons-vue'
import { Refresh, Check, Plus, Delete, ArrowDown } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { reflectionApi } from '@/api/reflection'
import { useConfigStore } from '@/stores/config'
import { useFleetGroups } from '@/composables/useFleetGroups'
import { DEFAULT_FLEET } from '@/mock/data/configs'
import type { VehicleCardModel } from '@/types/car'
import type { FleetGroup, FleetLifecycleConfig } from '@/types/config'
type FleetAllocCar = Pick<VehicleCardModel, 'id' | 'name' | 'rawId'>
const props = defineProps<{
cars: { id: string; name?: string }[]
cars: FleetAllocCar[]
canWrite: boolean
}>()
@@ -101,9 +112,31 @@ const emit = defineEmits<{ saved: [] }>()
const store = useConfigStore()
const { reload: reloadShared } = useFleetGroups()
const COLLAPSE_KEY = 'vehicle-hub.fleet-alloc-collapsed'
const loading = ref(false)
const saving = ref(false)
const dirty = ref(false)
const collapsed = ref(readCollapsed())
function readCollapsed(): boolean {
try {
const v = localStorage.getItem(COLLAPSE_KEY)
if (v === null) return true
return v === '1'
} catch {
return true
}
}
function toggleCollapsed() {
collapsed.value = !collapsed.value
try {
localStorage.setItem(COLLAPSE_KEY, collapsed.value ? '1' : '0')
} catch {
/* ignore */
}
}
// 保留 fleet 配置中除 groups 以外的字段(OTA / 批量 / 诊断),保存时原样回写。
const rest = ref<Omit<FleetLifecycleConfig, 'groups'>>({
@@ -118,8 +151,8 @@ function markDirty() {
}
const carIndex = computed(() => {
const m = new Map<string, string>()
for (const c of props.cars) m.set(c.id, c.name ?? c.id)
const m = new Map<string, FleetAllocCar>()
for (const c of props.cars) m.set(c.id, c)
return m
})
@@ -158,6 +191,38 @@ function newFleetId(): string {
return id
}
function resolveCarReflectionId(car: FleetAllocCar): number | null {
if (car.rawId != null && Number.isFinite(car.rawId)) return car.rawId
const parsed = Number.parseInt(car.id.replace(/\D/g, ''), 10)
return Number.isFinite(parsed) ? parsed : null
}
async function syncCarGroupFields(): Promise<number> {
const assignments = new Map<string, { reflectionId: number; fleetName: string }>()
const cars = carIndex.value
for (const fleet of fleets.value) {
const fleetName = fleet.name.trim() || fleet.id
for (const carId of fleet.carIds) {
const car = cars.get(carId)
if (!car) continue
const reflectionId = resolveCarReflectionId(car)
if (reflectionId == null) continue
assignments.set(carId, { reflectionId, fleetName })
}
}
const tasks = [...assignments.values()].map(({ reflectionId, fleetName }) =>
reflectionApi.setField('car', reflectionId, 'carGroup', fleetName)
)
const results = await Promise.allSettled(tasks)
const failed = results.filter((r) => r.status === 'rejected')
if (failed.length) {
throw new Error(`${failed.length}/${tasks.length} 辆车同步失败`)
}
return tasks.length
}
function addFleet() {
fleets.value.push({ id: newFleetId(), name: '新车队', floor: '', region: '', carIds: [] })
markDirty()
@@ -195,11 +260,17 @@ async function save() {
...rest.value,
groups: JSON.parse(JSON.stringify(fleets.value)) as FleetGroup[]
}
const env = await store.save<FleetLifecycleConfig>('fleet', body)
await store.save<FleetLifecycleConfig>('fleet', body)
let syncedCars = 0
try {
syncedCars = await syncCarGroupFields()
} catch (e) {
ElMessage.warning(`车队配置已保存,但同步 carGroup 字段失败:${e instanceof Error ? e.message : String(e)}`)
}
dirty.value = false
await reloadShared(true)
emit('saved')
ElMessage.success(`车队分配已保存 v${env.version}`)
ElMessage.success(syncedCars ? `车队分配已保存,已同步 ${syncedCars} 辆车 carGroup` : '车队分配已保存')
} catch (e) {
ElMessage.error(`保存失败:${e instanceof Error ? e.message : String(e)}`)
} finally {
@@ -220,6 +291,11 @@ onMounted(() => reload())
border-radius: 8px;
padding: 12px;
background: var(--mg-veil-2, rgba(255, 255, 255, 0.03));
transition: padding 0.2s ease;
}
.fleet-alloc.is-collapsed {
padding: 8px 12px;
}
.fa-header {
@@ -229,10 +305,37 @@ onMounted(() => reload())
flex-wrap: wrap;
}
.fa-fold {
display: inline-flex;
align-items: center;
gap: 6px;
appearance: none;
border: none;
background: transparent;
padding: 0;
cursor: pointer;
color: inherit;
}
.fa-fold:hover .fa-title {
color: var(--mg-accent, #c4b5fd);
}
.fold-chevron {
font-size: 14px;
color: var(--mg-text-muted, rgba(255, 255, 255, 0.55));
transition: transform 0.2s ease;
}
.fold-chevron.is-collapsed {
transform: rotate(-90deg);
}
.fa-title {
font-weight: 600;
font-size: 14px;
color: var(--mg-text-light, #fff);
transition: color 0.15s;
}
.fa-sub {
@@ -1,109 +1,108 @@
<template>
<div
class="vehicle-health-card"
<article
class="v-card"
:class="cardClass"
@click="onCardClick"
@dblclick="onCardDblClick"
>
<div class="battery-strip">
<el-progress
:percentage="batteryPct"
:stroke-width="4"
:show-text="false"
:color="batteryColor"
/>
<!-- 顶栏状态色带 + 车名 -->
<header class="v-card__banner" :class="accentTone">
<div class="v-card__banner-glow" aria-hidden="true" />
<div class="v-card__banner-row">
<div class="v-card__live">
<span class="live-dot" />
<span>{{ statusPill.label }}</span>
</div>
<div class="v-card__tools" @click.stop>
<VehicleMaintenanceSelect
:vehicle="vehicle"
:can-write="canWrite"
@changed="emit('maintenanceChanged')"
/>
</div>
</div>
<div class="v-card__identity">
<h3 class="v-card__name" :title="vehicle.name">{{ vehicle.name }}</h3>
<span class="v-card__id">{{ vehicle.id }}{{ missionIdSuffix }}</span>
<span v-if="vehicle.group" class="v-card__fleet">{{ vehicle.group }}</span>
</div>
</header>
<!-- 主体环形电量 + 指标格 -->
<div class="v-card__body">
<div class="v-card__ring" :class="batteryTone" :title="`电量 ${batteryPct}%`">
<svg viewBox="0 0 88 88" class="ring-svg" aria-hidden="true">
<circle class="ring-track" cx="44" cy="44" r="36" />
<circle
class="ring-progress"
cx="44"
cy="44"
r="36"
:stroke-dasharray="ringCircumference"
:stroke-dashoffset="ringOffset"
/>
</svg>
<div class="ring-center">
<span class="ring-label">电量</span>
<span class="ring-pct">{{ batteryPct }}%</span>
</div>
</div>
<dl class="v-card__grid">
<div class="cell">
<dt>IP</dt>
<dd class="mono">{{ vehicle.ip ?? '—' }}</dd>
</div>
<div class="cell">
<dt>延迟</dt>
<dd :class="latencyClass">{{ latencyLabel }}</dd>
</div>
<div class="cell">
<dt>故障率</dt>
<dd :class="faultClass">{{ faultLabel }}</dd>
</div>
<div class="cell">
<dt>运行状态</dt>
<dd>{{ runtimeStatusLabel }}</dd>
</div>
<div class="cell cell--wide">
<dt>资源</dt>
<dd class="resource-line">
<span :class="cpuChipClass">CPU {{ cpuLabel }}</span>
<span class="sep">·</span>
<span :class="memChipClass">内存 {{ memLabel }}</span>
</dd>
</div>
</dl>
</div>
<div class="card-head">
<div class="title-block">
<span class="vid">{{ vehicle.id }}</span>
<span class="vname" :title="vehicle.name">{{ vehicle.name }}</span>
<!-- 底栏连通性指示灯 -->
<footer class="v-card__dock">
<div class="dock-signals">
<span class="signal" :class="vehicle.isAlarmActive ? 'is-bad' : 'is-ok'">
<el-icon><Warning /></el-icon>
{{ vehicle.isAlarmActive ? '报警' : '无报警' }}
</span>
<span class="signal" :class="vehicle.reachable === false ? 'is-bad' : 'is-ok'">
<el-icon><Connection /></el-icon>
{{ vehicle.reachable === false ? '不可达' : '在线' }}
</span>
</div>
<div class="head-actions" @click.stop>
<el-dropdown trigger="click" @command="onMaintenanceCommand">
<el-button size="small" text :icon="MoreFilled" />
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="online">上线</el-dropdown-item>
<el-dropdown-item command="offline">下线维护</el-dropdown-item>
<el-dropdown-item command="repair">现场检修</el-dropdown-item>
<el-dropdown-item command="blown" divided>返厂检修</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-switch
:model-value="switchOn"
size="small"
:disabled="!canWrite"
inline-prompt
active-text=""
inactive-text=""
@change="(v: string | number | boolean) => onToggleMaintenance(Boolean(v))"
/>
</div>
</div>
<div class="card-body">
<div class="thumb">
<el-icon :size="36"><Van /></el-icon>
</div>
<div class="metrics">
<div class="metric-row">
<span class="label">IP</span>
<span class="value mono">{{ vehicle.ip ?? '—' }}</span>
</div>
<div class="metric-row">
<span class="label">延迟</span>
<span class="value" :class="latencyClass">{{ latencyLabel }}</span>
</div>
<div class="metric-row">
<span class="label">故障率</span>
<span class="value" :class="faultClass">{{ faultLabel }}</span>
</div>
<div class="metric-row">
<span class="label">群组</span>
<span class="value">{{ vehicle.group ?? '—' }}</span>
</div>
<div class="metric-row">
<span class="label">状态</span>
<span class="value">{{ vehicle.lstatus ?? stateLabel }}</span>
</div>
</div>
</div>
<div class="icon-grid">
<div class="icon-cell" title="CPU">
<el-icon><Cpu /></el-icon>
<span>{{ cpuLabel }}</span>
</div>
<div class="icon-cell" title="内存">
<el-icon><Coin /></el-icon>
<span>{{ memLabel }}</span>
</div>
<div class="icon-cell" title="报警">
<el-icon :class="{ 'is-alarm': vehicle.isAlarmActive }"><Warning /></el-icon>
<span>{{ vehicle.isAlarmActive ? '报警' : '正常' }}</span>
</div>
<div class="icon-cell" title="连接">
<el-icon :class="{ 'is-down': vehicle.reachable === false }"><Connection /></el-icon>
<span>{{ vehicle.reachable === false ? '不可达' : '可达' }}</span>
</div>
</div>
<div class="card-foot">
<span>电量 {{ batteryPct }}%</span>
<span class="hint">双击打开车载界面</span>
</div>
</div>
<span class="dock-action">
双击打开车载界面
<el-icon><Right /></el-icon>
</span>
</footer>
</article>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Van, Cpu, Coin, Warning, Connection, MoreFilled } from '@element-plus/icons-vue'
import { Warning, Connection, Right } from '@element-plus/icons-vue'
import type { VehicleCardModel } from '@/types/car'
import type { VehicleMaintenanceMode } from '@/api/vehicleOps'
import { openOnboardWeb, setVehicleMaintenance } from '@/api/vehicleOps'
import { openOnboardWeb } from '@/api/vehicleOps'
import { useVehicleCardState } from '@/composables/useVehicleCardState'
import VehicleMaintenanceSelect from '@/components/fleet/VehicleMaintenanceSelect.vue'
const props = defineProps<{
vehicle: VehicleCardModel
@@ -116,305 +115,373 @@ const emit = defineEmits<{
maintenanceChanged: []
}>()
const stateLabels: Record<string, string> = {
idle: '空闲',
running: '运行',
charging: '充电',
paused: '暂停',
fault: '故障',
offline: '离线'
}
const RING_R = 36
const ringCircumference = 2 * Math.PI * RING_R
const stateLabel = computed(() => stateLabels[props.vehicle.state] ?? props.vehicle.state)
const {
batteryPct,
batteryTone,
statusPill,
missionIdSuffix,
runtimeStatusLabel,
accentTone,
latencyLabel,
latencyClass,
faultLabel,
faultClass,
cpuLabel,
memLabel,
cpuChipClass,
memChipClass
} = useVehicleCardState({ vehicle: () => props.vehicle })
const batteryPct = computed(() => {
const raw = props.vehicle.batterySoc ?? 0
const pct = raw > 1 ? raw : raw * 100
return Math.max(0, Math.min(100, Math.round(pct)))
})
const batteryColor = computed(() => {
const p = batteryPct.value
if (p < 20) return '#f56c6c'
if (p < 50) return '#e6a23c'
return '#67c23a'
})
const switchOn = computed(() => props.vehicle.maintenanceMode === 'online')
const ringOffset = computed(() =>
ringCircumference * (1 - batteryPct.value / 100)
)
const cardClass = computed(() => ({
'is-selected': props.selected,
'is-alarm': props.vehicle.isAlarmActive,
'is-offline': props.vehicle.maintenanceMode === 'offline' || props.vehicle.state === 'offline',
'is-maintenance': props.vehicle.maintenanceMode === 'repair' || props.vehicle.maintenanceMode === 'blown',
'is-unreachable': props.vehicle.reachable === false
'is-unreachable': props.vehicle.reachable === false,
[statusPill.value.tone]: true
}))
const latencyLabel = computed(() => {
const ms = props.vehicle.latencyMs
if (ms == null) return '—'
if (props.vehicle.reachable === false) return '超时'
return `${ms} ms`
})
const latencyClass = computed(() => {
const ms = props.vehicle.latencyMs
if (props.vehicle.reachable === false) return 'danger'
if (ms != null && ms > 80) return 'warn'
return ''
})
const faultLabel = computed(() => {
const v = props.vehicle.faultRatePercent
if (v == null) return '—'
return `${v.toFixed(2)}%`
})
const faultClass = computed(() => {
const v = props.vehicle.faultRatePercent ?? 0
if (v >= 5) return 'danger'
if (v >= 1) return 'warn'
return ''
})
const cpuLabel = computed(() => {
const v = props.vehicle.cpuPercent
return v != null ? `${Math.round(v)}%` : '—'
})
const memLabel = computed(() => {
const v = props.vehicle.memPercent
return v != null ? `${Math.round(v)}%` : '—'
})
function onCardClick() {
emit('select', props.vehicle.id)
}
function onCardDblClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (target.closest('.head-actions')) return
if (target.closest('.v-card__tools')) return
openOnboardWeb(props.vehicle.onboardUrl, props.vehicle.ip)
}
async function applyMaintenance(mode: VehicleMaintenanceMode) {
const rawId = props.vehicle.rawId ?? parseInt(props.vehicle.id.replace(/\D/g, ''), 10)
if (!Number.isFinite(rawId)) return
const ok = await setVehicleMaintenance(rawId, mode)
if (ok) {
ElMessage.success('维护状态已更新')
emit('maintenanceChanged')
} else {
ElMessage.error('维护操作失败')
}
}
async function onToggleMaintenance(on: boolean) {
if (!props.canWrite) return
const mode: VehicleMaintenanceMode = on ? 'online' : 'offline'
try {
await ElMessageBox.confirm(
on ? '确认将车辆上线?' : '确认将车辆下线维护?',
'维护确认',
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }
)
await applyMaintenance(mode)
} catch {
/* cancelled */
}
}
async function onMaintenanceCommand(cmd: string) {
if (!props.canWrite) return
const mode = cmd as VehicleMaintenanceMode
if (mode === 'blown') {
try {
await ElMessageBox.confirm('返厂检修将停止调度并清空站点,确认?', '危险操作', {
type: 'error',
confirmButtonText: '确定',
cancelButtonText: '取消'
})
await applyMaintenance(mode)
} catch {
/* cancelled */
}
return
}
if (mode === 'repair') {
try {
await ElMessageBox.confirm('现场检修:不调度但仍刷新状态,确认?', '维护确认', {
type: 'warning'
})
await applyMaintenance(mode)
} catch {
/* cancelled */
}
return
}
await applyMaintenance(mode)
}
</script>
<style scoped>
.vehicle-health-card {
border: 1px solid var(--el-border-color-light);
border-radius: 8px;
background: var(--el-bg-color);
/* ── 全新车辆卡片:顶栏色带 + 环形电量 + 指标格 + 底栏信号 ── */
.v-card {
--vc-radius: 16px;
display: flex;
flex-direction: column;
align-self: start;
height: auto;
min-height: 220px;
border-radius: var(--vc-radius);
overflow: hidden;
cursor: pointer;
transition: box-shadow 0.15s ease, border-color 0.15s ease;
background: rgba(var(--mg-bg-card-rgb, 38, 24, 78), 0.92);
border: 1px solid var(--mg-glass-border, rgba(var(--mg-accent-rgb, 196, 181, 253), 0.22));
box-shadow: var(--mg-glass-shadow, 0 10px 28px rgba(var(--mg-shadow-rgb, 8, 2, 24), 0.5));
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
}
.vehicle-health-card:hover {
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
.v-card:hover {
transform: translateY(-4px);
border-color: var(--mg-glass-border-hi, rgba(var(--mg-primary-hover-rgb, 139, 92, 246), 0.55));
box-shadow: var(--mg-glass-shadow-hi, 0 16px 40px rgba(var(--mg-primary-rgb, 124, 58, 237), 0.32));
}
.vehicle-health-card.is-selected {
border-color: var(--el-color-primary);
box-shadow: 0 0 0 1px var(--el-color-primary-light-7);
.v-card.is-selected {
border-color: rgba(var(--mg-primary-hover-rgb, 139, 92, 246), 0.9);
box-shadow: 0 0 0 2px rgba(var(--mg-primary-hover-rgb, 139, 92, 246), 0.45);
}
.vehicle-health-card.is-alarm {
border-color: var(--el-color-danger-light-5);
/* ── 顶栏 ── */
.v-card__banner {
position: relative;
padding: 12px 14px 14px;
background: linear-gradient(
125deg,
rgba(var(--mg-primary-hover-rgb, 139, 92, 246), 0.55) 0%,
rgba(var(--mg-primary-rgb, 124, 58, 237), 0.35) 55%,
rgba(var(--mg-bg-card-darker-rgb, 20, 12, 48), 0.2) 100%
);
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.vehicle-health-card.is-offline,
.vehicle-health-card.is-maintenance {
border-color: var(--el-color-warning-light-5);
.v-card__banner.tone-danger {
background: linear-gradient(125deg, rgba(var(--mg-status-danger-rgb, 239, 68, 68), 0.45), rgba(var(--mg-bg-card-darker-rgb, 20, 12, 48), 0.5));
}
.v-card__banner.tone-warning {
background: linear-gradient(125deg, rgba(var(--mg-status-warning-rgb, 245, 158, 11), 0.4), rgba(var(--mg-primary-rgb, 124, 58, 237), 0.3));
}
.v-card__banner.tone-success {
background: linear-gradient(125deg, rgba(var(--mg-status-success-rgb, 34, 197, 94), 0.28), rgba(var(--mg-primary-rgb, 124, 58, 237), 0.38));
}
.v-card__banner.tone-info {
background: linear-gradient(125deg, rgba(var(--mg-status-info-rgb, 59, 130, 246), 0.35), rgba(var(--mg-primary-rgb, 124, 58, 237), 0.35));
}
.vehicle-health-card.is-unreachable {
opacity: 0.85;
.v-card__banner-glow {
position: absolute;
top: -30px;
right: -20px;
width: 120px;
height: 120px;
border-radius: 50%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.18) 0%, transparent 70%);
pointer-events: none;
}
.battery-strip {
height: 4px;
}
.card-head {
.v-card__banner-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 10px 4px;
gap: 8px;
margin-bottom: 10px;
}
.v-card__live {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.92);
letter-spacing: 0.3px;
}
.live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #fff;
box-shadow: 0 0 8px rgba(255, 255, 255, 0.8);
animation: pulse 2s ease-in-out infinite;
}
.tone-success .live-dot { background: var(--mg-status-success, #22c55e); box-shadow: 0 0 8px var(--mg-status-success, #22c55e); }
.tone-danger .live-dot { background: var(--mg-status-danger, #ef4444); box-shadow: 0 0 8px var(--mg-status-danger, #ef4444); }
.tone-warning .live-dot { background: var(--mg-status-warning, #f59e0b); box-shadow: 0 0 8px var(--mg-status-warning, #f59e0b); }
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.65; transform: scale(0.85); }
}
.v-card__tools {
display: flex;
align-items: center;
}
.v-card__identity {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
}
.title-block {
min-width: 0;
.v-card__name {
margin: 0;
font-size: 22px;
font-weight: 800;
color: #fff;
line-height: 1.1;
letter-spacing: -0.3px;
}
.v-card__id {
font-family: var(--mg-font-mono, ui-monospace, monospace);
font-size: 12px;
color: rgba(255, 255, 255, 0.65);
font-variant-numeric: tabular-nums;
}
.v-card__fleet {
margin-left: auto;
padding: 2px 8px;
border-radius: 6px;
font-size: 10px;
font-weight: 600;
background: rgba(0, 0, 0, 0.22);
color: rgba(255, 255, 255, 0.8);
}
/* ── 主体 ── */
.v-card__body {
display: flex;
gap: 14px;
padding: 14px;
align-items: center;
}
.v-card__ring {
position: relative;
width: 88px;
height: 88px;
flex-shrink: 0;
}
.ring-svg {
position: relative;
z-index: 0;
width: 100%;
height: 100%;
transform: rotate(-90deg);
}
.ring-track {
fill: none;
stroke: rgba(var(--mg-accent-rgb, 196, 181, 253), 0.18);
stroke-width: 6;
}
.ring-progress {
fill: none;
stroke: var(--mg-status-success, #22c55e);
stroke-width: 6;
stroke-linecap: round;
transition: stroke-dashoffset 0.4s ease;
filter: drop-shadow(0 0 4px rgba(var(--mg-status-success-rgb, 34, 197, 94), 0.45));
}
.v-card__ring.tone-warning .ring-progress {
stroke: var(--mg-status-warning, #f59e0b);
filter: drop-shadow(0 0 4px rgba(var(--mg-status-warning-rgb, 245, 158, 11), 0.45));
}
.v-card__ring.tone-danger .ring-progress {
stroke: var(--mg-status-danger, #ef4444);
filter: drop-shadow(0 0 4px rgba(var(--mg-status-danger-rgb, 239, 68, 68), 0.45));
}
.ring-center {
position: absolute;
inset: 0;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
pointer-events: none;
}
.vid {
font-size: 12px;
color: var(--el-text-color-secondary);
font-family: ui-monospace, monospace;
.ring-label {
font-size: 10px;
font-weight: 700;
color: var(--mg-text-dim, rgba(var(--mg-accent-rgb, 196, 181, 253), 0.95));
letter-spacing: 1px;
line-height: 1;
}
.vname {
font-weight: 600;
font-size: 14px;
.ring-pct {
font-family: var(--mg-font-mono, ui-monospace, monospace);
font-size: 18px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1;
color: var(--mg-text-light, #1a0f3d);
}
.v-card__ring.tone-warning .ring-pct {
color: var(--mg-status-warning, #f59e0b);
}
.v-card__ring.tone-danger .ring-pct {
color: var(--mg-status-danger, #ef4444);
}
.v-card__grid {
flex: 1;
min-width: 0;
margin: 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.cell {
margin: 0;
padding: 8px 10px;
border-radius: 10px;
background: var(--mg-veil-1, rgba(255, 255, 255, 0.05));
border: 1px solid var(--mg-veil-border, rgba(255, 255, 255, 0.07));
}
.cell--wide {
grid-column: 1 / -1;
}
.cell dt {
margin: 0 0 3px;
font-size: 10px;
color: var(--mg-text-muted, rgba(255, 255, 255, 0.5));
font-weight: 500;
}
.cell dd {
margin: 0;
font-size: 13px;
font-weight: 700;
color: var(--mg-text-light, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.head-actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
.card-body {
display: flex;
gap: 10px;
padding: 4px 10px 8px;
}
.thumb {
width: 56px;
height: 56px;
border-radius: 6px;
background: var(--el-fill-color-light);
display: flex;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary);
flex-shrink: 0;
}
.metrics {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 4px;
.cell dd.mono {
font-family: var(--mg-font-mono, ui-monospace, monospace);
font-weight: 600;
font-size: 12px;
}
.metric-row {
.cell dd.val-danger { color: var(--mg-status-danger, #ef4444); }
.cell dd.val-warn { color: var(--mg-status-warning, #f59e0b); }
.cell dd.val-ok { color: var(--mg-status-success, #22c55e); }
.resource-line {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px !important;
font-weight: 600 !important;
}
.resource-line .sep {
opacity: 0.35;
font-weight: 400;
}
/* ── 底栏 ── */
.v-card__dock {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 14px;
background: var(--mg-veil-2, rgba(0, 0, 0, 0.22));
border-top: 1px solid var(--mg-veil-border, rgba(255, 255, 255, 0.06));
}
.metric-row .label {
color: var(--el-text-color-secondary);
}
.metric-row .value {
font-weight: 500;
}
.metric-row .value.mono {
font-family: ui-monospace, monospace;
}
.metric-row .value.danger {
color: var(--el-color-danger);
}
.metric-row .value.warn {
color: var(--el-color-warning);
}
.icon-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
padding: 0 10px 8px;
}
.icon-cell {
.dock-signals {
display: flex;
flex-direction: column;
gap: 10px;
}
.signal {
display: inline-flex;
align-items: center;
gap: 2px;
font-size: 10px;
color: var(--el-text-color-secondary);
}
.icon-cell .is-alarm {
color: var(--el-color-danger);
}
.icon-cell .is-down {
color: var(--el-color-info);
}
.card-foot {
display: flex;
justify-content: space-between;
padding: 6px 10px;
gap: 4px;
font-size: 11px;
color: var(--el-text-color-secondary);
background: var(--el-fill-color-lighter);
border-top: 1px solid var(--el-border-color-lighter);
font-weight: 600;
color: var(--mg-text-muted, rgba(255, 255, 255, 0.55));
}
.card-foot .hint {
opacity: 0.7;
.signal .el-icon { font-size: 13px; }
.signal.is-ok { color: var(--mg-status-success, #22c55e); }
.signal.is-bad { color: var(--mg-status-danger, #ef4444); }
.dock-action {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
color: var(--mg-text-dim, rgba(255, 255, 255, 0.45));
letter-spacing: 0.2px;
transition: color 0.15s;
}
.v-card:hover .dock-action {
color: var(--mg-primary, #7c3aed);
}
</style>
@@ -0,0 +1,287 @@
<template>
<div
class="v-row"
:class="rowClass"
@click="onRowClick"
@dblclick="onRowDblClick"
>
<span class="v-row__accent" :class="accentTone" />
<div class="v-row__main">
<span class="live-dot" :class="accentTone" />
<div class="v-row__identity">
<span class="name" :title="vehicle.name">{{ vehicle.name }}</span>
<span class="id">{{ vehicle.id }}{{ missionIdSuffix }}</span>
</div>
<span class="mg-pill v-row__status" :class="statusPillClass">{{ statusDisplayLabel }}</span>
<div class="v-row__battery" :class="batteryTone" :title="`电量 ${batteryPct}%`">
<div class="bat-track"><div class="bat-fill" :style="{ width: `${batteryPct}%` }" /></div>
<span class="bat-pct">{{ batteryPct }}%</span>
</div>
<span class="v-row__meta mono" :title="vehicle.ip ?? ''">{{ vehicle.ip ?? '—' }}</span>
<span class="v-row__meta" :class="latencyClass">{{ latencyLabel }}</span>
<span class="v-row__meta" :class="faultClass">{{ faultLabel }}</span>
<span class="v-row__meta muted">{{ vehicle.group ?? '—' }}</span>
<div class="v-row__flags">
<span v-if="vehicle.isAlarmActive" class="flag bad">报警</span>
<span v-if="vehicle.reachable === false" class="flag bad">不可达</span>
</div>
</div>
<div class="v-row__tools" @click.stop>
<VehicleMaintenanceSelect
:vehicle="vehicle"
:can-write="canWrite"
@changed="emit('maintenanceChanged')"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import type { VehicleCardModel } from '@/types/car'
import { openOnboardWeb } from '@/api/vehicleOps'
import {
useVehicleCardState,
type VehicleCardStateOptions
} from '@/composables/useVehicleCardState'
import VehicleMaintenanceSelect from '@/components/fleet/VehicleMaintenanceSelect.vue'
const props = defineProps<{
vehicle: VehicleCardModel
selected?: boolean
canWrite?: boolean
}>()
const emit = defineEmits<{
select: [id: string]
maintenanceChanged: []
}>()
const stateOpts: VehicleCardStateOptions = { vehicle: () => props.vehicle }
const {
stateLabel,
batteryPct,
batteryTone,
statusPill,
missionIdSuffix,
statusDisplayLabel,
accentTone,
latencyLabel,
latencyClass,
faultLabel,
faultClass
} = useVehicleCardState(stateOpts)
const rowClass = computed(() => ({
'is-selected': props.selected,
'is-alarm': props.vehicle.isAlarmActive,
'is-unreachable': props.vehicle.reachable === false,
[accentTone.value]: true
}))
const statusPillClass = computed(() => {
switch (accentTone.value) {
case 'tone-success':
return 'is-success'
case 'tone-danger':
return 'is-danger'
case 'tone-warning':
return 'is-warning'
case 'tone-info':
return 'is-info'
default:
return 'is-idle'
}
})
function onRowClick() {
emit('select', props.vehicle.id)
}
function onRowDblClick(e: MouseEvent) {
const target = e.target as HTMLElement
if (target.closest('.v-row__tools')) return
openOnboardWeb(props.vehicle.onboardUrl, props.vehicle.ip)
}
</script>
<style scoped>
.v-row {
position: relative;
display: flex;
align-items: center;
gap: 10px;
min-height: 52px;
padding: 8px 12px 8px 14px;
border-radius: 12px;
background: rgba(var(--mg-bg-card-rgb, 38, 24, 78), 0.55);
border: 1px solid rgba(var(--mg-accent-rgb, 196, 181, 253), 0.18);
cursor: pointer;
transition: background 0.15s, border-color 0.15s, box-shadow 0.15s;
}
.v-row:hover {
background: rgba(var(--mg-bg-card-hi-rgb, 58, 38, 110), 0.65);
border-color: rgba(var(--mg-accent-rgb, 196, 181, 253), 0.38);
}
.v-row.is-selected {
border-color: rgba(var(--mg-primary-hover-rgb, 139, 92, 246), 0.75);
box-shadow: 0 0 0 1px rgba(var(--mg-primary-hover-rgb, 139, 92, 246), 0.4);
}
.v-row__accent {
position: absolute;
left: 0;
top: 8px;
bottom: 8px;
width: 3px;
border-radius: 0 3px 3px 0;
background: rgba(var(--mg-accent-rgb, 196, 181, 253), 0.5);
}
.v-row__accent.tone-success { background: var(--mg-status-success, #22c55e); }
.v-row__accent.tone-warning { background: var(--mg-status-warning, #f59e0b); }
.v-row__accent.tone-danger { background: var(--mg-status-danger, #ef4444); }
.v-row__accent.tone-info { background: var(--mg-status-info, #3b82f6); }
.v-row__main {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
background: rgba(255, 255, 255, 0.5);
}
.live-dot.tone-success { background: var(--mg-status-success, #22c55e); }
.live-dot.tone-danger { background: var(--mg-status-danger, #ef4444); }
.live-dot.tone-warning { background: var(--mg-status-warning, #f59e0b); }
.v-row__identity {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 100px;
max-width: 180px;
}
.name {
font-weight: 700;
font-size: 13px;
color: var(--mg-text-light, #fff);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.id {
font-size: 11px;
color: var(--mg-text-muted, rgba(255, 255, 255, 0.5));
font-family: var(--mg-font-mono, ui-monospace, monospace);
flex-shrink: 0;
}
.v-row__status {
font-size: 10px;
flex-shrink: 0;
}
.v-row__battery {
display: flex;
align-items: center;
gap: 6px;
width: 72px;
flex-shrink: 0;
}
.bat-track {
flex: 1;
height: 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
overflow: hidden;
}
.bat-fill {
height: 100%;
border-radius: inherit;
background: var(--mg-status-success, #22c55e);
}
.v-row__battery.tone-warning .bat-fill { background: var(--mg-status-warning, #f59e0b); }
.v-row__battery.tone-danger .bat-fill { background: var(--mg-status-danger, #ef4444); }
.bat-pct {
font-size: 10px;
font-family: var(--mg-font-mono, ui-monospace, monospace);
color: var(--mg-text-muted, rgba(255, 255, 255, 0.65));
min-width: 28px;
}
.v-row__meta {
font-size: 11px;
font-weight: 600;
color: var(--mg-text-light, #fff);
min-width: 48px;
white-space: nowrap;
}
.v-row__meta.mono {
font-family: var(--mg-font-mono, ui-monospace, monospace);
font-weight: 500;
min-width: 88px;
}
.v-row__meta.muted {
color: var(--mg-text-muted, rgba(255, 255, 255, 0.55));
font-weight: 500;
}
.v-row__meta.val-danger { color: var(--mg-status-danger, #ef4444); }
.v-row__meta.val-warn { color: var(--mg-status-warning, #f59e0b); }
.v-row__flags {
display: flex;
gap: 4px;
}
.flag {
padding: 1px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
}
.flag.bad {
color: var(--mg-status-danger, #b91c1c);
background: rgba(var(--mg-status-danger-rgb, 185, 28, 28), 0.12);
border: 1px solid rgba(var(--mg-status-danger-rgb, 185, 28, 28), 0.28);
}
.v-row__tools {
display: flex;
align-items: center;
flex-shrink: 0;
}
@media (max-width: 900px) {
.v-row__meta.muted,
.v-row__flags { display: none; }
}
</style>
@@ -0,0 +1,105 @@
<template>
<el-select
:model-value="currentMode"
size="small"
class="veh-maint-select"
:class="toneClass"
:disabled="!canWrite"
:teleported="true"
@change="onChange"
@click.stop
>
<el-option
v-for="opt in MAINTENANCE_OPTIONS"
:key="opt.value"
:label="opt.label"
:value="opt.value"
/>
</el-select>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { VehicleCardModel } from '@/types/car'
import type { VehicleMaintenanceMode } from '@/api/vehicleOps'
import {
MAINTENANCE_OPTIONS,
confirmAndApplyMaintenance
} from '@/composables/useVehicleMaintenanceActions'
const props = defineProps<{
vehicle: VehicleCardModel
canWrite?: boolean
}>()
const emit = defineEmits<{ changed: [] }>()
const currentMode = ref<VehicleMaintenanceMode>(props.vehicle.maintenanceMode ?? 'online')
watch(
() => props.vehicle.maintenanceMode,
(m) => {
currentMode.value = m ?? 'online'
}
)
const toneClass = computed(() => {
const m = currentMode.value
if (m === 'blown' || m === 'offline') return 'tone-danger'
if (m === 'repair') return 'tone-warning'
return 'tone-online'
})
async function onChange(mode: VehicleMaintenanceMode) {
const prev = props.vehicle.maintenanceMode ?? 'online'
if (mode === prev) return
const rawId = props.vehicle.rawId ?? parseInt(props.vehicle.id.replace(/\D/g, ''), 10)
const ok = await confirmAndApplyMaintenance(rawId, mode, prev)
if (ok) {
currentMode.value = mode
emit('changed')
} else {
currentMode.value = prev
}
}
</script>
<style scoped>
.veh-maint-select {
width: 108px;
}
.veh-maint-select :deep(.el-select__wrapper) {
background: rgba(0, 0, 0, 0.22);
border-color: rgba(255, 255, 255, 0.18);
box-shadow: none;
min-height: 28px;
padding: 0 8px;
}
.veh-maint-select :deep(.el-select__selected-item),
.veh-maint-select :deep(.el-select__placeholder) {
color: rgba(255, 255, 255, 0.92);
font-size: 12px;
font-weight: 600;
}
.veh-maint-select :deep(.el-select__caret) {
color: rgba(255, 255, 255, 0.65);
}
.veh-maint-select.tone-online :deep(.el-select__wrapper) {
border-color: rgba(var(--mg-status-success-rgb, 34, 197, 94), 0.45);
}
.veh-maint-select.tone-warning :deep(.el-select__wrapper) {
border-color: rgba(var(--mg-status-warning-rgb, 245, 158, 11), 0.5);
}
.veh-maint-select.tone-danger :deep(.el-select__wrapper) {
border-color: rgba(var(--mg-status-danger-rgb, 239, 68, 68), 0.5);
}
.veh-maint-select.is-disabled :deep(.el-select__wrapper) {
opacity: 0.65;
}
</style>
@@ -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
}
}
@@ -0,0 +1,62 @@
import {
Collection, Connection, Cpu, Document, DocumentCopy, EditPen,
Histogram, Link, MapLocation, Monitor, Notebook, OfficeBuilding,
Operation, Promotion, SetUp, Setting, Tools, User, Van, VideoCamera
} from '@element-plus/icons-vue'
import type { Component } from 'vue'
export interface NavMenuItem {
path: string
label: string
icon?: Component
key?: string
group?: string
children?: NavMenuItem[]
}
export const ADMIN_MENU: NavMenuItem[] = [
{ path: '/admin/dashboard', label: '总览', icon: Histogram, key: 'admin-dashboard', group: '概览' },
{ path: '/admin/map-monitor', label: '地图监控', icon: MapLocation, key: 'admin-map-monitor', group: '概览' },
{
path: '/admin/design', label: '设计与编排', icon: Tools, group: '设计与编排',
children: [
{ path: '/admin/maps', label: '地图管理', icon: MapLocation, key: 'admin-maps', group: '设计与编排' },
{ path: '/admin/map-editor', label: '地图编辑', icon: EditPen, key: 'admin-map-editor', group: '设计与编排' },
{ path: '/admin/project-properties', label: '项目属性', icon: Document, key: 'admin-project-properties', group: '设计与编排' },
{ path: '/admin/tracks', label: '场景管理', icon: Connection, key: 'admin-tracks', group: '设计与编排' },
{ path: '/admin/cars', label: '车辆管理', icon: Van, key: 'admin-cars', group: '设计与编排' },
{ path: '/admin/processes', label: '进程管理', icon: Cpu, key: 'admin-processes', group: '设计与编排' },
{ path: '/admin/scripts', label: '脚本管理', icon: DocumentCopy, key: 'admin-scripts', group: '设计与编排' },
{ path: '/admin/task-templates', label: '任务编排', icon: Operation, key: 'admin-task-templates', group: '设计与编排' },
{ path: '/admin/simple-fields', label: '字段管理', icon: Collection, key: 'admin-simple-fields', group: '设计与编排' }
]
},
{
path: '/admin/config', label: '平台配置中心', icon: Setting, group: '平台配置中心',
children: [
{ path: '/admin/config/strategy', label: '调度策略', icon: SetUp, key: 'admin-config-strategy', group: '平台配置中心' },
{ path: '/admin/config/vehicle-hub', label: '车辆运维', icon: Van, key: 'admin-vehicle-hub', group: '平台配置中心' },
{ path: '/admin/config/facility', label: '设备与库位', icon: OfficeBuilding, key: 'admin-config-facility', group: '平台配置中心' },
{ path: '/admin/config/business', label: '业务与集成', icon: Link, key: 'admin-config-business', group: '平台配置中心' },
{ path: '/admin/config/ops-center', label: '运维与回放', icon: VideoCamera, key: 'admin-config-ops-center', group: '平台配置中心' },
{ path: '/admin/config/system-center', label: '系统与权限', icon: User, key: 'admin-config-system-center', group: '平台配置中心' }
]
}
]
export const MONITOR_MENU: NavMenuItem[] = [
{ path: '/monitor/dashboard', label: '运营总览', icon: Monitor, key: 'monitor-dashboard', group: '运营监控' },
{ path: '/monitor/vehicle-hub', label: '车辆运维', icon: Van, key: 'monitor-vehicle-hub', group: '运营监控' },
{ path: '/monitor/map', label: '地图监控', icon: MapLocation, key: 'monitor-map', group: '运营监控' },
{ path: '/monitor/ops', label: '运维操作', icon: Promotion, key: 'monitor-ops', group: '运营监控' },
{ path: '/monitor/notes', label: '运营备注', icon: Notebook, key: 'monitor-notes', group: '运营监控' }
]
export function flattenNavMenu(items: NavMenuItem[]): NavMenuItem[] {
const out: NavMenuItem[] = []
for (const item of items) {
if (item.children?.length) out.push(...flattenNavMenu(item.children))
else if (item.key) out.push(item)
}
return out
}
@@ -0,0 +1,110 @@
import { Setting } from '@element-plus/icons-vue'
import type { Component } from 'vue'
import type { Scope } from '@/types/auth'
import {
ADMIN_MENU, MONITOR_MENU, flattenNavMenu, type NavMenuItem
} from '@/config/navMenu'
export const MAX_QUICK_ENTRIES = 16
export const QUICK_GRID_COLUMNS = 8
export interface QuickEntryDef {
key: string
label: string
path: string
icon: Component
hint?: string
primary?: boolean
pageKey: string
group?: string
}
/** 当前页即总览,不作为快捷入口候选 */
const EXCLUDED_QUICK_ENTRY_KEYS = new Set(['admin-dashboard', 'monitor-dashboard'])
/** 旧版别名 key → 菜单 key(加载/保存时归一化,避免重复项) */
const LEGACY_KEY_ALIASES: Record<string, string> = {
'platform-config': 'admin-map-editor',
mission: 'admin-task-templates',
cars: 'admin-cars',
auth: 'admin-config-system-center',
system: 'admin-config-system-center',
ops: 'admin-config-ops-center',
tasks: 'admin-config-strategy'
}
/** 与后端 DashboardShortcutCatalog.DefaultPlatformKeys 对齐(均为菜单 key */
export const DEFAULT_PLATFORM_QUICK_KEYS = [
'admin-map-editor',
'admin-task-templates',
'admin-cars',
'admin-config-system-center',
'admin-config-ops-center',
'admin-config-strategy'
] as const
export const DEFAULT_MONITOR_QUICK_KEYS = [
'monitor-vehicle-hub', 'monitor-map', 'monitor-ops'
] as const
export function normalizeQuickKey(key: string): string {
return LEGACY_KEY_ALIASES[key] ?? key
}
export function normalizeQuickKeys(keys: string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const raw of keys) {
const k = normalizeQuickKey(raw.trim())
if (!k || seen.has(k) || EXCLUDED_QUICK_ENTRY_KEYS.has(k)) continue
seen.add(k)
out.push(k)
}
return out
}
function menuItemToQuick(item: NavMenuItem): QuickEntryDef | null {
if (!item.key || EXCLUDED_QUICK_ENTRY_KEYS.has(item.key)) return null
return {
key: item.key,
label: item.label,
path: item.path,
icon: item.icon ?? Setting,
pageKey: item.key,
group: item.group
}
}
function buildCatalog(scope: Scope): Map<string, QuickEntryDef> {
const map = new Map<string, QuickEntryDef>()
const menu = scope === 'RCSMonitor' ? MONITOR_MENU : ADMIN_MENU
for (const item of flattenNavMenu(menu)) {
const q = menuItemToQuick(item)
if (q) map.set(q.key, q)
}
return map
}
export function getQuickEntryCatalog(scope: Scope): QuickEntryDef[] {
return [...buildCatalog(scope).values()]
}
export function resolveQuickEntry(key: string, scope: Scope): QuickEntryDef | undefined {
return buildCatalog(scope).get(normalizeQuickKey(key))
}
export function defaultQuickKeys(scope: Scope): string[] {
return scope === 'RCSMonitor'
? [...DEFAULT_MONITOR_QUICK_KEYS]
: [...DEFAULT_PLATFORM_QUICK_KEYS]
}
export function groupQuickEntries(items: QuickEntryDef[]): { group: string; items: QuickEntryDef[] }[] {
const groups = new Map<string, QuickEntryDef[]>()
for (const item of items) {
const g = item.group ?? '其他'
if (!groups.has(g)) groups.set(g, [])
groups.get(g)!.push(item)
}
return [...groups.entries()].map(([group, list]) => ({ group, items: list }))
}
@@ -107,10 +107,8 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import {
Fold, Expand, CaretBottom, Monitor, Setting, Histogram, Tools,
MapLocation, Van, Promotion, Notebook
} from '@element-plus/icons-vue'
import { Fold, Expand, CaretBottom } from '@element-plus/icons-vue'
import { ADMIN_MENU, MONITOR_MENU, type NavMenuItem } from '@/config/navMenu'
import { useAuthStore } from '@/stores/auth'
import { useUiStore } from '@/stores/ui'
import ScopeSwitcher from '@/components/ScopeSwitcher.vue'
@@ -139,45 +137,7 @@ const runModeLabel = computed(() => {
// key 即「权限页面 Key」(= vue-router route.name),用于按 auth.allowedPages 过滤菜单。
// 分组节点(无 key)只要还有可见子项就保留。
interface MenuItem { path: string; label: string; icon?: unknown; key?: string; children?: MenuItem[] }
const ADMIN_MENU: MenuItem[] = [
{ path: '/admin/dashboard', label: '总览', icon: Histogram, key: 'admin-dashboard' },
{ path: '/admin/map-monitor', label: '地图监控', icon: MapLocation, key: 'admin-map-monitor' },
{
path: '/admin/design', label: '设计与编排', icon: Tools,
children: [
{ path: '/admin/maps', label: '地图管理', key: 'admin-maps' },
{ path: '/admin/map-editor', label: '地图编辑', key: 'admin-map-editor' },
{ path: '/admin/project-properties', label: '项目属性', key: 'admin-project-properties' },
{ path: '/admin/tracks', label: '场景管理', key: 'admin-tracks' },
{ path: '/admin/cars', label: '车辆管理', key: 'admin-cars' },
{ path: '/admin/processes', label: '进程管理', key: 'admin-processes' },
{ path: '/admin/scripts', label: '脚本管理', key: 'admin-scripts' },
{ path: '/admin/task-templates', label: '任务编排', key: 'admin-task-templates' },
{ path: '/admin/simple-fields', label: '字段管理', key: 'admin-simple-fields' }
]
},
{
path: '/admin/config', label: '平台配置中心', icon: Setting,
children: [
{ path: '/admin/config/strategy', label: '调度策略', key: 'admin-config-strategy' },
{ path: '/admin/config/vehicle-hub', label: '车辆运维', key: 'admin-vehicle-hub' },
{ path: '/admin/config/facility', label: '设备与库位', key: 'admin-config-facility' },
{ path: '/admin/config/business', label: '业务与集成', key: 'admin-config-business' },
{ path: '/admin/config/ops-center', label: '运维与回放', key: 'admin-config-ops-center' },
{ path: '/admin/config/system-center', label: '系统与权限', key: 'admin-config-system-center' }
]
}
]
const MONITOR_MENU: MenuItem[] = [
{ path: '/monitor/dashboard', label: '运营总览', icon: Monitor, key: 'monitor-dashboard' },
{ path: '/monitor/vehicle-hub', label: '车辆运维', icon: Van, key: 'monitor-vehicle-hub' },
{ path: '/monitor/map', label: '地图监控', icon: MapLocation, key: 'monitor-map' },
{ path: '/monitor/ops', label: '运维操作', icon: Promotion, key: 'monitor-ops' },
{ path: '/monitor/notes', label: '运营备注', icon: Notebook, key: 'monitor-notes' }
]
type MenuItem = NavMenuItem
function filterMenu(items: MenuItem[]): MenuItem[] {
const out: MenuItem[] = []
@@ -957,6 +957,12 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); }
--mg-status-idle: #64748b;
--mg-status-idle-rgb: 100, 116, 139;
/* 卡片 / 列表行:浅底白卡(与 industrial-purple 深紫卡区分) */
--mg-bg-card-rgb: 255, 255, 255;
--mg-bg-card-hi-rgb: 249, 246, 255;
--mg-bg-card-darker-rgb: 245, 240, 251;
--mg-accent-rgb: 124, 58, 237;
/* 内嵌表面 veil:浅紫主题翻成紫色微透,保证白底卡片内的嵌套行 / 子卡可见 */
--mg-veil-1: rgba(124, 58, 237, 0.05);
--mg-veil-2: rgba(124, 58, 237, 0.10);
@@ -1345,6 +1351,11 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); }
color: #5b21b6 !important;
border-color: #ddd6fe !important;
}
:root[data-theme="fame-lavender"] .mg-content .el-tag--primary {
background: #ede9fe !important;
color: #5b21b6 !important;
border-color: #c4b5fd !important;
}
:root[data-theme="fame-lavender"] .mg-content .el-tag--success {
background: #ecfdf5 !important;
color: #047857 !important;
@@ -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;