地图监控改为固定双栏并可收起侧栏,编辑对齐改走内核 CAD,并接通 428 确认票。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -42,3 +42,4 @@ Desktop.ini
|
|||||||
.codex-temp/
|
.codex-temp/
|
||||||
frontends/apps/simple-platform-vue/imgui.ini
|
frontends/apps/simple-platform-vue/imgui.ini
|
||||||
/.cursor/plans
|
/.cursor/plans
|
||||||
|
/tmp-*.json
|
||||||
|
|||||||
@@ -385,6 +385,13 @@ public class OpsController : ControllerBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var call = await CallLiteAsync(HttpMethod.Post, path, actor);
|
var call = await CallLiteAsync(HttpMethod.Post, path, actor);
|
||||||
|
if (TryReadConfirmTicket(call.Body, out var token))
|
||||||
|
{
|
||||||
|
call = await CallLiteAsync(HttpMethod.Post, path, actor, new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
[ConfirmTokenHeader] = token
|
||||||
|
});
|
||||||
|
}
|
||||||
var success = call.Ok && ParseSuccess(call.Body);
|
var success = call.Ok && ParseSuccess(call.Body);
|
||||||
return success
|
return success
|
||||||
? new ForwardOutcome(true, "ok", null)
|
? new ForwardOutcome(true, "ok", null)
|
||||||
@@ -397,7 +404,32 @@ public class OpsController : ControllerBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<(bool Ok, int Status, string Body)> CallLiteAsync(HttpMethod method, string path, string? actor)
|
private const string ConfirmTokenHeader = "X-Platform-Confirm-Token";
|
||||||
|
|
||||||
|
private static bool TryReadConfirmTicket(string body, out string token)
|
||||||
|
{
|
||||||
|
token = "";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
var code = root.TryGetProperty("code", out var c) && c.TryGetInt32(out var n) ? n : 0;
|
||||||
|
if (code != 428) return false;
|
||||||
|
if (!root.TryGetProperty("data", out var data) || data.ValueKind != JsonValueKind.Object)
|
||||||
|
return false;
|
||||||
|
token = data.TryGetProperty("confirmToken", out var t) && t.ValueKind == JsonValueKind.String
|
||||||
|
? t.GetString() ?? ""
|
||||||
|
: "";
|
||||||
|
return !string.IsNullOrWhiteSpace(token);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(bool Ok, int Status, string Body)> CallLiteAsync(
|
||||||
|
HttpMethod method, string path, string? actor, IReadOnlyDictionary<string, string>? extraHeaders = null)
|
||||||
{
|
{
|
||||||
var url = $"http://127.0.0.1:{_sl.ProjectionPort}{path}";
|
var url = $"http://127.0.0.1:{_sl.ProjectionPort}{path}";
|
||||||
using var client = _httpFactory.CreateClient();
|
using var client = _httpFactory.CreateClient();
|
||||||
@@ -409,6 +441,14 @@ public class OpsController : ControllerBase
|
|||||||
msg.Headers.TryAddWithoutValidation("X-Platform-Confirmed", "1");
|
msg.Headers.TryAddWithoutValidation("X-Platform-Confirmed", "1");
|
||||||
if (!string.IsNullOrWhiteSpace(actor))
|
if (!string.IsNullOrWhiteSpace(actor))
|
||||||
msg.Headers.TryAddWithoutValidation("X-Platform-User", actor);
|
msg.Headers.TryAddWithoutValidation("X-Platform-User", actor);
|
||||||
|
if (extraHeaders != null)
|
||||||
|
{
|
||||||
|
foreach (var kv in extraHeaders)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(kv.Key) || kv.Value is null) continue;
|
||||||
|
msg.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
using var resp = await client.SendAsync(msg);
|
using var resp = await client.SendAsync(msg);
|
||||||
var body = await resp.Content.ReadAsStringAsync();
|
var body = await resp.Content.ReadAsStringAsync();
|
||||||
return (resp.IsSuccessStatusCode, (int)resp.StatusCode, body);
|
return (resp.IsSuccessStatusCode, (int)resp.StatusCode, body);
|
||||||
|
|||||||
@@ -260,10 +260,16 @@ export function formatReflectionExecuteMessage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ReflectionExecuteOptions {
|
export interface ReflectionExecuteOptions {
|
||||||
/** 已在平台侧完成二次确认时带上,对应后端 X-Platform-Confirmed: 1 */
|
/** 已在平台侧完成二次确认时带上;新内核仍需再带 428 下发的 confirmToken。 */
|
||||||
platformConfirmed?: boolean
|
platformConfirmed?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PlatformConfirmChallenge {
|
||||||
|
message: string
|
||||||
|
token: string
|
||||||
|
header: string
|
||||||
|
}
|
||||||
|
|
||||||
async function get<T>(path: string): Promise<T> {
|
async function get<T>(path: string): Promise<T> {
|
||||||
const { data } = await http.get<ReflectionEnvelope<T>>(`${BASE}${path}`)
|
const { data } = await http.get<ReflectionEnvelope<T>>(`${BASE}${path}`)
|
||||||
if (!data?.success) throw new ReflectionApiError(data?.message ?? `reflection ${path} failed`, data?.code ?? 500, data?.data)
|
if (!data?.success) throw new ReflectionApiError(data?.message ?? `reflection ${path} failed`, data?.code ?? 500, data?.data)
|
||||||
@@ -275,9 +281,69 @@ async function post<T>(
|
|||||||
params?: Record<string, string | number | boolean>,
|
params?: Record<string, string | number | boolean>,
|
||||||
headers?: Record<string, string>
|
headers?: Record<string, string>
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const { data } = await http.post<ReflectionEnvelope<T>>(`${BASE}${path}`, null, { params, headers })
|
try {
|
||||||
if (!data?.success) throw new ReflectionApiError(data?.message ?? `reflection ${path} failed`, data?.code ?? 500, data?.data)
|
const { data } = await http.post<ReflectionEnvelope<T>>(`${BASE}${path}`, null, { params, headers })
|
||||||
return data.data as T
|
if (!data?.success) throw new ReflectionApiError(data?.message ?? `reflection ${path} failed`, data?.code ?? 500, data?.data)
|
||||||
|
return data.data as T
|
||||||
|
} catch (e) {
|
||||||
|
if (e instanceof ReflectionApiError) throw e
|
||||||
|
throw wrapEnvelopeError(e, `reflection ${path} failed`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapEnvelopeError(e: unknown, fallback: string): unknown {
|
||||||
|
const resp = e && typeof e === 'object' && 'response' in e
|
||||||
|
? (e as { response?: { status?: number; data?: ReflectionEnvelope<unknown> } }).response
|
||||||
|
: undefined
|
||||||
|
const data = resp?.data
|
||||||
|
if (data && typeof data === 'object' && (data.success === false || typeof data.code === 'number')) {
|
||||||
|
return new ReflectionApiError(
|
||||||
|
data.message || fallback,
|
||||||
|
data.code ?? resp?.status ?? 500,
|
||||||
|
data.data
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONFIRM_TOKEN_HEADER = 'X-Platform-Confirm-Token'
|
||||||
|
|
||||||
|
function parseConfirmChallenge(e: unknown): PlatformConfirmChallenge | null {
|
||||||
|
const payload = unwrapConfirmPayload(e)
|
||||||
|
const token = payload?.confirmToken?.trim()
|
||||||
|
if (!token) return null
|
||||||
|
const rawHeader = payload?.confirmTokenHeader?.trim()
|
||||||
|
return {
|
||||||
|
message: payload?.confirmMessage?.trim() || '此操作需要确认',
|
||||||
|
token,
|
||||||
|
header: rawHeader && rawHeader.toLowerCase() === CONFIRM_TOKEN_HEADER.toLowerCase()
|
||||||
|
? rawHeader
|
||||||
|
: CONFIRM_TOKEN_HEADER
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function unwrapConfirmPayload(e: unknown): {
|
||||||
|
confirmMessage?: string | null
|
||||||
|
confirmToken?: string | null
|
||||||
|
confirmTokenHeader?: string | null
|
||||||
|
} | null {
|
||||||
|
if (e instanceof ReflectionApiError) {
|
||||||
|
if (e.code !== 428) return null
|
||||||
|
return (e.data as {
|
||||||
|
confirmMessage?: string | null
|
||||||
|
confirmToken?: string | null
|
||||||
|
confirmTokenHeader?: string | null
|
||||||
|
} | null) ?? null
|
||||||
|
}
|
||||||
|
const resp = e && typeof e === 'object' && 'response' in e
|
||||||
|
? (e as { response?: { status?: number; data?: ReflectionEnvelope<Record<string, string>> } }).response
|
||||||
|
: undefined
|
||||||
|
const data = resp?.data
|
||||||
|
const code = typeof data?.code === 'number' ? data.code : resp?.status
|
||||||
|
if (code !== 428) return null
|
||||||
|
const inner = data?.data
|
||||||
|
if (inner && typeof inner === 'object') return inner
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function del<T>(path: string): Promise<T> {
|
async function del<T>(path: string): Promise<T> {
|
||||||
@@ -297,22 +363,24 @@ async function executeWithPlatformConfirm<T>(
|
|||||||
params?: Record<string, string | number | boolean>,
|
params?: Record<string, string | number | boolean>,
|
||||||
opts?: ReflectionExecuteOptions
|
opts?: ReflectionExecuteOptions
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const headers = opts?.platformConfirmed ? { 'X-Platform-Confirmed': '1' } : undefined
|
const headers: Record<string, string> = {}
|
||||||
|
if (opts?.platformConfirmed) headers['X-Platform-Confirmed'] = '1'
|
||||||
try {
|
try {
|
||||||
return await post<T>(path, params, headers)
|
return await post<T>(path, params, Object.keys(headers).length ? headers : undefined)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof ReflectionApiError && e.code === 428 && !opts?.platformConfirmed) {
|
const challenge = parseConfirmChallenge(e)
|
||||||
const confirmMessage =
|
if (!challenge) throw e
|
||||||
(e.data as { confirmMessage?: string | null } | null)?.confirmMessage?.trim()
|
if (!opts?.platformConfirmed) {
|
||||||
|| '此操作需要确认'
|
await ElMessageBox.confirm(challenge.message, '确认', {
|
||||||
await ElMessageBox.confirm(confirmMessage, '确认', {
|
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
confirmButtonText: '确定',
|
confirmButtonText: '确定',
|
||||||
cancelButtonText: '取消'
|
cancelButtonText: '取消'
|
||||||
})
|
})
|
||||||
return await post<T>(path, params, { 'X-Platform-Confirmed': '1' })
|
|
||||||
}
|
}
|
||||||
throw e
|
return await post<T>(path, params, {
|
||||||
|
'X-Platform-Confirmed': '1',
|
||||||
|
[challenge.header]: challenge.token
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1350,38 +1350,74 @@ function pickCreateKind(originalKind: string, typeName: string): string {
|
|||||||
return originalKind
|
return originalKind
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CAD_ALIGN_MODES = new Set<AlignMode>([
|
||||||
|
'left', 'right', 'top', 'bottom', 'centerH', 'centerV', 'center', 'distributeH', 'distributeV'
|
||||||
|
])
|
||||||
|
|
||||||
async function applyAlignment(mode: AlignMode) {
|
async function applyAlignment(mode: AlignMode) {
|
||||||
// 选中对象必须是有 x,y 坐标的(site / special?
|
const refs = selection.items.value.filter((it) => it.kind !== 'track' && it.kind !== 'car')
|
||||||
const targets: AlignTarget[] = []
|
if (refs.length < 2) {
|
||||||
for (const it of selection.items.value) {
|
ElMessage.warning('对齐需要选中至少 2 个站点或装饰(文本/模型)')
|
||||||
if (it.kind === 'track') continue
|
|
||||||
// 拉一?bundle ?x/y
|
|
||||||
try {
|
|
||||||
const b = await reflectionApi.getBundle(it.kind, it.id)
|
|
||||||
const x = Number(b.fields?.x ?? 0)
|
|
||||||
const y = Number(b.fields?.y ?? 0)
|
|
||||||
targets.push({ kind: it.kind, id: it.id, x, y })
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message : String(err)
|
|
||||||
throw new Error(`读取对象坐标失败(${it.kind}#${it.id}):${msg}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (targets.length < 2) {
|
|
||||||
ElMessage.warning('对齐需要选中至少 2 个对象')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const ops = buildAlignOps(targets, mode)
|
|
||||||
if (ops.length === 0) { ElMessage.info('对齐无变化'); return }
|
|
||||||
|
|
||||||
await history.run({
|
try {
|
||||||
label: `对齐 (${mode})`,
|
if (CAD_ALIGN_MODES.has(mode)) {
|
||||||
apply: async () => { await mapEditApi.batch(ops) },
|
let previous: Array<{ kind: string; id: number; x: number; y: number }> = []
|
||||||
revert: async () => {
|
let alignedCount = 0
|
||||||
// 反向恢复每个对象的原坐标
|
await history.run({
|
||||||
const revertOps = targets.map((t) => ({ action: 'patch' as const, kind: t.kind, id: t.id, data: { x: t.x, y: t.y } }))
|
label: `对齐 (${mode})`,
|
||||||
await mapEditApi.batch(revertOps)
|
apply: async () => {
|
||||||
|
const r = await mapEditApi.cadAlign(mode, refs.map((it) => ({ kind: it.kind, id: it.id })))
|
||||||
|
previous = r.previous ?? []
|
||||||
|
alignedCount = r.count ?? 0
|
||||||
|
if (!alignedCount) throw new Error('没有可对齐的站点或装饰')
|
||||||
|
},
|
||||||
|
revert: async () => {
|
||||||
|
if (!previous.length) return
|
||||||
|
await mapEditApi.batch(previous.map((t) => ({
|
||||||
|
action: 'patch' as const,
|
||||||
|
kind: t.kind,
|
||||||
|
id: t.id,
|
||||||
|
data: { x: t.x, y: t.y }
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ElMessage.success(`已对齐 ${alignedCount} 个对象`)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
const targets: AlignTarget[] = []
|
||||||
|
for (const it of refs) {
|
||||||
|
const b = await reflectionApi.getBundle(it.kind, it.id)
|
||||||
|
targets.push({
|
||||||
|
kind: it.kind,
|
||||||
|
id: it.id,
|
||||||
|
x: Number(b.fields?.x ?? 0),
|
||||||
|
y: Number(b.fields?.y ?? 0)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const ops = buildAlignOps(targets, mode)
|
||||||
|
if (ops.length === 0) {
|
||||||
|
ElMessage.info('对齐无变化')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await history.run({
|
||||||
|
label: `对齐 (${mode})`,
|
||||||
|
apply: async () => { await mapEditApi.batch(ops) },
|
||||||
|
revert: async () => {
|
||||||
|
await mapEditApi.batch(targets.map((t) => ({
|
||||||
|
action: 'patch' as const,
|
||||||
|
kind: t.kind,
|
||||||
|
id: t.id,
|
||||||
|
data: { x: t.x, y: t.y }
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ElMessage.success(`已对齐 ${targets.length} 个对象`)
|
||||||
|
} catch (err) {
|
||||||
|
ElMessage.error(`对齐失败:${(err as Error).message}`)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runBatchGenerate(id: EditToolId) {
|
async function runBatchGenerate(id: EditToolId) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="map-monitor-page" :class="[`layout-${layoutMode}`, { 'sheet-collapsed': sheetCollapsed }]">
|
<div class="map-monitor-page layout-dual">
|
||||||
<header class="mm-command-bar" aria-label="监控指挥条">
|
<header class="mm-command-bar" aria-label="监控指挥条">
|
||||||
<div class="mm-kpi-pills" aria-label="关键指标">
|
<div class="mm-kpi-pills" aria-label="关键指标">
|
||||||
<div class="mm-pill">
|
<div class="mm-pill">
|
||||||
@@ -51,22 +51,6 @@
|
|||||||
<span class="mm-live" :class="{ 'is-live': streamConnected }">
|
<span class="mm-live" :class="{ 'is-live': streamConnected }">
|
||||||
{{ streamConnected ? '实时' : '轮询' }}
|
{{ streamConnected ? '实时' : '轮询' }}
|
||||||
</span>
|
</span>
|
||||||
<div class="mm-layout-toggle" role="group" aria-label="布局切换">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="mm-toggle-btn"
|
|
||||||
:class="{ 'is-active': layoutMode === 'dual' }"
|
|
||||||
title="双栏:地图 + 右侧列表/详情"
|
|
||||||
@click="setLayout('dual')"
|
|
||||||
>双栏</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="mm-toggle-btn"
|
|
||||||
:class="{ 'is-active': layoutMode === 'immersive' }"
|
|
||||||
title="沉浸:指挥条在上、列表在下,中间为地图"
|
|
||||||
@click="setLayout('immersive')"
|
|
||||||
>沉浸</button>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="mm-icon-btn" :disabled="refreshing" @click="refreshAll">
|
<button type="button" class="mm-icon-btn" :disabled="refreshing" @click="refreshAll">
|
||||||
{{ refreshing ? '刷新中' : '刷新' }}
|
{{ refreshing ? '刷新中' : '刷新' }}
|
||||||
</button>
|
</button>
|
||||||
@@ -89,45 +73,40 @@
|
|||||||
@ready="onWorkspaceReady"
|
@ready="onWorkspaceReady"
|
||||||
/>
|
/>
|
||||||
<FloatingAlarmStack :alarms="alarms" @locate="onAlarmLocate" @ack="onAlarmAck" />
|
<FloatingAlarmStack :alarms="alarms" @locate="onAlarmLocate" @ack="onAlarmAck" />
|
||||||
|
|
||||||
<aside
|
|
||||||
v-if="selection && layoutMode === 'immersive'"
|
|
||||||
ref="floatPanelRef"
|
|
||||||
class="mm-float-detail"
|
|
||||||
:class="{ 'is-dragging': floatDragging }"
|
|
||||||
:style="floatPanelStyle"
|
|
||||||
aria-label="选中详情"
|
|
||||||
>
|
|
||||||
<header class="mm-float-head" @mousedown.prevent="onFloatDragStart">
|
|
||||||
<div class="mm-float-title">
|
|
||||||
<span class="mm-float-grip" aria-hidden="true" />
|
|
||||||
<span class="mm-float-id">{{ floatTitle }}</span>
|
|
||||||
<span v-if="selectionKindLabel" class="mm-float-kind">{{ selectionKindLabel }}</span>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="mm-float-close" aria-label="关闭" @mousedown.stop @click="clearSelection">×</button>
|
|
||||||
</header>
|
|
||||||
<div class="mm-float-body">
|
|
||||||
<MonitorSelectionPanel
|
|
||||||
:selection="selection"
|
|
||||||
:refresh-key="tick"
|
|
||||||
:cars="cars"
|
|
||||||
:missions="missions"
|
|
||||||
:read-only="!!readOnly"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<aside class="mm-panel" aria-label="工作列表面板">
|
<aside
|
||||||
<div v-if="layoutMode === 'immersive'" class="mm-sheet-handle">
|
class="mm-panel"
|
||||||
<button type="button" class="mm-sheet-toggle" @click="toggleSheet">
|
:class="{ 'is-docked': isDualDocked, 'is-peeking': isDualDocked && !!dockPeek }"
|
||||||
{{ sheetCollapsed ? '展开列表' : '收起列表' }}
|
aria-label="工作列表面板"
|
||||||
|
>
|
||||||
|
<div v-if="railCollapsed" class="mm-dock" aria-label="压缩面板">
|
||||||
|
<button type="button" class="mm-rail-fold" title="展开完整面板" @click="toggleRail">
|
||||||
|
展开
|
||||||
</button>
|
</button>
|
||||||
<span class="mm-sheet-hint">沉浸 · 画布下方列表</span>
|
<div class="mm-dock-cards" role="tablist">
|
||||||
|
<button
|
||||||
|
v-for="card in dockCards"
|
||||||
|
:key="card.id"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class="mm-dock-card"
|
||||||
|
:class="{
|
||||||
|
'is-active': dockPeek === card.id,
|
||||||
|
'has-alert': card.alert
|
||||||
|
}"
|
||||||
|
:aria-selected="dockPeek === card.id"
|
||||||
|
:title="`${card.label} ${card.count}`"
|
||||||
|
@click="onDockCard(card.id)"
|
||||||
|
>
|
||||||
|
<span class="mm-dock-label">{{ card.label }}</span>
|
||||||
|
<span class="mm-dock-count">{{ card.count }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-show="!sheetCollapsed || layoutMode === 'dual'" class="mm-panel-list">
|
<div v-show="listVisible" class="mm-panel-list">
|
||||||
<div class="mm-rail-head">
|
<div v-if="!isDualDocked" class="mm-rail-head">
|
||||||
<div class="mm-segment" role="tablist">
|
<div class="mm-segment" role="tablist">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -157,22 +136,30 @@
|
|||||||
<span v-if="activeAlarmCount" class="mm-seg-badge">{{ activeAlarmCount }}</span>
|
<span v-if="activeAlarmCount" class="mm-seg-badge">{{ activeAlarmCount }}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<span class="mm-rail-count">
|
<div class="mm-rail-head-actions">
|
||||||
{{
|
<span class="mm-rail-count">
|
||||||
workbenchTab === 'vehicle'
|
{{
|
||||||
? `${cars.length}`
|
workbenchTab === 'vehicle'
|
||||||
: workbenchTab === 'mission'
|
? `${cars.length}`
|
||||||
? `${deliveries.length}`
|
: workbenchTab === 'mission'
|
||||||
: `${activeAlarmCount}`
|
? `${deliveries.length}`
|
||||||
}}
|
: `${activeAlarmCount}`
|
||||||
</span>
|
}}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="mm-rail-fold"
|
||||||
|
title="收起为车辆 / 任务 / 告警压缩框"
|
||||||
|
@click="toggleRail"
|
||||||
|
>收起</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mm-rail-list">
|
<div class="mm-rail-list">
|
||||||
<VehicleMonitorPanel
|
<VehicleMonitorPanel
|
||||||
v-show="workbenchTab === 'vehicle'"
|
v-show="workbenchTab === 'vehicle'"
|
||||||
table
|
table
|
||||||
:lite="layoutMode === 'dual'"
|
lite
|
||||||
:cars="cars"
|
:cars="cars"
|
||||||
:missions="missions"
|
:missions="missions"
|
||||||
:selected-id="selectedVehicleId"
|
:selected-id="selectedVehicleId"
|
||||||
@@ -209,7 +196,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="layoutMode === 'dual'" class="mm-panel-detail">
|
<div v-if="!railCollapsed" class="mm-panel-detail">
|
||||||
<header class="mm-detail-head">
|
<header class="mm-detail-head">
|
||||||
<span>选中信息</span>
|
<span>选中信息</span>
|
||||||
<button
|
<button
|
||||||
@@ -331,98 +318,50 @@ const activeAlarmCount = computed(() => activeAlarms.value.length)
|
|||||||
const clockText = ref('')
|
const clockText = ref('')
|
||||||
let clockTimer: ReturnType<typeof setInterval> | null = null
|
let clockTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
/** 选中详情浮动窗:可拖拽,默认右下角 */
|
type WorkbenchTab = 'vehicle' | 'mission' | 'alarm'
|
||||||
const floatPanelRef = ref<HTMLElement | null>(null)
|
const RAIL_KEY = 'migu.map-monitor.railCollapsed'
|
||||||
const floatDragging = ref(false)
|
|
||||||
const floatPos = ref<{ left?: number; top?: number; right?: number; bottom?: number }>({
|
const railCollapsed = ref(readStoredRailCollapsed())
|
||||||
right: 14,
|
const dockPeek = ref<WorkbenchTab | null>(null)
|
||||||
bottom: 14
|
|
||||||
|
const isDualDocked = computed(() => railCollapsed.value)
|
||||||
|
const listVisible = computed(() => {
|
||||||
|
if (railCollapsed.value) return dockPeek.value != null
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
const floatPanelStyle = computed(() => ({
|
const dockCards = computed(() => [
|
||||||
left: floatPos.value.left !== undefined ? `${floatPos.value.left}px` : undefined,
|
{ id: 'vehicle' as const, label: '车辆', count: cars.value.length, alert: false },
|
||||||
top: floatPos.value.top !== undefined ? `${floatPos.value.top}px` : undefined,
|
{ id: 'mission' as const, label: '任务', count: deliveries.value.length, alert: false },
|
||||||
right: floatPos.value.right !== undefined ? `${floatPos.value.right}px` : undefined,
|
{ id: 'alarm' as const, label: '告警', count: activeAlarmCount.value, alert: activeAlarmCount.value > 0 }
|
||||||
bottom: floatPos.value.bottom !== undefined ? `${floatPos.value.bottom}px` : undefined
|
])
|
||||||
}))
|
|
||||||
|
|
||||||
type MonitorLayout = 'dual' | 'immersive'
|
function readStoredRailCollapsed(): boolean {
|
||||||
const LAYOUT_KEY = 'migu.map-monitor.layout'
|
|
||||||
|
|
||||||
function readStoredLayout(): MonitorLayout {
|
|
||||||
try {
|
try {
|
||||||
const v = localStorage.getItem(LAYOUT_KEY)
|
return localStorage.getItem(RAIL_KEY) === '1'
|
||||||
if (v === 'dual' || v === 'immersive') return v
|
} catch {
|
||||||
} catch { /* ignore */ }
|
return false
|
||||||
return 'dual'
|
|
||||||
}
|
|
||||||
|
|
||||||
const layoutMode = ref<MonitorLayout>(readStoredLayout())
|
|
||||||
const sheetCollapsed = ref(false)
|
|
||||||
|
|
||||||
function setLayout(mode: MonitorLayout) {
|
|
||||||
if (layoutMode.value === mode) return
|
|
||||||
layoutMode.value = mode
|
|
||||||
try { localStorage.setItem(LAYOUT_KEY, mode) } catch { /* ignore */ }
|
|
||||||
if (mode === 'immersive') {
|
|
||||||
floatPos.value = { right: 14, bottom: 14 }
|
|
||||||
} else {
|
|
||||||
floatPos.value = { right: 14, bottom: 14 }
|
|
||||||
sheetCollapsed.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSheet() {
|
function persistRailCollapsed() {
|
||||||
sheetCollapsed.value = !sheetCollapsed.value
|
try { localStorage.setItem(RAIL_KEY, railCollapsed.value ? '1' : '0') } catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
let floatDragStartX = 0
|
function toggleRail() {
|
||||||
let floatDragStartY = 0
|
railCollapsed.value = !railCollapsed.value
|
||||||
let floatOriginLeft = 0
|
if (railCollapsed.value) dockPeek.value = null
|
||||||
let floatOriginTop = 0
|
persistRailCollapsed()
|
||||||
|
|
||||||
function onFloatDragStart(ev: MouseEvent) {
|
|
||||||
const el = floatPanelRef.value
|
|
||||||
if (!el) return
|
|
||||||
const rect = el.getBoundingClientRect()
|
|
||||||
const parent = el.offsetParent as HTMLElement | null
|
|
||||||
const parentRect = parent?.getBoundingClientRect()
|
|
||||||
floatOriginLeft = parentRect ? rect.left - parentRect.left : rect.left
|
|
||||||
floatOriginTop = parentRect ? rect.top - parentRect.top : rect.top
|
|
||||||
floatDragStartX = ev.clientX
|
|
||||||
floatDragStartY = ev.clientY
|
|
||||||
floatDragging.value = true
|
|
||||||
floatPos.value = { left: floatOriginLeft, top: floatOriginTop }
|
|
||||||
window.addEventListener('mousemove', onFloatDragMove)
|
|
||||||
window.addEventListener('mouseup', onFloatDragEnd)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFloatDragMove(ev: MouseEvent) {
|
function onDockCard(id: WorkbenchTab) {
|
||||||
const el = floatPanelRef.value
|
if (dockPeek.value === id) {
|
||||||
const parent = el?.offsetParent as HTMLElement | null
|
dockPeek.value = null
|
||||||
let left = floatOriginLeft + (ev.clientX - floatDragStartX)
|
return
|
||||||
let top = floatOriginTop + (ev.clientY - floatDragStartY)
|
|
||||||
if (el && parent) {
|
|
||||||
const maxL = Math.max(0, parent.clientWidth - el.offsetWidth)
|
|
||||||
const maxT = Math.max(0, parent.clientHeight - el.offsetHeight)
|
|
||||||
left = Math.min(Math.max(0, left), maxL)
|
|
||||||
top = Math.min(Math.max(0, top), maxT)
|
|
||||||
}
|
}
|
||||||
floatPos.value = { left, top }
|
dockPeek.value = id
|
||||||
|
setNav(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onFloatDragEnd() {
|
|
||||||
floatDragging.value = false
|
|
||||||
window.removeEventListener('mousemove', onFloatDragMove)
|
|
||||||
window.removeEventListener('mouseup', onFloatDragEnd)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(selection, (next, prev) => {
|
|
||||||
// 新开选中时若尚未拖过,回到默认右下角(沉浸模式抬高避开底栏)
|
|
||||||
if (next && !prev && floatPos.value.left === undefined) {
|
|
||||||
floatPos.value = { right: 14, bottom: 14 }
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const runningCars = computed(() => cars.value.filter((c) => c.state === 'running').length)
|
const runningCars = computed(() => cars.value.filter((c) => c.state === 'running').length)
|
||||||
const idleCars = computed(() => cars.value.filter((c) => c.state === 'idle').length)
|
const idleCars = computed(() => cars.value.filter((c) => c.state === 'idle').length)
|
||||||
const offlineCars = computed(() => cars.value.filter((c) => c.state === 'offline').length)
|
const offlineCars = computed(() => cars.value.filter((c) => c.state === 'offline').length)
|
||||||
@@ -440,43 +379,13 @@ const abnormalTasks = computed(() =>
|
|||||||
: missions.value.filter((m) => m.status === 'failed' || m.status === 'cancelled').length
|
: missions.value.filter((m) => m.status === 'failed' || m.status === 'cancelled').length
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectedCar = computed(() => {
|
function setNav(mode: WorkbenchTab) {
|
||||||
if (!selection.value || selection.value.kind !== 'vehicle') return null
|
|
||||||
const sel = selection.value
|
|
||||||
return cars.value.find((c) => c.id === sel.id || detailIdForCar(c) === sel.id) ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
const floatTitle = computed(() => {
|
|
||||||
if (!selection.value) return ''
|
|
||||||
if (selection.value.kind === 'vehicle') {
|
|
||||||
const car = selectedCar.value
|
|
||||||
const id = car ? (car.rawId != null ? String(car.rawId) : car.id) : selection.value.id
|
|
||||||
const state = car ? stateLabelOf(car.state) : ''
|
|
||||||
return state ? `${id} ${state}` : String(id)
|
|
||||||
}
|
|
||||||
return selection.value.name || `${selectionKindLabel.value} ${selection.value.id}`
|
|
||||||
})
|
|
||||||
|
|
||||||
function stateLabelOf(s: string): string {
|
|
||||||
switch (s) {
|
|
||||||
case 'running': return '运行中'
|
|
||||||
case 'idle': return '空闲'
|
|
||||||
case 'charging': return '充电中'
|
|
||||||
case 'paused': return '已暂停'
|
|
||||||
case 'fault': return '异常'
|
|
||||||
case 'offline': return '离线'
|
|
||||||
default: return s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setNav(mode: 'vehicle' | 'mission' | 'alarm') {
|
|
||||||
workbenchTab.value = mode
|
workbenchTab.value = mode
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearSelection() {
|
function clearSelection() {
|
||||||
selection.value = null
|
selection.value = null
|
||||||
selectedDeliveryId.value = null
|
selectedDeliveryId.value = null
|
||||||
floatPos.value = { right: 14, bottom: 14 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function tickClock() {
|
function tickClock() {
|
||||||
@@ -485,23 +394,6 @@ function tickClock() {
|
|||||||
clockText.value = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
clockText.value = `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectionKindLabel = computed(() => {
|
|
||||||
switch (selection.value?.kind) {
|
|
||||||
case 'vehicle':
|
|
||||||
return '车辆'
|
|
||||||
case 'site':
|
|
||||||
return '站点'
|
|
||||||
case 'track':
|
|
||||||
return '路径'
|
|
||||||
case 'delivery':
|
|
||||||
return '任务'
|
|
||||||
case 'special':
|
|
||||||
return '装饰'
|
|
||||||
default:
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedVehicleId = computed(() => {
|
const selectedVehicleId = computed(() => {
|
||||||
if (!selection.value || selection.value.kind !== 'vehicle') return null
|
if (!selection.value || selection.value.kind !== 'vehicle') return null
|
||||||
const sel = selection.value
|
const sel = selection.value
|
||||||
@@ -944,15 +836,13 @@ onUnmounted(() => {
|
|||||||
if (pollTimer) clearInterval(pollTimer)
|
if (pollTimer) clearInterval(pollTimer)
|
||||||
if (clockTimer) clearInterval(clockTimer)
|
if (clockTimer) clearInterval(clockTimer)
|
||||||
stream.off(onStreamEvent)
|
stream.off(onStreamEvent)
|
||||||
onFloatDragEnd()
|
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Option A:Outpost 浅色 chrome + 品牌紫;画布/浮层保持深色指挥视口 */
|
/* Option A:Outpost 浅色 chrome + 品牌紫;画布保持深色指挥视口 */
|
||||||
.map-monitor-page {
|
.map-monitor-page {
|
||||||
/* 双栏精简列:车辆/状态/电量/任务/交管;沉浸底栏仍用满宽完整列 */
|
/* 双栏精简列:车辆/状态/电量/任务/交管 */
|
||||||
--mm-rail-w: 420px;
|
--mm-rail-w: 420px;
|
||||||
--mm-sheet-h: min(34vh, 340px);
|
|
||||||
--mm-radius: 14px;
|
--mm-radius: 14px;
|
||||||
--mm-ease: cubic-bezier(0.16, 1, 0.3, 1);
|
--mm-ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
--mm-accent: var(--mg-primary, #7543e8);
|
--mm-accent: var(--mg-primary, #7543e8);
|
||||||
@@ -1104,30 +994,6 @@ onUnmounted(() => {
|
|||||||
border-color: rgba(var(--mg-status-success-rgb, 21, 128, 61), 0.28);
|
border-color: rgba(var(--mg-status-success-rgb, 21, 128, 61), 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mm-layout-toggle {
|
|
||||||
display: inline-flex;
|
|
||||||
padding: 2px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--mm-surface-2);
|
|
||||||
border: 1px solid var(--mm-border);
|
|
||||||
}
|
|
||||||
.mm-toggle-btn {
|
|
||||||
appearance: none;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--mm-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 650;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.mm-toggle-btn.is-active {
|
|
||||||
background: var(--mm-accent);
|
|
||||||
color: #fff;
|
|
||||||
box-shadow: 0 2px 8px rgba(var(--mg-primary-rgb, 117, 67, 232), 0.28);
|
|
||||||
}
|
|
||||||
.mm-icon-btn {
|
.mm-icon-btn {
|
||||||
height: 30px;
|
height: 30px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
@@ -1188,6 +1054,19 @@ onUnmounted(() => {
|
|||||||
box-shadow:
|
box-shadow:
|
||||||
0 1px 2px rgba(40, 33, 58, 0.04),
|
0 1px 2px rgba(40, 33, 58, 0.04),
|
||||||
0 10px 26px rgba(54, 35, 78, 0.08);
|
0 10px 26px rgba(54, 35, 78, 0.08);
|
||||||
|
transition:
|
||||||
|
flex-basis 0.2s var(--mm-ease),
|
||||||
|
width 0.2s var(--mm-ease),
|
||||||
|
min-width 0.2s var(--mm-ease);
|
||||||
|
}
|
||||||
|
.layout-dual .mm-panel.is-docked {
|
||||||
|
--mm-dock-w: 88px;
|
||||||
|
flex-basis: var(--mm-dock-w);
|
||||||
|
width: var(--mm-dock-w);
|
||||||
|
min-width: var(--mm-dock-w);
|
||||||
|
}
|
||||||
|
.layout-dual .mm-panel.is-docked.is-peeking {
|
||||||
|
--mm-dock-w: 320px;
|
||||||
}
|
}
|
||||||
.layout-dual .mm-panel-list {
|
.layout-dual .mm-panel-list {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
@@ -1206,72 +1085,91 @@ onUnmounted(() => {
|
|||||||
border-top: 1px solid var(--mm-border);
|
border-top: 1px solid var(--mm-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* —— C 沉浸:指挥条在上、列表在下,中间画布(不遮挡 iframe 内外工具条) —— */
|
.mm-dock {
|
||||||
.layout-immersive .mm-workspace {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
.layout-immersive .mm-canvas {
|
|
||||||
position: relative;
|
|
||||||
flex: 1 1 auto;
|
|
||||||
min-height: 0;
|
|
||||||
border-radius: var(--mm-radius);
|
|
||||||
}
|
|
||||||
.layout-immersive .mm-panel {
|
|
||||||
position: relative;
|
|
||||||
flex: 0 0 var(--mm-sheet-h);
|
|
||||||
height: var(--mm-sheet-h);
|
|
||||||
max-height: var(--mm-sheet-h);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: var(--mm-radius);
|
|
||||||
border: 1px solid var(--mm-border);
|
|
||||||
background: var(--mm-surface);
|
|
||||||
box-shadow:
|
|
||||||
0 1px 2px rgba(40, 33, 58, 0.04),
|
|
||||||
0 10px 26px rgba(54, 35, 78, 0.08);
|
|
||||||
transition: flex-basis 0.22s var(--mm-ease), height 0.22s var(--mm-ease), max-height 0.22s var(--mm-ease);
|
|
||||||
}
|
|
||||||
.layout-immersive.sheet-collapsed .mm-panel {
|
|
||||||
flex-basis: 44px;
|
|
||||||
height: 44px;
|
|
||||||
max-height: 44px;
|
|
||||||
}
|
|
||||||
.layout-immersive .mm-panel-list {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mm-sheet-handle {
|
|
||||||
flex: none;
|
flex: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px 12px;
|
padding: 8px;
|
||||||
border-bottom: 1px solid var(--mm-border);
|
border-bottom: 1px solid var(--mm-border);
|
||||||
}
|
}
|
||||||
.mm-sheet-toggle {
|
.mm-dock-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.mm-dock-card {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
min-height: 64px;
|
||||||
|
padding: 8px 4px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--mm-border);
|
||||||
|
background: var(--mm-surface-2);
|
||||||
|
color: var(--mm-ink);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.mm-dock-card:hover {
|
||||||
|
background: var(--mm-accent-soft);
|
||||||
|
border-color: rgba(var(--mg-primary-rgb, 117, 67, 232), 0.35);
|
||||||
|
}
|
||||||
|
.mm-dock-card.is-active {
|
||||||
|
background: var(--mm-accent);
|
||||||
|
border-color: var(--mm-accent);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 2px 8px rgba(var(--mg-primary-rgb, 117, 67, 232), 0.28);
|
||||||
|
}
|
||||||
|
.mm-dock-card.has-alert:not(.is-active) {
|
||||||
|
border-color: rgba(var(--mg-status-danger-rgb, 185, 28, 28), 0.35);
|
||||||
|
background: rgba(var(--mg-status-danger-rgb, 185, 28, 28), 0.08);
|
||||||
|
}
|
||||||
|
.mm-dock-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 650;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.mm-dock-count {
|
||||||
|
font-family: var(--mg-font-mono);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
.mm-dock-card.has-alert:not(.is-active) .mm-dock-count {
|
||||||
|
color: var(--mg-status-danger, #b91c1c);
|
||||||
|
}
|
||||||
|
.layout-dual .mm-panel.is-docked.is-peeking .mm-dock-card {
|
||||||
|
min-height: 44px;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
.layout-dual .mm-panel.is-docked.is-peeking .mm-dock-count {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mm-rail-fold {
|
||||||
|
appearance: none;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-radius: 8px;
|
||||||
border: 1px solid rgba(var(--mg-primary-rgb, 117, 67, 232), 0.28);
|
border: 1px solid rgba(var(--mg-primary-rgb, 117, 67, 232), 0.28);
|
||||||
background: var(--mm-accent-soft);
|
background: var(--mm-accent-soft);
|
||||||
color: var(--mm-accent);
|
color: var(--mm-accent);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
height: 28px;
|
|
||||||
padding: 0 12px;
|
|
||||||
border-radius: 8px;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
.mm-sheet-hint {
|
.mm-rail-fold:hover {
|
||||||
font-size: 12px;
|
background: rgba(var(--mg-primary-rgb, 117, 67, 232), 0.2);
|
||||||
color: var(--mm-muted);
|
}
|
||||||
|
.mm-dock .mm-rail-fold {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mm-rail-head {
|
.mm-rail-head {
|
||||||
@@ -1283,6 +1181,12 @@ onUnmounted(() => {
|
|||||||
padding: 10px 12px 8px;
|
padding: 10px 12px 8px;
|
||||||
border-bottom: 1px solid var(--mm-border);
|
border-bottom: 1px solid var(--mm-border);
|
||||||
}
|
}
|
||||||
|
.mm-rail-head-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
.mm-segment {
|
.mm-segment {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
padding: 3px;
|
padding: 3px;
|
||||||
@@ -1377,122 +1281,6 @@ onUnmounted(() => {
|
|||||||
color: var(--mg-text-muted);
|
color: var(--mg-text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 浮层叠在深色画布上:保持深玻璃,accent 跟主题紫 */
|
|
||||||
.mm-float-detail {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 6;
|
|
||||||
width: min(400px, calc(100% - 28px));
|
|
||||||
max-height: min(42%, 320px);
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
overflow: hidden;
|
|
||||||
border-radius: 12px;
|
|
||||||
--mg-text-light: #f4eeff;
|
|
||||||
--mg-text-muted: rgba(244, 238, 255, 0.72);
|
|
||||||
background: rgba(24, 18, 40, 0.94);
|
|
||||||
border: 1px solid rgba(155, 124, 255, 0.22);
|
|
||||||
box-shadow: 0 14px 36px rgba(0, 0, 0, 0.45);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
-webkit-backdrop-filter: blur(10px);
|
|
||||||
color: #f4eeff;
|
|
||||||
}
|
|
||||||
.mm-float-detail.is-dragging {
|
|
||||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.55);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
.mm-float-head {
|
|
||||||
flex: none;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 8px;
|
|
||||||
padding: 10px 12px 8px;
|
|
||||||
border-bottom: 1px solid rgba(155, 124, 255, 0.16);
|
|
||||||
cursor: grab;
|
|
||||||
}
|
|
||||||
.mm-float-detail.is-dragging .mm-float-head { cursor: grabbing; }
|
|
||||||
.mm-float-title {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.mm-float-grip {
|
|
||||||
width: 10px;
|
|
||||||
height: 14px;
|
|
||||||
flex: none;
|
|
||||||
background:
|
|
||||||
radial-gradient(circle, rgba(244, 238, 255, 0.55) 1.2px, transparent 1.3px) 0 0 / 5px 5px,
|
|
||||||
radial-gradient(circle, rgba(244, 238, 255, 0.55) 1.2px, transparent 1.3px) 5px 2.5px / 5px 5px;
|
|
||||||
opacity: 0.55;
|
|
||||||
}
|
|
||||||
.mm-float-id {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: 650;
|
|
||||||
color: #f4eeff;
|
|
||||||
white-space: nowrap;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
.mm-float-kind {
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
padding: 2px 7px;
|
|
||||||
border-radius: 5px;
|
|
||||||
color: #ddd4ff;
|
|
||||||
background: rgba(117, 67, 232, 0.35);
|
|
||||||
}
|
|
||||||
.mm-float-close {
|
|
||||||
appearance: none;
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
color: rgba(244, 238, 255, 0.65);
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-size: 18px;
|
|
||||||
line-height: 1;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.mm-float-close:hover {
|
|
||||||
background: rgba(155, 124, 255, 0.18);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
.mm-float-body {
|
|
||||||
flex: 1;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: auto;
|
|
||||||
padding: 4px 8px 10px;
|
|
||||||
}
|
|
||||||
.mm-float-detail :deep(.monitor-selection),
|
|
||||||
.mm-float-detail :deep(.vehicle-monitor),
|
|
||||||
.mm-float-detail :deep(.mission-list-panel) {
|
|
||||||
--mg-text-light: #f4eeff;
|
|
||||||
--mg-text-muted: rgba(244, 238, 255, 0.72);
|
|
||||||
--mg-text-dim: rgba(244, 238, 255, 0.55);
|
|
||||||
color: #f4eeff;
|
|
||||||
}
|
|
||||||
.mm-float-detail :deep(.table-head),
|
|
||||||
.mm-float-detail :deep(.col-pos),
|
|
||||||
.mm-float-detail :deep(.muted),
|
|
||||||
.mm-float-detail :deep(.muted-line),
|
|
||||||
.mm-float-detail :deep(.kv-row .k) {
|
|
||||||
color: rgba(244, 238, 255, 0.7) !important;
|
|
||||||
}
|
|
||||||
.mm-float-detail :deep(.table-row),
|
|
||||||
.mm-float-detail :deep(.col-id),
|
|
||||||
.mm-float-detail :deep(.col-state),
|
|
||||||
.mm-float-detail :deep(.kv-row .v) {
|
|
||||||
color: #f4eeff !important;
|
|
||||||
}
|
|
||||||
.mm-float-detail :deep(.el-input__wrapper) {
|
|
||||||
background: rgba(12, 8, 24, 0.65) !important;
|
|
||||||
box-shadow: 0 0 0 1px rgba(155, 124, 255, 0.22) inset !important;
|
|
||||||
}
|
|
||||||
.mm-float-detail :deep(.el-input__inner) {
|
|
||||||
color: #f4eeff !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mm-alarm-panel {
|
.mm-alarm-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -1571,10 +1359,21 @@ onUnmounted(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 36vh;
|
min-height: 36vh;
|
||||||
}
|
}
|
||||||
|
.layout-dual .mm-panel.is-docked,
|
||||||
|
.layout-dual .mm-panel.is-docked.is-peeking {
|
||||||
|
--mm-dock-w: 100%;
|
||||||
|
min-height: auto;
|
||||||
|
}
|
||||||
|
.layout-dual .mm-panel.is-docked:not(.is-peeking) .mm-dock-cards {
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
.layout-dual .mm-panel.is-docked:not(.is-peeking) .mm-dock-card {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.mm-segment-btn,
|
.mm-segment-btn,
|
||||||
.layout-immersive .mm-panel { transition: none; }
|
.layout-dual .mm-panel { transition: none; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user