优化小车卡片界面和车队编队的功能,优化首页菜单的快捷入口功能
This commit is contained in:
@@ -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
|
||||
}
|
||||
+211
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user