地图监控改为固定双栏并可收起侧栏,编辑对齐改走内核 CAD,并接通 428 确认票。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -42,3 +42,4 @@ Desktop.ini
|
||||
.codex-temp/
|
||||
frontends/apps/simple-platform-vue/imgui.ini
|
||||
/.cursor/plans
|
||||
/tmp-*.json
|
||||
|
||||
@@ -385,6 +385,13 @@ public class OpsController : ControllerBase
|
||||
try
|
||||
{
|
||||
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);
|
||||
return success
|
||||
? 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}";
|
||||
using var client = _httpFactory.CreateClient();
|
||||
@@ -409,6 +441,14 @@ public class OpsController : ControllerBase
|
||||
msg.Headers.TryAddWithoutValidation("X-Platform-Confirmed", "1");
|
||||
if (!string.IsNullOrWhiteSpace(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);
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
return (resp.IsSuccessStatusCode, (int)resp.StatusCode, body);
|
||||
|
||||
@@ -260,10 +260,16 @@ export function formatReflectionExecuteMessage(
|
||||
}
|
||||
|
||||
export interface ReflectionExecuteOptions {
|
||||
/** 已在平台侧完成二次确认时带上,对应后端 X-Platform-Confirmed: 1 */
|
||||
/** 已在平台侧完成二次确认时带上;新内核仍需再带 428 下发的 confirmToken。 */
|
||||
platformConfirmed?: boolean
|
||||
}
|
||||
|
||||
interface PlatformConfirmChallenge {
|
||||
message: string
|
||||
token: string
|
||||
header: string
|
||||
}
|
||||
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
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)
|
||||
@@ -275,9 +281,69 @@ async function post<T>(
|
||||
params?: Record<string, string | number | boolean>,
|
||||
headers?: Record<string, string>
|
||||
): Promise<T> {
|
||||
try {
|
||||
const { data } = await http.post<ReflectionEnvelope<T>>(`${BASE}${path}`, null, { params, headers })
|
||||
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> {
|
||||
@@ -297,22 +363,24 @@ async function executeWithPlatformConfirm<T>(
|
||||
params?: Record<string, string | number | boolean>,
|
||||
opts?: ReflectionExecuteOptions
|
||||
): 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 {
|
||||
return await post<T>(path, params, headers)
|
||||
return await post<T>(path, params, Object.keys(headers).length ? headers : undefined)
|
||||
} catch (e) {
|
||||
if (e instanceof ReflectionApiError && e.code === 428 && !opts?.platformConfirmed) {
|
||||
const confirmMessage =
|
||||
(e.data as { confirmMessage?: string | null } | null)?.confirmMessage?.trim()
|
||||
|| '此操作需要确认'
|
||||
await ElMessageBox.confirm(confirmMessage, '确认', {
|
||||
const challenge = parseConfirmChallenge(e)
|
||||
if (!challenge) throw e
|
||||
if (!opts?.platformConfirmed) {
|
||||
await ElMessageBox.confirm(challenge.message, '确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定',
|
||||
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
|
||||
}
|
||||
|
||||
const CAD_ALIGN_MODES = new Set<AlignMode>([
|
||||
'left', 'right', 'top', 'bottom', 'centerH', 'centerV', 'center', 'distributeH', 'distributeV'
|
||||
])
|
||||
|
||||
async function applyAlignment(mode: AlignMode) {
|
||||
// 选中对象必须是有 x,y 坐标的(site / special?
|
||||
const targets: AlignTarget[] = []
|
||||
for (const it of selection.items.value) {
|
||||
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 个对象')
|
||||
const refs = selection.items.value.filter((it) => it.kind !== 'track' && it.kind !== 'car')
|
||||
if (refs.length < 2) {
|
||||
ElMessage.warning('对齐需要选中至少 2 个站点或装饰(文本/模型)')
|
||||
return
|
||||
}
|
||||
const ops = buildAlignOps(targets, mode)
|
||||
if (ops.length === 0) { ElMessage.info('对齐无变化'); return }
|
||||
|
||||
try {
|
||||
if (CAD_ALIGN_MODES.has(mode)) {
|
||||
let previous: Array<{ kind: string; id: number; x: number; y: number }> = []
|
||||
let alignedCount = 0
|
||||
await history.run({
|
||||
label: `对齐 (${mode})`,
|
||||
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 () => {
|
||||
// 反向恢复每个对象的原坐标
|
||||
const revertOps = targets.map((t) => ({ action: 'patch' as const, kind: t.kind, id: t.id, data: { x: t.x, y: t.y } }))
|
||||
await mapEditApi.batch(revertOps)
|
||||
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) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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="监控指挥条">
|
||||
<div class="mm-kpi-pills" aria-label="关键指标">
|
||||
<div class="mm-pill">
|
||||
@@ -51,22 +51,6 @@
|
||||
<span class="mm-live" :class="{ 'is-live': streamConnected }">
|
||||
{{ streamConnected ? '实时' : '轮询' }}
|
||||
</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">
|
||||
{{ refreshing ? '刷新中' : '刷新' }}
|
||||
</button>
|
||||
@@ -89,45 +73,40 @@
|
||||
@ready="onWorkspaceReady"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<aside class="mm-panel" aria-label="工作列表面板">
|
||||
<div v-if="layoutMode === 'immersive'" class="mm-sheet-handle">
|
||||
<button type="button" class="mm-sheet-toggle" @click="toggleSheet">
|
||||
{{ sheetCollapsed ? '展开列表' : '收起列表' }}
|
||||
<aside
|
||||
class="mm-panel"
|
||||
:class="{ 'is-docked': isDualDocked, 'is-peeking': isDualDocked && !!dockPeek }"
|
||||
aria-label="工作列表面板"
|
||||
>
|
||||
<div v-if="railCollapsed" class="mm-dock" aria-label="压缩面板">
|
||||
<button type="button" class="mm-rail-fold" title="展开完整面板" @click="toggleRail">
|
||||
展开
|
||||
</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 v-show="!sheetCollapsed || layoutMode === 'dual'" class="mm-panel-list">
|
||||
<div class="mm-rail-head">
|
||||
<div v-show="listVisible" class="mm-panel-list">
|
||||
<div v-if="!isDualDocked" class="mm-rail-head">
|
||||
<div class="mm-segment" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
@@ -157,6 +136,7 @@
|
||||
<span v-if="activeAlarmCount" class="mm-seg-badge">{{ activeAlarmCount }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mm-rail-head-actions">
|
||||
<span class="mm-rail-count">
|
||||
{{
|
||||
workbenchTab === 'vehicle'
|
||||
@@ -166,13 +146,20 @@
|
||||
: `${activeAlarmCount}`
|
||||
}}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="mm-rail-fold"
|
||||
title="收起为车辆 / 任务 / 告警压缩框"
|
||||
@click="toggleRail"
|
||||
>收起</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mm-rail-list">
|
||||
<VehicleMonitorPanel
|
||||
v-show="workbenchTab === 'vehicle'"
|
||||
table
|
||||
:lite="layoutMode === 'dual'"
|
||||
lite
|
||||
:cars="cars"
|
||||
:missions="missions"
|
||||
:selected-id="selectedVehicleId"
|
||||
@@ -209,7 +196,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="layoutMode === 'dual'" class="mm-panel-detail">
|
||||
<div v-if="!railCollapsed" class="mm-panel-detail">
|
||||
<header class="mm-detail-head">
|
||||
<span>选中信息</span>
|
||||
<button
|
||||
@@ -331,98 +318,50 @@ const activeAlarmCount = computed(() => activeAlarms.value.length)
|
||||
const clockText = ref('')
|
||||
let clockTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
/** 选中详情浮动窗:可拖拽,默认右下角 */
|
||||
const floatPanelRef = ref<HTMLElement | null>(null)
|
||||
const floatDragging = ref(false)
|
||||
const floatPos = ref<{ left?: number; top?: number; right?: number; bottom?: number }>({
|
||||
right: 14,
|
||||
bottom: 14
|
||||
type WorkbenchTab = 'vehicle' | 'mission' | 'alarm'
|
||||
const RAIL_KEY = 'migu.map-monitor.railCollapsed'
|
||||
|
||||
const railCollapsed = ref(readStoredRailCollapsed())
|
||||
const dockPeek = ref<WorkbenchTab | null>(null)
|
||||
|
||||
const isDualDocked = computed(() => railCollapsed.value)
|
||||
const listVisible = computed(() => {
|
||||
if (railCollapsed.value) return dockPeek.value != null
|
||||
return true
|
||||
})
|
||||
const floatPanelStyle = computed(() => ({
|
||||
left: floatPos.value.left !== undefined ? `${floatPos.value.left}px` : undefined,
|
||||
top: floatPos.value.top !== undefined ? `${floatPos.value.top}px` : undefined,
|
||||
right: floatPos.value.right !== undefined ? `${floatPos.value.right}px` : undefined,
|
||||
bottom: floatPos.value.bottom !== undefined ? `${floatPos.value.bottom}px` : undefined
|
||||
}))
|
||||
const dockCards = computed(() => [
|
||||
{ id: 'vehicle' as const, label: '车辆', count: cars.value.length, alert: false },
|
||||
{ id: 'mission' as const, label: '任务', count: deliveries.value.length, alert: false },
|
||||
{ id: 'alarm' as const, label: '告警', count: activeAlarmCount.value, alert: activeAlarmCount.value > 0 }
|
||||
])
|
||||
|
||||
type MonitorLayout = 'dual' | 'immersive'
|
||||
const LAYOUT_KEY = 'migu.map-monitor.layout'
|
||||
|
||||
function readStoredLayout(): MonitorLayout {
|
||||
function readStoredRailCollapsed(): boolean {
|
||||
try {
|
||||
const v = localStorage.getItem(LAYOUT_KEY)
|
||||
if (v === 'dual' || v === 'immersive') return v
|
||||
} catch { /* ignore */ }
|
||||
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
|
||||
return localStorage.getItem(RAIL_KEY) === '1'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSheet() {
|
||||
sheetCollapsed.value = !sheetCollapsed.value
|
||||
function persistRailCollapsed() {
|
||||
try { localStorage.setItem(RAIL_KEY, railCollapsed.value ? '1' : '0') } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
let floatDragStartX = 0
|
||||
let floatDragStartY = 0
|
||||
let floatOriginLeft = 0
|
||||
let floatOriginTop = 0
|
||||
|
||||
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 toggleRail() {
|
||||
railCollapsed.value = !railCollapsed.value
|
||||
if (railCollapsed.value) dockPeek.value = null
|
||||
persistRailCollapsed()
|
||||
}
|
||||
|
||||
function onFloatDragMove(ev: MouseEvent) {
|
||||
const el = floatPanelRef.value
|
||||
const parent = el?.offsetParent as HTMLElement | null
|
||||
let left = floatOriginLeft + (ev.clientX - floatDragStartX)
|
||||
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)
|
||||
function onDockCard(id: WorkbenchTab) {
|
||||
if (dockPeek.value === id) {
|
||||
dockPeek.value = null
|
||||
return
|
||||
}
|
||||
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 idleCars = computed(() => cars.value.filter((c) => c.state === 'idle').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
|
||||
)
|
||||
|
||||
const selectedCar = computed(() => {
|
||||
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') {
|
||||
function setNav(mode: WorkbenchTab) {
|
||||
workbenchTab.value = mode
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selection.value = null
|
||||
selectedDeliveryId.value = null
|
||||
floatPos.value = { right: 14, bottom: 14 }
|
||||
}
|
||||
|
||||
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())}`
|
||||
}
|
||||
|
||||
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(() => {
|
||||
if (!selection.value || selection.value.kind !== 'vehicle') return null
|
||||
const sel = selection.value
|
||||
@@ -944,15 +836,13 @@ onUnmounted(() => {
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
if (clockTimer) clearInterval(clockTimer)
|
||||
stream.off(onStreamEvent)
|
||||
onFloatDragEnd()
|
||||
})
|
||||
</script>
|
||||
<style scoped>
|
||||
/* Option A:Outpost 浅色 chrome + 品牌紫;画布/浮层保持深色指挥视口 */
|
||||
/* Option A:Outpost 浅色 chrome + 品牌紫;画布保持深色指挥视口 */
|
||||
.map-monitor-page {
|
||||
/* 双栏精简列:车辆/状态/电量/任务/交管;沉浸底栏仍用满宽完整列 */
|
||||
/* 双栏精简列:车辆/状态/电量/任务/交管 */
|
||||
--mm-rail-w: 420px;
|
||||
--mm-sheet-h: min(34vh, 340px);
|
||||
--mm-radius: 14px;
|
||||
--mm-ease: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
--mm-accent: var(--mg-primary, #7543e8);
|
||||
@@ -1104,30 +994,6 @@ onUnmounted(() => {
|
||||
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 {
|
||||
height: 30px;
|
||||
padding: 0 12px;
|
||||
@@ -1188,6 +1054,19 @@ onUnmounted(() => {
|
||||
box-shadow:
|
||||
0 1px 2px rgba(40, 33, 58, 0.04),
|
||||
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 {
|
||||
flex: 1 1 0;
|
||||
@@ -1206,72 +1085,91 @@ onUnmounted(() => {
|
||||
border-top: 1px solid var(--mm-border);
|
||||
}
|
||||
|
||||
/* —— C 沉浸:指挥条在上、列表在下,中间画布(不遮挡 iframe 内外工具条) —— */
|
||||
.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 {
|
||||
.mm-dock {
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 6px 12px;
|
||||
padding: 8px;
|
||||
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;
|
||||
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);
|
||||
background: var(--mm-accent-soft);
|
||||
color: var(--mm-accent);
|
||||
font-size: 12px;
|
||||
font-weight: 650;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mm-sheet-hint {
|
||||
font-size: 12px;
|
||||
color: var(--mm-muted);
|
||||
.mm-rail-fold:hover {
|
||||
background: rgba(var(--mg-primary-rgb, 117, 67, 232), 0.2);
|
||||
}
|
||||
.mm-dock .mm-rail-fold {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mm-rail-head {
|
||||
@@ -1283,6 +1181,12 @@ onUnmounted(() => {
|
||||
padding: 10px 12px 8px;
|
||||
border-bottom: 1px solid var(--mm-border);
|
||||
}
|
||||
.mm-rail-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: none;
|
||||
}
|
||||
.mm-segment {
|
||||
display: inline-flex;
|
||||
padding: 3px;
|
||||
@@ -1377,122 +1281,6 @@ onUnmounted(() => {
|
||||
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 {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -1571,10 +1359,21 @@ onUnmounted(() => {
|
||||
width: 100%;
|
||||
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) {
|
||||
.mm-segment-btn,
|
||||
.layout-immersive .mm-panel { transition: none; }
|
||||
.layout-dual .mm-panel { transition: none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user