fix: improve map editor and monitor operations

This commit is contained in:
zhaowei.huang
2026-07-26 14:14:19 +08:00
parent beb6563060
commit 7af29fe07f
23 changed files with 2786 additions and 958 deletions
+2 -2
View File
@@ -13,7 +13,7 @@ MiGu.Server/data/*.db
MiGu.Server/data/*.db-shm MiGu.Server/data/*.db-shm
MiGu.Server/data/*.db-wal MiGu.Server/data/*.db-wal
MiGu.Server/data/.internal-token MiGu.Server/data/.internal-token
# OTA 运行时产物:拉取/上传包、任务、历史、settings(子目录*.json 上面那行不递归) # OTA 运行时产物:拉取/上传包、任务、历史、settings(子目录 *.json 上面那行不递归)
MiGu.Server/data/ota/ MiGu.Server/data/ota/
# 前端构建产物 # 前端构建产物
@@ -32,7 +32,7 @@ frontends/apps/simple-platform-vue/auto-imports.d.ts
*.swp *.swp
Thumbs.db Thumbs.db
Desktop.ini Desktop.ini
/.cursor/rules /.cursor/
/MiGu.Server/wwwroot /MiGu.Server/wwwroot
/MiGu.Server/data/platform.db /MiGu.Server/data/platform.db
@@ -437,12 +437,12 @@ public static class PlatformPersistence
private static async Task EnsureSqliteColumnAsync(PlatformDbContext db, string table, string column, string definition) private static async Task EnsureSqliteColumnAsync(PlatformDbContext db, string table, string column, string definition)
{ {
// 仅允许内部迁移调用方传入的标识符;拒绝注入用分隔符/空白 // 仅允许内部迁移调用方传入的标识符;definition 可含 GUID 默认值连字符
static bool IsSafeIdent(string s) => static bool IsSafeIdent(string s) =>
!string.IsNullOrEmpty(s) && s.All(ch => char.IsAsciiLetterOrDigit(ch) || ch == '_'); !string.IsNullOrEmpty(s) && s.All(ch => char.IsAsciiLetterOrDigit(ch) || ch == '_');
if (!IsSafeIdent(table) || !IsSafeIdent(column) if (!IsSafeIdent(table) || !IsSafeIdent(column)
|| definition.Contains(';') || definition.Contains("--") || definition.Contains(';') || definition.Contains("--")
|| !System.Text.RegularExpressions.Regex.IsMatch(definition, @"^[A-Za-z0-9_()'.,\s]+$")) || !System.Text.RegularExpressions.Regex.IsMatch(definition, @"^[A-Za-z0-9_()'.,\s\-]+$"))
throw new ArgumentException("unsafe sqlite migration identifier"); throw new ArgumentException("unsafe sqlite migration identifier");
var conn = db.Database.GetDbConnection(); var conn = db.Database.GetDbConnection();
@@ -1,7 +1,15 @@
import { reflectionApi } from './reflection' import { ReflectionApiError, reflectionApi } from './reflection'
export type VehicleMaintenanceMode = 'online' | 'offline' | 'repair' | 'blown' export type VehicleMaintenanceMode = 'online' | 'offline' | 'repair' | 'blown'
/** 地图监控车辆列表快捷动作 */
export type VehicleQuickAction =
| 'online'
| 'offline'
| 'endTask'
| 'disable'
| 'enable'
const METHOD_MAP: Record<VehicleMaintenanceMode, string> = { const METHOD_MAP: Record<VehicleMaintenanceMode, string> = {
online: 'OnlineCar', online: 'OnlineCar',
offline: 'OfflineCar', offline: 'OfflineCar',
@@ -9,6 +17,29 @@ const METHOD_MAP: Record<VehicleMaintenanceMode, string> = {
blown: 'Blown' blown: 'Blown'
} }
/** 结束任务:按车型依次尝试(Dummy=ForceStopClumsy=ForceStopUI,通用=UIIntercept */
const END_TASK_METHODS = ['ForceStopUI', 'ForceStop', 'UIIntercept'] as const
const QUICK_METHOD_MAP: Record<Exclude<VehicleQuickAction, 'endTask'>, string> = {
online: 'OnlineCar',
offline: 'OfflineCar',
disable: 'DisableCar',
enable: 'EnableCar'
}
export const VEHICLE_QUICK_ACTIONS: {
value: VehicleQuickAction
label: string
danger?: boolean
confirm: string
}[] = [
{ value: 'online', label: '上线', confirm: '确认将车辆上线?' },
{ value: 'offline', label: '下线', confirm: '确认将车辆下线?下线后不再被任务选中。' },
{ value: 'endTask', label: '结束任务', danger: true, confirm: '确认结束该车当前任务?' },
{ value: 'disable', label: '禁用车辆', danger: true, confirm: '确认禁用该车?禁用后需先启用才能重新上线。' },
{ value: 'enable', label: '启用车辆', confirm: '确认启用该车?启用后仍需上线才能参与调度。' }
]
export async function setVehicleMaintenance( export async function setVehicleMaintenance(
carId: number, carId: number,
mode: VehicleMaintenanceMode mode: VehicleMaintenanceMode
@@ -23,6 +54,44 @@ export async function setVehicleMaintenance(
} }
} }
async function tryExecuteMethods(carId: number, methods: readonly string[]): Promise<boolean> {
let lastErr: unknown
for (const method of methods) {
try {
await reflectionApi.execute('car', carId, method)
return true
} catch (err) {
if (!isUnsupportedMethodError(err)) throw err
lastErr = err
}
}
if (lastErr) throw lastErr
return false
}
function isUnsupportedMethodError(err: unknown): boolean {
if (err instanceof ReflectionApiError && (err.code === 404 || err.code === 405)) return true
const msg = err instanceof Error ? err.message : String(err)
return /method|not\s*found|unsupported|not\s*supported/i.test(msg)
}
/** 执行车辆快捷动作;失败抛错,由调用方提示。 */
export async function executeVehicleQuickAction(
carId: number,
action: VehicleQuickAction
): Promise<void> {
if (!Number.isFinite(carId) || carId <= 0) {
throw new Error('无效车辆 ID')
}
if (action === 'endTask') {
const ok = await tryExecuteMethods(carId, END_TASK_METHODS)
if (!ok) throw new Error('当前车型不支持结束任务')
return
}
const method = QUICK_METHOD_MAP[action]
await reflectionApi.execute('car', carId, method)
}
export function openOnboardWeb(url?: string | null, ip?: string | null): void { export function openOnboardWeb(url?: string | null, ip?: string | null): void {
const target = url ?? (ip ? `http://${ip}:8081` : null) const target = url ?? (ip ? `http://${ip}:8081` : null)
if (!target) return if (!target) return
@@ -189,8 +189,14 @@ async function prepareAndLoad(forceReload = false) {
const embedded = needsDeclare() const embedded = needsDeclare()
if (embedded) { if (embedded) {
// 并行:iframe 立即加载,declare 后台尽力发送(不挡首屏) // embed 必须先 declaremigu 默认是 canvas-only,迟到的 /declareEmbedUi 会被跳过
// canvas-only 与默认一致,可后台尽力发送不挡首屏。
if (props.embedUi) {
await postDeclareBestEffort()
if (generation !== prepareGeneration) return
} else {
void postDeclareBestEffort() void postDeclareBestEffort()
}
iframeSrc.value = buildIframeSrc(forceReload || !!iframeSrc.value) iframeSrc.value = buildIframeSrc(forceReload || !!iframeSrc.value)
} else { } else {
loadingHint.value = '正在等待 SimpleLite webVRender 就绪 ...' loadingHint.value = '正在等待 SimpleLite webVRender 就绪 ...'
File diff suppressed because it is too large Load Diff
@@ -257,6 +257,7 @@ async function send() {
</script> </script>
<style scoped> <style scoped>
/* Shares --ai-* tokens with AiAssistantDrawer (A/E paper · C amethyst) */
.ai-assistant-panel { .ai-assistant-panel {
position: absolute; position: absolute;
top: 0; top: 0;
@@ -265,11 +266,10 @@ async function send() {
z-index: 30; z-index: 30;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
/* 实底(高不透明),解决「太透明看不清」 */ background: var(--ai-panel-bg);
background: linear-gradient(180deg, rgba(28, 12, 56, 0.98) 0%, rgba(16, 6, 34, 0.99) 100%); border-left: 1px solid var(--ai-panel-border);
border-left: 1px solid rgba(190, 140, 240, 0.28); box-shadow: var(--ai-panel-shadow);
box-shadow: -10px 0 30px rgba(8, 2, 16, 0.55); color: var(--ai-bot-fg);
color: rgba(236, 224, 250, 0.95);
transform: translateX(100%); transform: translateX(100%);
opacity: 0; opacity: 0;
visibility: hidden; visibility: hidden;
@@ -281,7 +281,6 @@ async function send() {
visibility: visible; visibility: visible;
} }
/* 左沿拖拽手柄:覆盖在 border-left 上方,hover 高亮提示可拖拽。 */
.aap-resizer { .aap-resizer {
position: absolute; position: absolute;
left: 0; left: 0;
@@ -294,7 +293,7 @@ async function send() {
transition: background 0.15s ease; transition: background 0.15s ease;
touch-action: none; touch-action: none;
} }
.aap-resizer:hover { background: rgba(190, 140, 240, 0.45); } .aap-resizer:hover { background: var(--ai-accent-soft); }
.aap-header { .aap-header {
flex: none; flex: none;
@@ -302,35 +301,46 @@ async function send() {
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
padding: 12px 14px; padding: 14px 16px;
background: linear-gradient(135deg, rgba(120, 70, 220, 0.5) 0%, rgba(255, 90, 200, 0.4) 100%); background: var(--ai-head-bg);
border-bottom: 1px solid rgba(255, 255, 255, 0.12); border-bottom: 1px solid var(--ai-head-border);
} }
.aap-title { display: flex; align-items: center; gap: 10px; min-width: 0; } .aap-title { display: flex; align-items: center; gap: 10px; min-width: 0; }
.aap-glyph { .aap-glyph {
font-size: 20px; width: 32px;
color: #fff; height: 32px;
text-shadow: 0 0 10px rgba(255, 200, 250, 0.7); border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
flex: none; flex: none;
color: var(--ai-avatar-bot-fg);
background: var(--ai-avatar-bot-bg);
border: 1px solid var(--ai-avatar-bot-border);
} }
.aap-title-text { min-width: 0; } .aap-title-text { min-width: 0; }
.aap-title-main { font-size: 15px; font-weight: 700; color: #fff; line-height: 1.2; } .aap-title-main { font-size: 15px; font-weight: 700; color: var(--ai-title); line-height: 1.2; }
.aap-title-sub { font-size: 11px; color: rgba(240, 222, 255, 0.78); margin-top: 2px; } .aap-title-sub { font-size: 11px; color: var(--ai-muted); margin-top: 2px; }
.aap-close { .aap-close {
appearance: none; appearance: none;
border: 0; border: 1px solid transparent;
background: rgba(255, 255, 255, 0.12); background: transparent;
color: #fff; color: var(--ai-muted);
width: 26px; width: 28px;
height: 26px; height: 28px;
border-radius: 6px; border-radius: 8px;
cursor: pointer; cursor: pointer;
font-size: 13px; font-size: 13px;
line-height: 1; line-height: 1;
flex: none; flex: none;
transition: background 0.15s ease; transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.aap-close:hover {
background: var(--ai-close-hover-bg);
color: var(--ai-close-hover-fg);
border-color: var(--ai-accent-soft-border);
} }
.aap-close:hover { background: rgba(255, 255, 255, 0.24); }
.aap-alert { margin: 10px 12px 0; } .aap-alert { margin: 10px 12px 0; }
@@ -338,30 +348,33 @@ async function send() {
flex: 1 1 0; flex: 1 1 0;
min-height: 0; min-height: 0;
overflow-y: auto; overflow-y: auto;
padding: 12px; padding: 14px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
scrollbar-width: thin;
scrollbar-color: var(--ai-scrollbar) transparent;
} }
.aap-messages::-webkit-scrollbar { width: 6px; }
.aap-messages::-webkit-scrollbar-thumb { background: var(--ai-scrollbar); border-radius: 3px; }
.aap-empty { padding: 8px 2px; display: flex; flex-direction: column; gap: 8px; } .aap-empty { padding: 8px 2px; display: flex; flex-direction: column; gap: 8px; }
.aap-empty-title { font-size: 12px; color: rgba(210, 188, 240, 0.7); } .aap-empty-title { font-size: 12px; color: var(--ai-muted); }
.aap-example { .aap-example {
appearance: none; appearance: none;
text-align: left; text-align: left;
border: 1px dashed rgba(190, 140, 240, 0.4); border: 1px solid var(--ai-example-border);
background: rgba(255, 255, 255, 0.04); background: var(--ai-example-bg);
color: rgba(232, 215, 245, 0.9); color: var(--ai-example-fg);
border-radius: 8px; border-radius: 10px;
padding: 8px 10px; padding: 9px 11px;
font-size: 12.5px; font-size: 12.5px;
cursor: pointer; cursor: pointer;
transition: all 0.15s ease; transition: border-color 0.15s ease, transform 0.15s ease;
} }
.aap-example:hover { .aap-example:hover {
background: rgba(150, 90, 230, 0.22); border-color: var(--ai-accent);
border-color: rgba(190, 140, 240, 0.7); transform: translateY(-1px);
color: #fff;
} }
.aap-msg { display: flex; } .aap-msg { display: flex; }
@@ -369,38 +382,38 @@ async function send() {
.aap-msg--assistant { justify-content: flex-start; } .aap-msg--assistant { justify-content: flex-start; }
.aap-bubble { .aap-bubble {
max-width: 86%; max-width: 86%;
padding: 8px 11px; padding: 9px 12px;
border-radius: 12px; border-radius: 14px;
font-size: 13px; font-size: 13px;
line-height: 1.5; line-height: 1.55;
} }
.aap-msg--user .aap-bubble { .aap-msg--user .aap-bubble {
background: linear-gradient(135deg, rgba(150, 90, 240, 0.95) 0%, rgba(120, 70, 220, 0.95) 100%); background: var(--ai-user-bg);
color: #fff; color: var(--ai-user-fg);
border-bottom-right-radius: 4px; border-bottom-right-radius: 4px;
} }
.aap-msg--assistant .aap-bubble { .aap-msg--assistant .aap-bubble {
background: rgba(255, 255, 255, 0.07); background: var(--ai-bot-bg);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid var(--ai-bot-border);
color: rgba(236, 224, 250, 0.95); color: var(--ai-bot-fg);
border-bottom-left-radius: 4px; border-bottom-left-radius: 4px;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
} }
.aap-bubble-text { white-space: pre-wrap; word-break: break-word; } .aap-bubble-text { white-space: pre-wrap; word-break: break-word; }
.aap-meta { .aap-meta {
margin-top: 6px; margin-top: 6px;
padding-top: 6px; padding-top: 6px;
border-top: 1px dashed rgba(255, 255, 255, 0.14); border-top: 1px dashed var(--ai-bot-border);
font-size: 11.5px; font-size: 11.5px;
color: rgba(210, 188, 240, 0.8); color: var(--ai-muted);
} }
.aap-meta b { color: var(--mg-accent, #c4a4ff); } .aap-meta b { color: var(--ai-accent); }
.aap-meta-ic { color: #6ee7b7; margin-right: 4px; vertical-align: -2px; } .aap-meta-ic { color: #10b981; margin-right: 4px; vertical-align: -2px; }
/* assistant 消息 Markdown 渲染 */ .aap-md { font-size: 13px; line-height: 1.6; word-break: break-word; color: var(--ai-bot-fg); }
.aap-md { font-size: 13px; line-height: 1.6; word-break: break-word; color: rgba(236, 224, 250, 0.95); }
.aap-md :deep(p) { margin: 0 0 7px; } .aap-md :deep(p) { margin: 0 0 7px; }
.aap-md :deep(p:last-child) { margin-bottom: 0; } .aap-md :deep(p:last-child) { margin-bottom: 0; }
.aap-md :deep(.mmd-h) { margin: 8px 0 5px; font-weight: 700; color: #fff; } .aap-md :deep(.mmd-h) { margin: 8px 0 5px; font-weight: 700; color: var(--ai-title); }
.aap-md :deep(h3.mmd-h) { font-size: 14px; } .aap-md :deep(h3.mmd-h) { font-size: 14px; }
.aap-md :deep(h4.mmd-h) { font-size: 13px; } .aap-md :deep(h4.mmd-h) { font-size: 13px; }
.aap-md :deep(.mmd-ul), .aap-md :deep(.mmd-ol) { margin: 4px 0 7px; padding-left: 18px; } .aap-md :deep(.mmd-ul), .aap-md :deep(.mmd-ol) { margin: 4px 0 7px; padding-left: 18px; }
@@ -408,16 +421,17 @@ async function send() {
.aap-md :deep(.mmd-code) { .aap-md :deep(.mmd-code) {
font-family: var(--mg-font-mono, monospace); font-family: var(--mg-font-mono, monospace);
font-size: 12px; font-size: 12px;
background: rgba(0, 0, 0, 0.4); background: var(--ai-md-code-bg);
border: 1px solid rgba(255, 255, 255, 0.1); color: var(--ai-md-code-fg);
border: 1px solid var(--ai-example-border);
border-radius: 5px; border-radius: 5px;
padding: 1px 5px; padding: 1px 5px;
} }
.aap-md :deep(.mmd-pre) { .aap-md :deep(.mmd-pre) {
margin: 6px 0; margin: 6px 0;
padding: 9px 11px; padding: 9px 11px;
background: rgba(0, 0, 0, 0.42); background: var(--ai-md-pre-bg);
border: 1px solid rgba(255, 255, 255, 0.1); border: 1px solid var(--ai-md-pre-border);
border-radius: 8px; border-radius: 8px;
overflow: auto; overflow: auto;
max-height: 280px; max-height: 280px;
@@ -425,22 +439,24 @@ async function send() {
.aap-md :deep(.mmd-pre code) { .aap-md :deep(.mmd-pre code) {
font-family: var(--mg-font-mono, monospace); font-family: var(--mg-font-mono, monospace);
font-size: 12px; font-size: 12px;
color: rgba(220, 230, 245, 0.95); color: var(--ai-bot-fg);
white-space: pre; white-space: pre;
} }
.aap-md :deep(.mmd-link) { color: var(--mg-accent, #c4a4ff); text-decoration: underline; } .aap-md :deep(.mmd-link) { color: var(--ai-md-link); text-decoration: underline; }
.aap-md :deep(.mmd-quote) { .aap-md :deep(.mmd-quote) {
margin: 6px 0; margin: 6px 0;
padding: 3px 10px; padding: 3px 10px;
border-left: 3px solid rgba(190, 140, 240, 0.6); border-left: 3px solid var(--ai-md-blockquote-border);
color: rgba(220, 205, 250, 0.85); color: var(--ai-md-blockquote-fg);
} }
.aap-md :deep(strong) { color: #fff; } .aap-md :deep(strong) { color: var(--ai-title); }
.aap-bubble--loading { .aap-bubble--loading {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
color: rgba(210, 188, 240, 0.85); color: var(--ai-thinking-fg);
background: var(--ai-thinking-bg) !important;
border: 1px solid var(--ai-thinking-border) !important;
} }
.aap-toolbar { .aap-toolbar {
@@ -448,21 +464,51 @@ async function send() {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
padding: 8px 12px 0; padding: 8px 14px 0;
flex-wrap: wrap; flex-wrap: wrap;
border-top: 1px solid var(--ai-composer-border);
background: var(--ai-composer-bg);
} }
.aap-toolbar-label { font-size: 11.5px; color: rgba(210, 188, 240, 0.7); flex: none; } .aap-toolbar-label { font-size: 11.5px; color: var(--ai-muted); flex: none; }
.aap-toolbar :deep(.el-radio-button__inner) { .aap-toolbar :deep(.el-radio-button__inner) {
padding: 5px 9px; padding: 5px 9px;
font-size: 12px; font-size: 12px;
background: var(--ai-input-bg);
color: var(--ai-input-fg);
border-color: var(--ai-input-border);
}
.aap-toolbar :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
background: var(--ai-accent) !important;
border-color: var(--ai-accent) !important;
color: #fff !important;
box-shadow: none !important;
} }
.aap-input { .aap-input {
flex: none; flex: none;
padding: 8px 12px 12px; padding: 8px 14px 14px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
background: var(--ai-composer-bg);
}
.aap-input :deep(.el-textarea__inner) {
background: var(--ai-input-bg) !important;
border: 1px solid var(--ai-input-border) !important;
color: var(--ai-input-fg) !important;
box-shadow: none !important;
border-radius: 10px;
}
.aap-input :deep(.el-textarea__inner::placeholder) {
color: var(--ai-input-placeholder) !important;
}
.aap-input :deep(.el-textarea__inner:focus) {
border-color: var(--ai-accent) !important;
} }
.aap-send { align-self: flex-end; min-width: 84px; } .aap-send { align-self: flex-end; min-width: 84px; }
.aap-send.el-button--primary {
background: var(--ai-send-bg) !important;
border-color: transparent !important;
color: var(--ai-send-fg) !important;
}
</style> </style>
@@ -166,7 +166,8 @@
<button <button
class="prop-add-field-btn" class="prop-add-field-btn"
:disabled="!addableFieldOptions.length" :disabled="fieldCatalogLoading"
type="button"
@click="openAddFieldDialog" @click="openAddFieldDialog"
> >
+ 添加字段 + 添加字段
@@ -180,12 +181,24 @@
width="420px" width="420px"
append-to-body append-to-body
destroy-on-close destroy-on-close
align-center
class="add-field-dialog"
> >
<p v-if="fieldCatalogError" class="add-field-hint is-warn">
字段目录加载失败:{{ fieldCatalogError }}。仍可手动输入字段名。
</p>
<p v-else-if="!addableFieldOptions.length" class="add-field-hint">
目录中暂无可添加项(可能尚未在「字段管理」配置,或已全部加到该对象)。可手动输入字段名。
</p>
<el-select <el-select
v-model="addFieldPickKey" v-model="addFieldPickKey"
filterable filterable
placeholder="选择要添加的字段" allow-create
default-first-option
clearable
placeholder="选择或输入字段名"
class="add-field-select" class="add-field-select"
teleported
> >
<el-option <el-option
v-for="opt in addableFieldOptions" v-for="opt in addableFieldOptions"
@@ -196,7 +209,7 @@
</el-select> </el-select>
<template #footer> <template #footer>
<el-button @click="addFieldDialogOpen = false">取消</el-button> <el-button @click="addFieldDialogOpen = false">取消</el-button>
<el-button type="primary" :disabled="!addFieldPickKey" @click="confirmAddField"> <el-button type="primary" :disabled="!addFieldPickKey.trim()" @click="confirmAddField">
确定 确定
</el-button> </el-button>
</template> </template>
@@ -525,9 +538,10 @@ watch(
) )
watch( watch(
() => props.selectionKind, () => [props.selectionKind, props.primary?.id] as const,
(k) => { ([k, id]) => {
if (k === 'site' || k === 'track') tab.value = 'defaults' // 选中站点 / 路径 / 车辆时默认停在「对象」页;类型默认仅用户主动点开。
if (id != null && (k === 'site' || k === 'track' || k === 'car')) tab.value = 'object'
} }
) )
@@ -607,6 +621,8 @@ const carTypeRef = computed(() => props.carType ?? null)
const { const {
loading: fieldCatalogLoading, loading: fieldCatalogLoading,
records: fieldCatalogRecords, records: fieldCatalogRecords,
lastError: fieldCatalogError,
reload: reloadFieldCatalog,
labelForKey, labelForKey,
defaultValueForKey defaultValueForKey
} = useSimpleFieldCatalog(selectionKindRef, carTypeRef) } = useSimpleFieldCatalog(selectionKindRef, carTypeRef)
@@ -626,18 +642,16 @@ function customFieldDisplayLabel(key: string, chinese: string): string {
return `${key} - ${cn}` return `${key} - ${cn}`
} }
/** 展示已在对象上存在、且在 simple_fields 目录中的扩展字段 */ /** 展示对象上已有的扩展字段;目录有中文名则带上,目录外字段仍可编辑 */
const customFieldRows = computed(() => const customFieldRows = computed(() =>
otherFields.value otherFields.value.map((f) => {
.filter((f) => catalogKeySet.value.has(f.key)) const chinese = catalogKeySet.value.has(f.key) ? labelForKey(f.key) : ''
.map((f) => {
const chinese = labelForKey(f.key)
return { return {
key: f.key, key: f.key,
value: f.value, value: f.value,
dataType: catalogDataTypeByKey.value.get(f.key) ?? '', dataType: catalogDataTypeByKey.value.get(f.key) ?? '',
chinese, chinese,
label: customFieldDisplayLabel(f.key, chinese) label: customFieldDisplayLabel(f.key, chinese || '—')
} }
}) })
) )
@@ -714,14 +728,23 @@ function onCustomFieldCommit(key: string, value: string) {
onFieldChange(key, value) onFieldChange(key, value)
} }
function openAddFieldDialog() { async function openAddFieldDialog() {
await reloadFieldCatalog()
addFieldPickKey.value = addableFieldOptions.value[0]?.key ?? '' addFieldPickKey.value = addableFieldOptions.value[0]?.key ?? ''
addFieldDialogOpen.value = true addFieldDialogOpen.value = true
} }
function confirmAddField() { function confirmAddField() {
const key = addFieldPickKey.value const key = addFieldPickKey.value.trim()
if (!key) return if (!key) return
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
ElMessage.warning('字段名仅允许字母/数字/下划线,且不能以数字开头')
return
}
if (customFieldRows.value.some((r) => r.key === key) || props.fields.some((f) => f.key === key)) {
ElMessage.warning(`字段 ${key} 已存在`)
return
}
const value = defaultValueForKey(key) const value = defaultValueForKey(key)
customFieldDrafts.value = { ...customFieldDrafts.value, [key]: value } customFieldDrafts.value = { ...customFieldDrafts.value, [key]: value }
addFieldDialogOpen.value = false addFieldDialogOpen.value = false
@@ -1276,6 +1299,13 @@ function onDelete() {
0 0 0 3px rgba(var(--mg-primary-rgb), 0.14) !important; 0 0 0 3px rgba(var(--mg-primary-rgb), 0.14) !important;
} }
.add-field-select { width: 100%; } .add-field-select { width: 100%; }
.add-field-hint {
margin: 0 0 10px;
font-size: 12px;
line-height: 1.45;
color: var(--me-text-muted);
}
.add-field-hint.is-warn { color: #b45309; }
/* 运行状态:胶囊行 */ /* 运行状态:胶囊行 */
.prop-status-list { .prop-status-list {
@@ -13,14 +13,14 @@
吸附<b :class="{ on: snapOn, off: !snapOn }">{{ snapOn ? '开' : '关' }}</b> 吸附<b :class="{ on: snapOn, off: !snapOn }">{{ snapOn ? '开' : '关' }}</b>
</span> </span>
<span class="spacer" /> <span class="spacer" />
<span class="seg">历史 {{ undoSize }} / {{ redoSize }}</span> <span class="seg">可撤销 {{ undoSize }}/{{ undoLimit }} · 可重做 {{ redoSize }}</span>
<span v-if="connected" class="seg ok"> 实时通道在线</span> <span v-if="connected" class="seg ok"> 实时通道在线</span>
<span v-else class="seg warn"> 实时通道断开</span> <span v-else class="seg warn"> 实时通道断开</span>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
defineProps<{ withDefaults(defineProps<{
selectedCount: number selectedCount: number
mouseX: number mouseX: number
mouseY: number mouseY: number
@@ -28,8 +28,11 @@ defineProps<{
snapOn: boolean snapOn: boolean
undoSize: number undoSize: number
redoSize: number redoSize: number
undoLimit?: number
connected: boolean connected: boolean
}>() }>(), {
undoLimit: 20
})
function formatXY(v: number) { function formatXY(v: number) {
if (!Number.isFinite(v)) return '--' if (!Number.isFinite(v)) return '--'
@@ -50,6 +50,8 @@ interface ToolGroup { id: string; title: string; tools: ToolDef[] }
const props = defineProps<{ const props = defineProps<{
activeTool: EditToolId activeTool: EditToolId
/** 左侧「仅*」互斥过滤模式;与 activeTool 独立,可与绘制工具同时生效。 */
activeSelectOnly?: 'site' | 'straight' | 'curve' | 'decor' | null
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -62,17 +64,10 @@ const groups: ToolGroup[] = [
id: 'select', id: 'select',
title: '选择', title: '选择',
tools: [ tools: [
{ id: 'select.single', label: '单选', glyph: '', tip: '点击拾取单个对象' }, { id: 'select.byType.site', label: '仅站点', glyph: '·', tip: '画布只能选中站点;再点一次取消' },
{ id: 'select.rect', label: '矩形框选', glyph: '' }, { id: 'select.byType.straight', label: '仅直线', glyph: '─', tip: '仅 UITrack(直线/折线);再点一次取消' },
{ id: 'select.lasso', label: '套索圈选', glyph: '' }, { id: 'select.byType.curve', label: '仅曲线', glyph: '∽', tip: '贝塞尔 / 弧 / NURBS;再点一次取消' },
{ id: 'select.byType.site', label: '仅站点', glyph: '·' }, { id: 'select.byType.decor', label: '仅装饰', glyph: '', tip: '画布只能选中装饰;再点一次取消' }
{ id: 'select.byType.polyline', label: '仅折线', glyph: '', tip: '仅 UITrack(直线/折线)' },
{ id: 'select.byType.bezier', label: '仅贝塞尔', glyph: '∽', tip: '仅 UIBezierTrack' },
{ id: 'select.byType.arc', label: '仅弧形', glyph: '◜', tip: '仅 UICircularArcTrack' },
{ id: 'select.byType.decor', label: '仅装饰', glyph: '★' },
{ id: 'select.byType.currentLayer', label: '当前图层', glyph: '▤', tip: '保留当前图层对象' },
{ id: 'select.invert', label: '反选', glyph: '⇄' },
{ id: 'select.clear', label: '清空选中', glyph: '⊘' }
] ]
}, },
{ {
@@ -80,29 +75,23 @@ const groups: ToolGroup[] = [
title: '绘制', title: '绘制',
tools: [ tools: [
{ id: 'draw.site', label: '添加站点', glyph: '', tip: '点击工具后在画布单击放置' }, { id: 'draw.site', label: '添加站点', glyph: '', tip: '点击工具后在画布单击放置' },
{ id: 'draw.sites.continuous', label: '连续站点', glyph: '⁂', tip: '切换工具或 Esc 退出' }, { id: 'draw.sites.continuous', label: '连续站点', glyph: '⁂', tip: '切换工具或右键退出' },
{ id: 'draw.track.polyline', label: '线路径', glyph: '─', tip: '依序点击两端站点' }, { id: 'draw.track.polyline', label: '线路径', glyph: '─', tip: '依序点击两端站点' },
{ id: 'draw.track.bezier', label: '贝塞尔路径', glyph: '', tip: '点两端站点 → 弹框输入中间控制点个数 → 连续点选每个控制点' }, { id: 'draw.tracks.continuous', label: '连续直线', glyph: '', tip: '连续点两端站点建直线;右键退出' },
{ id: 'draw.track.arc', label: '弧形路径', glyph: '', tip: '点两端站点 → 输入半径(≥ |AB|/2,等于即半圆)' }, { id: 'draw.track.bezier', label: '贝塞尔路径', glyph: '', tip: '点两端站点 → 输入中间控制点个数 → 连续点选' },
{ id: 'draw.decor.line', label: '装饰线段', glyph: '' }, { id: 'draw.track.arc', label: '弧形路径', glyph: '◜', tip: '点两端站点 → 输入半径(≥ |AB|/2)' },
{ id: 'draw.decor.rect', label: '装饰矩形', glyph: '' }, { id: 'draw.decor.text', label: '装饰文本', glyph: 'T', tip: '输入内容后在画布单击落点' },
{ id: 'draw.decor.circle', label: '装饰圆', glyph: '' }, { id: 'draw.image', label: '插入图片', glyph: '🖼', tip: '选择图片后在画布单击落点' }
{ id: 'draw.decor.text', label: '装饰文本', glyph: 'T' },
{ id: 'draw.image', label: '插入图片', glyph: '🖼' },
{ id: 'draw.model', label: '插入模型', glyph: '◎', tip: 'CycleGUI 3D 模型 (obj/glb/gltf)' }
] ]
}, },
{ {
id: 'transform', id: 'transform',
title: '变换', title: '变换',
tools: [ tools: [
{ id: 'transform.move', label: '移动', glyph: '✥' },
{ id: 'transform.rotate', label: '旋转', glyph: '⟳' },
{ id: 'transform.scale', label: '缩放', glyph: '⤡' },
{ id: 'transform.copy', label: '复制', glyph: '📋', tip: 'Ctrl+C' }, { id: 'transform.copy', label: '复制', glyph: '📋', tip: 'Ctrl+C' },
{ id: 'transform.paste', label: '粘贴', glyph: '📥', tip: 'Ctrl+V' }, { id: 'transform.paste', label: '粘贴', glyph: '📥', tip: 'Ctrl+V,相对原位置偏移 200mm' },
{ id: 'transform.delete', label: '删除', glyph: '🗑', tip: 'Delete' }, { id: 'transform.duplicate', label: '复制副本', glyph: '', tip: 'Ctrl+D' },
{ id: 'transform.copyFields', label: '复制字段', glyph: '', tip: '多选时第 1 个为源,其余为目标' } { id: 'transform.delete', label: '删除', glyph: '🗑', tip: 'Delete' }
] ]
}, },
{ {
@@ -111,7 +100,7 @@ const groups: ToolGroup[] = [
tools: [ tools: [
{ id: 'align.left', label: '左对齐', glyph: '⫷' }, { id: 'align.left', label: '左对齐', glyph: '⫷' },
{ id: 'align.right', label: '右对齐', glyph: '⫸' }, { id: 'align.right', label: '右对齐', glyph: '⫸' },
{ id: 'align.top', label: '上对齐', glyph: '' }, { id: 'align.top', label: '上对齐', glyph: '' },
{ id: 'align.bottom', label: '下对齐', glyph: '⫶' }, { id: 'align.bottom', label: '下对齐', glyph: '⫶' },
{ id: 'align.centerH', label: '水平居中', glyph: '⇔' }, { id: 'align.centerH', label: '水平居中', glyph: '⇔' },
{ id: 'align.centerV', label: '垂直居中', glyph: '⇕' }, { id: 'align.centerV', label: '垂直居中', glyph: '⇕' },
@@ -143,7 +132,13 @@ const groups: ToolGroup[] = [
} }
] ]
function isActive(id: EditToolId) { return id === props.activeTool } function isActive(id: EditToolId) {
if (id.startsWith('select.byType.')) {
const t = id.replace('select.byType.', '')
return props.activeSelectOnly === t
}
return id === props.activeTool
}
function onClick(id: EditToolId) { emit('select-tool', id) } function onClick(id: EditToolId) { emit('select-tool', id) }
</script> </script>
@@ -16,7 +16,6 @@
<el-dropdown-item command="file.import.dwg">导入 .dwg/.dxf</el-dropdown-item> <el-dropdown-item command="file.import.dwg">导入 .dwg/.dxf</el-dropdown-item>
<el-dropdown-item command="file.import.geojson">导入 .geojson</el-dropdown-item> <el-dropdown-item command="file.import.geojson">导入 .geojson</el-dropdown-item>
<el-dropdown-item command="file.upload.image" divided>导入图片</el-dropdown-item> <el-dropdown-item command="file.upload.image" divided>导入图片</el-dropdown-item>
<el-dropdown-item command="file.upload.model">导入 CycleGUI 模型</el-dropdown-item>
<el-dropdown-item command="file.export.png" divided>导出 PNG</el-dropdown-item> <el-dropdown-item command="file.export.png" divided>导出 PNG</el-dropdown-item>
<el-dropdown-item command="file.export.svg">导出 SVG</el-dropdown-item> <el-dropdown-item command="file.export.svg">导出 SVG</el-dropdown-item>
<el-dropdown-item command="file.export.json">导出 JSON</el-dropdown-item> <el-dropdown-item command="file.export.json">导出 JSON</el-dropdown-item>
@@ -25,109 +24,67 @@
</el-dropdown> </el-dropdown>
<el-dropdown trigger="click" @command="onCmd"> <el-dropdown trigger="click" @command="onCmd">
<el-button text class="topbar-btn">编辑</el-button> <el-button text class="topbar-btn">
撤销
<span class="topbar-count">{{ undoCount }}/{{ undoLimit }}</span>
</el-button>
<template #dropdown> <template #dropdown>
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item command="edit.cut">剪切 (Ctrl+X)</el-dropdown-item> <el-dropdown-item command="edit.undo" :disabled="!canUndo">
<el-dropdown-item command="edit.copy">复制 (Ctrl+C)</el-dropdown-item> 撤销 (Ctrl+Z) · {{ undoCount }}/{{ undoLimit }}
<el-dropdown-item command="edit.paste">粘贴 (Ctrl+V)</el-dropdown-item> </el-dropdown-item>
<el-dropdown-item command="edit.delete">删除 (Del)</el-dropdown-item> <el-dropdown-item command="edit.redo" :disabled="!canRedo">
<el-dropdown-item command="edit.selectAll" divided>全选</el-dropdown-item> 重做 (Ctrl+Y) · {{ redoCount }}
<el-dropdown-item command="edit.invert">反选</el-dropdown-item> </el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown trigger="click" @command="onCmd">
<el-button text class="topbar-btn">撤销</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="edit.undo" :disabled="!canUndo">撤销 (Ctrl+Z)</el-dropdown-item>
<el-dropdown-item command="edit.redo" :disabled="!canRedo">重做 (Ctrl+Y)</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown trigger="click" @command="onCmd">
<el-button text class="topbar-btn">视图</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="view.fitAll">缩放至全屏</el-dropdown-item>
<el-dropdown-item command="view.fitSelection">缩放至选中</el-dropdown-item>
<el-dropdown-item command="view.toggleGrid" divided>显示网格</el-dropdown-item>
<el-dropdown-item command="view.toggleScale">显示比例尺</el-dropdown-item>
<el-dropdown-item command="view.toggle2D">切换 2D / 3D</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown trigger="click" @command="onCmd">
<el-button text class="topbar-btn">图层</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="layer.new">新建图层</el-dropdown-item>
<el-dropdown-item command="layer.toggleVisibility">切换显示图层</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!--
过滤菜单对齐 SimpleLite 桌面底部工具栏的选择 / 对齐 / 显示内容三组开关
MapEditorView 维护 viewFilter 状态
- selectFilter.{sites,tracks,cars,decor}未勾选的类型不会进入 selection也就不会被高亮 + 不会出现在右侧属性面板等价于 SimpleLite SelectFilter*
- alignSnap.{sites,cars,tracks}影响绘制工具吸附目标
- showViewport.{sceneLabels,scenePrimitives,cars}影响右侧属性面板的类型筛选画布渲染本身仍由 SimpleLite LayerToShow / 后端开关控制等后续后端 API 打通后可双向同步
历史上图层菜单里的站点层可见 / 可选等三项是 stub已删除并搬到这里
-->
<el-dropdown trigger="click" @command="onCmd">
<el-button text class="topbar-btn">过滤</el-button>
<template #dropdown>
<el-dropdown-menu class="topbar-filter-menu">
<el-dropdown-item disabled class="topbar-filter-header">选择可拾取</el-dropdown-item>
<el-dropdown-item command="view.select.sites">{{ mark(viewFilter.selectFilter.sites) }} 站点</el-dropdown-item>
<el-dropdown-item command="view.select.tracks">{{ mark(viewFilter.selectFilter.tracks) }} 路径</el-dropdown-item>
<el-dropdown-item command="view.select.cars">{{ mark(viewFilter.selectFilter.cars) }} AGV</el-dropdown-item>
<el-dropdown-item command="view.select.decor">{{ mark(viewFilter.selectFilter.decor) }} 区域 / 装饰</el-dropdown-item>
<el-dropdown-item divided disabled class="topbar-filter-header">对齐鼠标吸附</el-dropdown-item>
<el-dropdown-item command="view.snap.sites">{{ mark(viewFilter.alignSnap.sites) }} 吸附站点</el-dropdown-item>
<el-dropdown-item command="view.snap.cars">{{ mark(viewFilter.alignSnap.cars) }} 吸附 AGV</el-dropdown-item>
<el-dropdown-item command="view.snap.tracks">{{ mark(viewFilter.alignSnap.tracks) }} 吸附路径</el-dropdown-item>
<el-dropdown-item divided disabled class="topbar-filter-header">显示内容</el-dropdown-item>
<el-dropdown-item command="view.show.sceneLabels">{{ mark(viewFilter.showViewport.sceneLabels) }} 场景图元标记</el-dropdown-item>
<el-dropdown-item command="view.show.scenePrimitives">{{ mark(viewFilter.showViewport.scenePrimitives) }} 场景图元</el-dropdown-item>
<el-dropdown-item command="view.show.cars">{{ mark(viewFilter.showViewport.cars) }} 车辆</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
</el-dropdown> </el-dropdown>
<!-- 编辑 / 视图 / 图层 / 过滤 已从顶栏移除过滤由左侧* MapEditorView 驱动 -->
<div class="spacer" /> <div class="spacer" />
<el-tooltip content="撤销 (Ctrl+Z)" placement="bottom"> <el-tooltip :content="`撤销 (Ctrl+Z) · ${undoCount}/${undoLimit}`" placement="bottom">
<el-button size="small" :disabled="!canUndo" class="topbar-icon-btn" @click="onCmd('edit.undo')"></el-button> <el-button size="small" :disabled="!canUndo" class="topbar-icon-btn" @click="onCmd('edit.undo')">
<span v-if="undoCount > 0" class="topbar-badge">{{ undoCount }}</span>
</el-button>
</el-tooltip> </el-tooltip>
<el-tooltip content="重做 (Ctrl+Y)" placement="bottom"> <el-tooltip :content="`重做 (Ctrl+Y) · ${redoCount}`" placement="bottom">
<el-button size="small" :disabled="!canRedo" class="topbar-icon-btn" @click="onCmd('edit.redo')"></el-button> <el-button size="small" :disabled="!canRedo" class="topbar-icon-btn" @click="onCmd('edit.redo')">
<span v-if="redoCount > 0" class="topbar-badge">{{ redoCount }}</span>
</el-button>
</el-tooltip> </el-tooltip>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
export interface ViewFilterState { export interface ViewFilterState {
selectFilter: { sites: boolean; tracks: boolean; cars: boolean; decor: boolean } selectFilter: {
sites: boolean
tracks: boolean
cars: boolean
decor: boolean
trackMode?: 'all' | 'straight' | 'curve'
}
alignSnap: { sites: boolean; cars: boolean; tracks: boolean } alignSnap: { sites: boolean; cars: boolean; tracks: boolean }
showViewport: { sceneLabels: boolean; scenePrimitives: boolean; cars: boolean } showViewport: { sceneLabels: boolean; scenePrimitives: boolean; cars: boolean }
} }
defineProps<{ withDefaults(defineProps<{
canUndo: boolean canUndo: boolean
canRedo: boolean canRedo: boolean
saving?: boolean saving?: boolean
// 由 MapEditorView 注入:「过滤」菜单的三组开关状态。父组件保证非空。 undoCount?: number
viewFilter: ViewFilterState redoCount?: number
}>() undoLimit?: number
/** 保留类型兼容;顶栏「过滤」菜单已移除。 */
viewFilter?: ViewFilterState
}>(), {
undoCount: 0,
redoCount: 0,
undoLimit: 20
})
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'cmd', cmd: string): void (e: 'cmd', cmd: string): void
@@ -136,10 +93,6 @@ const emit = defineEmits<{
function onCmd(cmd: string) { function onCmd(cmd: string) {
emit('cmd', cmd) emit('cmd', cmd)
} }
function mark(on: boolean): string {
return on ? '☑' : '☐'
}
</script> </script>
<style scoped> <style scoped>
@@ -154,7 +107,6 @@ function mark(on: boolean): string {
box-shadow: 0 2px 8px rgba(var(--mg-shadow-rgb, 22, 58, 74), 0.08); box-shadow: 0 2px 8px rgba(var(--mg-shadow-rgb, 22, 58, 74), 0.08);
} }
.spacer { flex: 1; } .spacer { flex: 1; }
.el-divider--vertical { margin: 0 6px; background: var(--me-border); }
.topbar-btn { .topbar-btn {
color: var(--me-text) !important; color: var(--me-text) !important;
@@ -169,23 +121,43 @@ function mark(on: boolean): string {
color: var(--mg-primary) !important; color: var(--mg-primary) !important;
background: var(--me-hover) !important; background: var(--me-hover) !important;
} }
.topbar-btn:focus { .topbar-count {
background: var(--me-hover) !important; margin-left: 6px;
font-size: 11px;
font-weight: 500;
opacity: 0.72;
font-variant-numeric: tabular-nums;
} }
.topbar-icon-btn { .topbar-icon-btn {
position: relative;
color: var(--me-text) !important; color: var(--me-text) !important;
background: var(--me-surface) !important; background: var(--me-surface) !important;
border: 1px solid var(--me-border) !important; border: 1px solid var(--me-border) !important;
font-size: 15px; font-size: 15px;
min-width: 36px;
} }
.topbar-icon-btn:hover:not(.is-disabled) { .topbar-icon-btn:hover:not(.is-disabled) {
background: var(--me-hover) !important; background: var(--me-hover) !important;
border-color: rgba(var(--mg-primary-rgb), 0.55) !important; border-color: rgba(var(--mg-primary-rgb), 0.55) !important;
color: var(--mg-primary) !important; color: var(--mg-primary) !important;
} }
.topbar-icon-btn.is-disabled { .topbar-icon-btn.is-disabled { opacity: 0.4; }
opacity: 0.4; .topbar-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: var(--mg-primary);
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
font-variant-numeric: tabular-nums;
} }
.topbar-save-btn { .topbar-save-btn {
@@ -204,15 +176,4 @@ function mark(on: boolean): string {
--el-button-text-color: #fff !important; --el-button-text-color: #fff !important;
--el-button-hover-text-color: #fff !important; --el-button-hover-text-color: #fff !important;
} }
.topbar-filter-menu :deep(.topbar-filter-header) {
font-size: 11px;
color: var(--me-text-muted);
cursor: default;
pointer-events: none;
padding-top: 6px;
padding-bottom: 2px;
text-transform: none;
letter-spacing: 0.4px;
}
</style> </style>
@@ -217,6 +217,7 @@ import {
extractSocFromKv, extractSocFromKv,
socToPercent socToPercent
} from '@/utils/batterySoc' } from '@/utils/batterySoc'
import { formatTrafficStatus as formatTrafficStatusText } from '@/utils/carRuntime'
import type { Car } from '@/types/car' import type { Car } from '@/types/car'
import type { Mission } from '@/types/mission' import type { Mission } from '@/types/mission'
import type { SelectedObjectRef } from '@/types/workbench' import type { SelectedObjectRef } from '@/types/workbench'
@@ -406,36 +407,8 @@ function formatCarPosition(): string {
} }
function formatTrafficStatus(): string { function formatTrafficStatus(): string {
const tc = pickStatus('TCStat') || pickField('TCStat') // 与车体浮窗 / 车辆列表同一口径(畅通 · 正常拿锁 · 被 X 车阻塞)
if (tc && tc !== '/' && tc.trim() !== '') return tc return formatTrafficStatusText(statusRows.value)
const blockedRaw = pickStatus('blockedBy')
if (blockedRaw && blockedRaw !== '[]' && blockedRaw !== '/') {
try {
const arr = JSON.parse(blockedRaw) as Array<{ carid?: number; type?: number }>
if (Array.isArray(arr) && arr.length > 0) {
const ids = arr.map((b) => b.carid).filter((id): id is number => id != null)
if (ids.length) return `被车辆 ${ids.join('、')} 交管`
return '交管阻塞'
}
} catch {
return blockedRaw.length > 48 ? `${blockedRaw.slice(0, 48)}` : blockedRaw
}
}
const stopped = pickStatus('trafficStopped')
if (stopped && stopped !== '(0, 0)' && stopped !== '(0,0)' && stopped !== '/') {
return `交管暂停 ${stopped}`
}
const holding = pickStatus('holdingLocks')
const pending = pickStatus('pendingLocks')
const parts: string[] = []
if (holding && holding !== '[]') parts.push(`已占 ${holding}`)
if (pending && pending !== '[]') parts.push(`待锁 ${pending}`)
if (parts.length) return parts.join('')
return '正常'
} }
function formatAlarmStatus(): { text: string; warn: boolean } { function formatAlarmStatus(): { text: string; warn: boolean } {
@@ -1,5 +1,5 @@
<template> <template>
<div class="vehicle-monitor" :class="{ 'is-compact': compact, 'is-table': table }"> <div class="vehicle-monitor" :class="{ 'is-compact': compact, 'is-table': table, 'is-lite': lite }">
<div v-if="!compact && !table" class="overview-row"> <div v-if="!compact && !table" class="overview-row">
<div class="overview-item"> <div class="overview-item">
<div class="overview-num">{{ cars.length }}</div> <div class="overview-num">{{ cars.length }}</div>
@@ -28,7 +28,7 @@
v-model="search" v-model="search"
size="small" size="small"
clearable clearable
:placeholder="table ? '搜索车辆编号/名称' : '搜索 ID / 名称 / 任务'" :placeholder="tablePlaceholder"
class="search" class="search"
/> />
<el-select <el-select
@@ -65,29 +65,92 @@
</div> </div>
<div v-if="table" class="table-wrap"> <div v-if="table" class="table-wrap">
<div class="table-head" aria-hidden="true"> <div class="dense-scroll">
<span class="col-id">车辆编号</span> <table class="dense-table" aria-label="车辆列表">
<span class="col-state">状态</span> <thead>
<span class="col-bat">电量</span> <tr>
<span class="col-pos">当前位置</span> <th class="col-vehicle">车辆</th>
</div> <th class="col-state">状态</th>
<div class="vehicle-list table-body"> <th class="col-bat">电量</th>
<button <th class="col-mission">任务</th>
<th v-if="!lite" class="col-alarm">报警</th>
<th class="col-tc">交管</th>
<th v-if="!lite" class="col-lock">锁点</th>
<th v-if="!lite" class="col-ops">操作</th>
</tr>
</thead>
<tbody>
<tr
v-for="car in filteredCars" v-for="car in filteredCars"
:key="car.id" :key="car.id"
type="button" class="dense-row"
class="table-row" :class="{
:class="{ 'is-selected': car.id === selectedId }" 'is-selected': car.id === selectedId,
'has-alarm': !lite && hasAlarm(car),
'is-busy': !lite && !!actingId && actingId === carKey(car)
}"
tabindex="0"
@click="onSelect(car)" @click="onSelect(car)"
@keydown.enter.prevent="onSelect(car)"
> >
<span class="col-id" :title="car.name">{{ shortId(car) }}</span> <td class="col-vehicle">
<span class="col-state"> <div class="veh-cell">
<span class="state-dot" :class="`dot-${car.state}`" /> <span class="veh-id">{{ shortId(car) }}</span>
<span class="veh-name" :title="car.name">{{ car.name || `车辆 ${shortId(car)}` }}</span>
</div>
<div class="veh-meta" :title="positionMeta(car)">{{ positionMeta(car) }}</div>
</td>
<td class="col-state">
<span class="state-pill" :class="`pill-${car.state}`">
<i class="state-dot" :class="`dot-${car.state}`" />
{{ stateLabel(car.state) }} {{ stateLabel(car.state) }}
</span> </span>
<span class="col-bat" :class="batteryLevel(batteryPct(car))">{{ batteryText(car) }}</span> </td>
<span class="col-pos" :title="positionText(car)">{{ positionText(car) }}</span> <td class="col-bat" :class="batteryLevel(batteryPct(car))">{{ batteryText(car) }}</td>
<td class="col-mission" :class="{ muted: !missionForCar(car) }" :title="missionTitle(car)">
{{ missionCell(car) }}
</td>
<td v-if="!lite" class="col-alarm" :class="{ warn: hasAlarm(car) }" :title="alarmText(car)">
{{ alarmCell(car) }}
</td>
<td class="col-tc" :class="{ warn: isTrafficBlocked(car) }" :title="trafficText(car)">
{{ trafficCell(car) }}
</td>
<td v-if="!lite" class="col-lock mono" :title="lockText(car)">{{ lockCell(car) }}</td>
<td v-if="!lite" class="col-ops" @click.stop>
<el-dropdown
trigger="click"
placement="bottom-end"
:disabled="!!actingId"
@command="(cmd: VehicleQuickAction) => onQuickAction(car, cmd)"
>
<button
type="button"
class="ops-btn"
:disabled="!!actingId"
title="车辆操作"
>
操作
<el-icon class="ops-caret"><ArrowDown /></el-icon>
</button> </button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
v-for="op in quickActions"
:key="op.value"
:command="op.value"
:divided="op.value === 'disable'"
:class="{ 'is-danger-item': op.danger }"
>
{{ op.label }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</td>
</tr>
</tbody>
</table>
<div v-if="!filteredCars.length" class="empty muted">无匹配车辆</div> <div v-if="!filteredCars.length" class="empty muted">无匹配车辆</div>
</div> </div>
</div> </div>
@@ -157,9 +220,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { ArrowDown } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import type { Car, CarState } from '@/types/car' import type { Car, CarState } from '@/types/car'
import type { Mission, MissionStatus } from '@/types/mission' import type { Mission, MissionStatus } from '@/types/mission'
import type { SelectedObjectRef } from '@/types/workbench' import type { SelectedObjectRef } from '@/types/workbench'
import {
VEHICLE_QUICK_ACTIONS,
executeVehicleQuickAction,
type VehicleQuickAction
} from '@/api/vehicleOps'
import { isTrafficCongested } from '@/utils/carRuntime'
const props = defineProps<{ const props = defineProps<{
cars: Car[] cars: Car[]
@@ -167,14 +238,27 @@ const props = defineProps<{
selectedId?: string | null selectedId?: string | null
/** 紧凑模式:隐藏顶部概览条 */ /** 紧凑模式:隐藏顶部概览条 */
compact?: boolean compact?: boolean
/** 表格模式:筛选芯片 + 四列表格(地图监控参考布局 */ /** 表格模式:筛选芯片 + 紧凑数据表(地图监控) */
table?: boolean table?: boolean
/** 双栏精简:隐藏报警 / 锁点 / 操作列 */
lite?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
select: [ref: SelectedObjectRef] select: [ref: SelectedObjectRef]
'action-done': []
}>() }>()
const quickActions = VEHICLE_QUICK_ACTIONS
const actingId = ref<string | null>(null)
const tablePlaceholder = computed(() => {
if (!props.table) return '搜索 ID / 名称 / 任务'
return props.lite
? '搜索编号 / 名称 / 交管 / 任务'
: '搜索编号 / 名称 / 报警 / 交管 / 任务'
})
const search = ref('') const search = ref('')
const filterState = ref<CarState | ''>('') const filterState = ref<CarState | ''>('')
type QuickFilter = 'all' | 'running' | 'idle' | 'offline' | 'fault' type QuickFilter = 'all' | 'running' | 'idle' | 'offline' | 'fault'
@@ -326,6 +410,63 @@ function positionText(car: Car): string {
return `${Math.round(car.x)}, ${Math.round(car.y)}` return `${Math.round(car.x)}, ${Math.round(car.y)}`
} }
function positionMeta(car: Car): string {
const parts = [positionText(car)]
if (car.siteId != null) parts.push(`站点 #${car.siteId}`)
if (car.lstatus) parts.push(car.lstatus)
return parts.filter((p) => p && p !== '-').join(' · ') || '—'
}
function hasAlarm(car: Car): boolean {
return (car.alarmLevel != null && car.alarmLevel > 0) || !!car.alarmInfo || car.state === 'fault'
}
function alarmText(car: Car): string {
if (car.alarmInfo) return car.alarmInfo
if (car.state === 'fault') return car.lstatus || '故障'
return '无'
}
function alarmCell(car: Car): string {
const t = alarmText(car)
if (t === '无') return '—'
return t.length > 10 ? `${t.slice(0, 10)}` : t
}
function trafficText(car: Car): string {
return (car.trafficStatus || '—').trim() || '—'
}
function isTrafficBlocked(car: Car): boolean {
return isTrafficCongested(trafficText(car))
}
function trafficCell(car: Car): string {
const t = trafficText(car)
return t.length > 14 ? `${t.slice(0, 14)}` : t
}
function lockText(car: Car): string {
return (car.lockPoint || '—').trim() || '—'
}
function lockCell(car: Car): string {
const t = lockText(car)
return t.length > 10 ? `${t.slice(0, 10)}` : t
}
function missionCell(car: Car): string {
const m = missionForCar(car)
if (!m) return car.missionId ? '有任务' : '空闲'
return missionStatusLabel(m.status)
}
function missionTitle(car: Car): string {
const m = missionForCar(car)
if (!m) return car.missionId ? `任务 ${car.missionId}` : '空闲'
return `${missionStatusLabel(m.status)} · ${m.name}`
}
const filteredCars = computed(() => { const filteredCars = computed(() => {
const q = search.value.trim().toLowerCase() const q = search.value.trim().toLowerCase()
const tokens = q ? q.split(/\s+/).filter(Boolean) : [] const tokens = q ? q.split(/\s+/).filter(Boolean) : []
@@ -346,6 +487,11 @@ const filteredCars = computed(() => {
c.typeName ?? '', c.typeName ?? '',
c.group ?? '', c.group ?? '',
c.ip ?? '', c.ip ?? '',
c.lstatus ?? '',
c.alarmInfo ?? '',
c.trafficStatus ?? '',
c.lockPoint ?? '',
c.siteId != null ? String(c.siteId) : '',
stateLabel(c.state), stateLabel(c.state),
mission?.name ?? '', mission?.name ?? '',
mission ? missionStatusLabel(mission.status) : '', mission ? missionStatusLabel(mission.status) : '',
@@ -365,6 +511,51 @@ function detailIdForCar(car: Car): string {
if (car.rawId != null) return String(car.rawId) if (car.rawId != null) return String(car.rawId)
return car.id.replace(/^C/i, '').replace(/^0+/, '') || car.id return car.id.replace(/^C/i, '').replace(/^0+/, '') || car.id
} }
function carKey(car: Car): string {
return String(car.rawId ?? car.id)
}
function rawIdOf(car: Car): number | null {
if (car.rawId != null && Number.isFinite(car.rawId)) return car.rawId
const n = Number(detailIdForCar(car))
return Number.isFinite(n) && n > 0 ? n : null
}
async function onQuickAction(car: Car, action: VehicleQuickAction) {
const rawId = rawIdOf(car)
if (rawId == null) {
ElMessage.warning('无法解析车辆 ID')
return
}
const op = quickActions.find((x) => x.value === action)
if (!op) return
try {
await ElMessageBox.confirm(
`${op.confirm}\n目标:${car.name || shortId(car)}#${rawId}`,
op.label,
{
type: op.danger ? 'warning' : 'info',
confirmButtonText: '确定',
cancelButtonText: '取消',
distinguishCancelAndClose: true
}
)
} catch {
return
}
actingId.value = carKey(car)
try {
await executeVehicleQuickAction(rawId, action)
ElMessage.success(`已执行「${op.label}`)
emit('action-done')
} catch (err) {
ElMessage.error(`${op.label}」失败:${(err as Error).message}`)
} finally {
actingId.value = null
}
}
</script> </script>
<style scoped> <style scoped>
@@ -377,7 +568,12 @@ function detailIdForCar(car: Car): string {
overflow: hidden; overflow: hidden;
} }
.vehicle-monitor.is-compact { gap: 8px; } .vehicle-monitor.is-compact { gap: 8px; }
.vehicle-monitor.is-table { gap: 8px; } .vehicle-monitor.is-table { gap: 6px; }
.vehicle-monitor.is-lite .col-vehicle { width: 36%; }
.vehicle-monitor.is-lite .col-state { width: 86px; }
.vehicle-monitor.is-lite .col-bat { width: 48px; }
.vehicle-monitor.is-lite .col-mission { width: 72px; }
.vehicle-monitor.is-lite .col-tc { width: auto; }
.vehicle-monitor.is-compact .vehicle-row { .vehicle-monitor.is-compact .vehicle-row {
border-radius: 8px; border-radius: 8px;
@@ -450,7 +646,7 @@ function detailIdForCar(car: Car): string {
color: var(--mg-text-muted); color: var(--mg-text-muted);
font-size: 11px; font-size: 11px;
font-weight: 550; font-weight: 550;
height: 26px; height: 24px;
padding: 0 8px; padding: 0 8px;
border-radius: 6px; border-radius: 6px;
cursor: pointer; cursor: pointer;
@@ -480,80 +676,190 @@ function detailIdForCar(car: Car): string {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
border: 1px solid var(--mg-veil-border);
border-radius: 8px;
background: #fff;
} }
.table-head, .dense-scroll {
.table-row {
display: grid;
grid-template-columns: 56px 64px 48px minmax(0, 1fr);
gap: 6px;
align-items: center;
padding: 0 8px;
}
.table-head {
flex: none;
height: 28px;
font-size: 11px;
font-weight: 600;
color: var(--mg-text-muted);
border-bottom: 1px solid var(--mg-veil-border);
}
.table-body {
flex: 1 1 0; flex: 1 1 0;
min-height: 0; min-height: 0;
overflow-y: auto; overflow: auto;
gap: 0;
padding-right: 0;
} }
.table-row { .dense-table {
appearance: none;
width: 100%; width: 100%;
border: 0; border-collapse: separate;
border-bottom: 1px solid var(--mg-veil-border); border-spacing: 0;
background: transparent; table-layout: fixed;
color: var(--mg-text-light); font-size: 12px;
}
.dense-table thead th {
position: sticky;
top: 0;
z-index: 2;
height: 30px;
padding: 0 8px;
text-align: left; text-align: left;
height: 40px;
font-size: 12px;
cursor: pointer;
transition: background 0.15s ease;
}
.table-row:hover {
background: var(--mg-veil-1);
}
.table-row.is-selected {
background: rgba(var(--mg-primary-rgb), 0.1);
box-shadow: inset 3px 0 0 var(--mg-primary);
}
.col-id {
font-family: var(--mg-font-mono);
font-variant-numeric: tabular-nums;
font-weight: 650;
font-size: 12px;
}
.col-state {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px; font-size: 11px;
font-weight: 650;
color: var(--mg-text-muted); color: var(--mg-text-muted);
background: var(--mg-veil-1, #f6f3fb);
border-bottom: 1px solid var(--mg-veil-border);
white-space: nowrap; white-space: nowrap;
} }
.col-bat { .dense-row {
font-family: var(--mg-font-mono); cursor: pointer;
font-variant-numeric: tabular-nums; transition: background 0.12s ease;
font-size: 11px;
font-weight: 600;
} }
.col-bat.low { color: var(--mg-status-danger); } .dense-row td {
.col-bat.mid { color: var(--mg-status-warning); } height: 40px;
.col-bat.high { color: var(--mg-status-success); } padding: 4px 8px;
.col-pos { vertical-align: middle;
font-size: 11px; border-bottom: 1px solid rgba(40, 33, 58, 0.06);
color: var(--mg-text-dim); color: var(--mg-text-light);
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.dense-row:hover td {
background: rgba(var(--mg-primary-rgb), 0.04);
}
.dense-row.is-selected td {
background: rgba(var(--mg-primary-rgb), 0.08);
}
.dense-row.is-selected td:first-child {
box-shadow: inset 3px 0 0 var(--mg-primary);
}
.dense-row.has-alarm td.col-alarm {
font-weight: 650;
}
.dense-row.is-busy {
opacity: 0.65;
pointer-events: none;
}
.col-vehicle { width: 22%; }
.col-state { width: 88px; }
.col-bat { width: 52px; }
.col-mission { width: 72px; }
.col-alarm { width: 14%; }
.col-tc { width: 14%; }
.col-lock { width: 72px; }
.col-ops { width: 64px; text-align: right; }
.veh-cell {
display: flex;
align-items: baseline;
gap: 6px;
min-width: 0;
}
.veh-id {
font-family: var(--mg-font-mono);
font-variant-numeric: tabular-nums;
font-size: 13px;
font-weight: 750;
color: var(--mg-text-light);
flex: none;
}
.veh-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
font-weight: 550;
color: var(--mg-text-light);
}
.veh-meta {
margin-top: 1px;
font-size: 10.5px;
color: var(--mg-text-faint);
font-family: var(--mg-font-mono);
font-variant-numeric: tabular-nums;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.state-pill {
display: inline-flex;
align-items: center;
gap: 4px;
height: 22px;
padding: 0 7px;
border-radius: 999px;
font-size: 11px;
font-weight: 650;
background: var(--mg-veil-1);
color: var(--mg-text-muted);
border: 1px solid var(--mg-veil-border);
}
.state-pill.pill-running {
color: var(--mg-status-success);
background: color-mix(in srgb, var(--mg-status-success) 12%, #fff);
border-color: color-mix(in srgb, var(--mg-status-success) 28%, transparent);
}
.state-pill.pill-fault {
color: var(--mg-status-danger);
background: color-mix(in srgb, var(--mg-status-danger) 10%, #fff);
border-color: color-mix(in srgb, var(--mg-status-danger) 28%, transparent);
}
.state-pill.pill-offline {
color: var(--mg-text-faint);
}
.state-pill.pill-charging {
color: var(--mg-primary);
background: rgba(var(--mg-primary-rgb), 0.08);
border-color: rgba(var(--mg-primary-rgb), 0.28);
}
.state-pill.pill-paused {
color: var(--mg-status-warning);
background: color-mix(in srgb, var(--mg-status-warning) 12%, #fff);
}
.ops-btn {
appearance: none;
height: 24px;
padding: 0 8px;
border-radius: 6px;
border: 1px solid rgba(var(--mg-primary-rgb), 0.35);
background: rgba(var(--mg-primary-rgb), 0.08);
color: var(--mg-primary);
font-size: 11px;
font-weight: 650;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 1px;
transition: background 0.15s ease, border-color 0.15s ease;
}
.ops-btn:hover:not(:disabled) {
background: rgba(var(--mg-primary-rgb), 0.14);
border-color: rgba(var(--mg-primary-rgb), 0.55);
}
.ops-btn:disabled { opacity: 0.55; cursor: not-allowed; }
.ops-caret { font-size: 11px; margin-left: 1px; }
.col-bat,
.col-mission,
.col-alarm,
.col-tc,
.col-lock {
font-variant-numeric: tabular-nums;
}
.col-bat.low,
.col-alarm.warn,
.col-tc.warn { color: var(--mg-status-danger); font-weight: 650; }
.col-bat.mid { color: var(--mg-status-warning); font-weight: 650; }
.col-bat.high { color: var(--mg-status-success); font-weight: 650; }
.col-mission.muted { color: var(--mg-text-faint); }
.mono {
font-family: var(--mg-font-mono);
font-variant-numeric: tabular-nums;
}
:deep(.is-danger-item) {
color: var(--mg-status-danger) !important;
}
.state-dot { .state-dot {
width: 6px; width: 6px;
height: 6px; height: 6px;
@@ -46,6 +46,7 @@ export type EditToolId =
| 'transform.scale' | 'transform.scale'
| 'transform.copy' | 'transform.copy'
| 'transform.paste' | 'transform.paste'
| 'transform.duplicate'
| 'transform.delete' | 'transform.delete'
| 'transform.copyFields' | 'transform.copyFields'
// 对齐组 // 对齐组
@@ -136,6 +137,7 @@ export function useEditTool() {
'transform.scale': '缩放', 'transform.scale': '缩放',
'transform.copy': '复制', 'transform.copy': '复制',
'transform.paste': '粘贴', 'transform.paste': '粘贴',
'transform.duplicate': '复制副本',
'transform.delete': '删除', 'transform.delete': '删除',
'transform.copyFields': '复制字段', 'transform.copyFields': '复制字段',
'align.left': '左对齐', 'align.left': '左对齐',
@@ -50,7 +50,7 @@ export interface SelectionDetailEvent {
} }
export interface WorkspaceShortcutEvent { export interface WorkspaceShortcutEvent {
action: 'undo' | 'redo' | 'delete' action: 'undo' | 'redo' | 'delete' | 'copy' | 'paste' | 'duplicate'
} }
export interface MapEditStreamHandlers { export interface MapEditStreamHandlers {
@@ -43,6 +43,7 @@ export function useSimpleFieldCatalog(
) { ) {
const loading = ref(false) const loading = ref(false)
const records = ref<SimpleFieldRecord[]>([]) const records = ref<SimpleFieldRecord[]>([])
const lastError = ref<string | null>(null)
const fieldType = computed(() => fieldCategoryForMapKind(kind.value)) const fieldType = computed(() => fieldCategoryForMapKind(kind.value))
@@ -56,9 +57,11 @@ export function useSimpleFieldCatalog(
const ft = fieldType.value const ft = fieldType.value
if (!ft) { if (!ft) {
records.value = [] records.value = []
lastError.value = null
return return
} }
loading.value = true loading.value = true
lastError.value = null
try { try {
// 车辆:为兼容 shortName / assembly.shortName 等不同 car_type 存储格式,先拉 carFields 再前端匹配当前车型。 // 车辆:为兼容 shortName / assembly.shortName 等不同 car_type 存储格式,先拉 carFields 再前端匹配当前车型。
if (ft === 'carFields') { if (ft === 'carFields') {
@@ -68,14 +71,22 @@ export function useSimpleFieldCatalog(
const rows = await listSimpleFields(ft) const rows = await listSimpleFields(ft)
records.value = dedupeByKey(rows) records.value = dedupeByKey(rows)
} }
} catch { } catch (err) {
records.value = [] records.value = []
lastError.value = (err as Error)?.message || '字段目录加载失败'
} finally { } finally {
loading.value = false loading.value = false
} }
} }
watch(fieldType, () => { void reload() }, { immediate: true }) watch(fieldType, () => { void reload() }, { immediate: true })
// 选中车后 typeName 异步到齐时需重滤 carFields
watch(
() => carType?.value,
() => {
if (fieldType.value === 'carFields') void reload()
}
)
function labelForKey(key: string): string { function labelForKey(key: string): string {
const row = byKey.value.get(key) const row = byKey.value.get(key)
@@ -89,6 +100,7 @@ export function useSimpleFieldCatalog(
return { return {
loading, loading,
records, records,
lastError,
fieldType, fieldType,
byKey, byKey,
reload, reload,
@@ -82,15 +82,6 @@
<el-dropdown-menu> <el-dropdown-menu>
<el-dropdown-item v-if="auth.scope === 'Platform'" command="wizard">配置向导</el-dropdown-item> <el-dropdown-item v-if="auth.scope === 'Platform'" command="wizard">配置向导</el-dropdown-item>
<el-dropdown-item command="status">服务状态</el-dropdown-item> <el-dropdown-item command="status">服务状态</el-dropdown-item>
<el-dropdown-item divided disabled class="legacy-theme-label">高级 · 兼容主题</el-dropdown-item>
<el-dropdown-item
v-for="t in ui.legacyThemes"
:key="t.id"
:command="`theme:${t.id}`"
:class="{ 'is-legacy-active': t.id === ui.themeId }">
<span class="legacy-swatch" :style="{ background: t.preview }" />
{{ t.name }}
</el-dropdown-item>
<el-dropdown-item command="logout" divided>退出登录</el-dropdown-item> <el-dropdown-item command="logout" divided>退出登录</el-dropdown-item>
</el-dropdown-menu> </el-dropdown-menu>
</template> </template>
@@ -184,10 +175,6 @@ const activePath = computed(() => {
const currentTitle = computed(() => (route.meta.title as string | undefined) ?? '') const currentTitle = computed(() => (route.meta.title as string | undefined) ?? '')
function onUserCommand(cmd: string) { function onUserCommand(cmd: string) {
if (cmd.startsWith('theme:')) {
ui.setThemeId(cmd.slice('theme:'.length))
return
}
if (cmd === 'logout') { if (cmd === 'logout') {
auth.logout() auth.logout()
router.push('/login') router.push('/login')
@@ -568,25 +555,3 @@ function onUserCommand(cmd: string) {
flex-direction: column; flex-direction: column;
} }
</style> </style>
<style>
/* 用户菜单「兼容主题」项(teleport 到 body */
.legacy-theme-label {
font-size: 11px !important;
opacity: 0.65;
cursor: default !important;
}
.legacy-swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 8px;
vertical-align: middle;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.12);
}
.el-dropdown-menu__item.is-legacy-active {
color: var(--mg-primary) !important;
font-weight: 600;
}
</style>
@@ -54,7 +54,6 @@ function loadInitial(): UiState {
const schema = Number(localStorage.getItem(THEME_SCHEMA_KEY) || '0') const schema = Number(localStorage.getItem(THEME_SCHEMA_KEY) || '0')
const migrated = schema < THEME_SCHEMA_VERSION const migrated = schema < THEME_SCHEMA_VERSION
if (migrated) { if (migrated) {
// ponytail: 一次强制 outpost-light,兼容主题仍可从高级菜单切回
themeId = DEFAULT_THEME_ID themeId = DEFAULT_THEME_ID
localStorage.setItem(THEME_SCHEMA_KEY, String(THEME_SCHEMA_VERSION)) localStorage.setItem(THEME_SCHEMA_KEY, String(THEME_SCHEMA_VERSION))
} }
@@ -2323,3 +2323,119 @@ a:hover { color: rgba(var(--mg-accent-rgb), 0.85); }
:root[data-theme="outpost-light"] .mg-content .map-editor-page .el-checkbox__label { :root[data-theme="outpost-light"] .mg-content .map-editor-page .el-checkbox__label {
color: #64748b !important; color: #64748b !important;
} }
/* ?? AI Assistant surface tokens (A Porcelain / C Amethyst / E Paper Split) ?? */
:root[data-shell="outpost"] {
--ai-panel-bg: #ffffff;
--ai-side-bg: color-mix(in srgb, var(--ai-panel-bg) 92%, var(--ai-accent-soft));
--ai-panel-border: #e5e7eb;
--ai-panel-shadow: -12px 0 40px rgba(15, 23, 42, 0.08);
--ai-head-bg: #ffffff;
--ai-head-border: #e5e7eb;
--ai-title: rgba(45, 27, 105, 0.96);
--ai-muted: rgba(76, 29, 149, 0.52);
--ai-accent: var(--mg-primary);
--ai-accent-soft: color-mix(in srgb, var(--mg-primary) 12%, #ffffff);
--ai-accent-soft-border: color-mix(in srgb, var(--mg-primary) 28%, transparent);
--ai-user-bg: var(--mg-primary);
--ai-user-fg: #ffffff;
--ai-bot-bg: #ffffff;
--ai-bot-fg: rgba(45, 27, 105, 0.92);
--ai-bot-border: #e5e7eb;
--ai-example-bg: color-mix(in srgb, var(--mg-primary) 8%, #ffffff);
--ai-example-border: color-mix(in srgb, var(--mg-primary) 22%, transparent);
--ai-example-fg: color-mix(in srgb, var(--mg-primary) 78%, #1a0f3d);
--ai-input-bg: #ffffff;
--ai-input-border: #e5e7eb;
--ai-input-fg: rgba(45, 27, 105, 0.92);
--ai-input-placeholder: rgba(76, 29, 149, 0.4);
--ai-composer-bg: #ffffff;
--ai-composer-border: #e5e7eb;
--ai-send-bg: var(--mg-primary);
--ai-send-fg: #ffffff;
--ai-send-disabled: #c4b5fd;
--ai-dot: var(--mg-primary);
--ai-scrollbar: color-mix(in srgb, var(--mg-primary) 28%, transparent);
--ai-fab-bg: var(--mg-primary);
--ai-fab-fg: #ffffff;
--ai-fab-shadow: 0 8px 24px color-mix(in srgb, var(--mg-primary) 28%, transparent);
--ai-fab-glow: none;
--ai-md-code-bg: color-mix(in srgb, var(--mg-primary) 8%, #ffffff);
--ai-md-code-fg: color-mix(in srgb, var(--mg-primary) 72%, #1a0f3d);
--ai-md-pre-bg: #f8fafc;
--ai-md-pre-border: #e5e7eb;
--ai-md-blockquote-border: color-mix(in srgb, var(--mg-primary) 45%, transparent);
--ai-md-blockquote-fg: rgba(76, 29, 149, 0.72);
--ai-md-link: var(--mg-primary);
--ai-md-hr: #e5e7eb;
--ai-md-table-border: #e5e7eb;
--ai-md-th-bg: #f8fafc;
--ai-avatar-bot-bg: color-mix(in srgb, var(--mg-primary) 12%, #ffffff);
--ai-avatar-bot-fg: var(--mg-primary);
--ai-avatar-bot-border: color-mix(in srgb, var(--mg-primary) 28%, transparent);
--ai-avatar-user-bg: var(--mg-primary);
--ai-avatar-user-fg: #ffffff;
--ai-close-hover-bg: color-mix(in srgb, var(--mg-primary) 10%, #ffffff);
--ai-close-hover-fg: var(--mg-primary);
--ai-thinking-bg: color-mix(in srgb, var(--mg-primary) 6%, #ffffff);
--ai-thinking-border: color-mix(in srgb, var(--mg-primary) 18%, transparent);
--ai-thinking-fg: rgba(76, 29, 149, 0.62);
}
/* C Amethyst Dock purple theme only */
:root[data-theme="outpost-purple"] {
--ai-panel-bg: linear-gradient(180deg, #1e1235 0%, #160e28 48%, #120a22 100%);
--ai-side-bg: rgba(26, 16, 44, 0.96);
--ai-panel-border: rgba(167, 139, 250, 0.22);
--ai-panel-shadow: -16px 0 48px rgba(0, 0, 0, 0.45);
--ai-head-bg: rgba(30, 18, 53, 0.92);
--ai-head-border: rgba(167, 139, 250, 0.18);
--ai-title: rgba(245, 243, 255, 0.96);
--ai-muted: rgba(196, 181, 253, 0.55);
--ai-accent: #a78bfa;
--ai-accent-soft: rgba(124, 58, 237, 0.22);
--ai-accent-soft-border: rgba(167, 139, 250, 0.35);
--ai-user-bg: linear-gradient(135deg, #7c3aed 0%, #6d28d9 100%);
--ai-user-fg: #ffffff;
--ai-bot-bg: rgba(255, 255, 255, 0.06);
--ai-bot-fg: rgba(237, 233, 254, 0.92);
--ai-bot-border: rgba(167, 139, 250, 0.2);
--ai-example-bg: rgba(124, 58, 237, 0.18);
--ai-example-border: rgba(167, 139, 250, 0.32);
--ai-example-fg: rgba(221, 214, 254, 0.92);
--ai-input-bg: rgba(255, 255, 255, 0.06);
--ai-input-border: rgba(167, 139, 250, 0.28);
--ai-input-fg: rgba(245, 243, 255, 0.94);
--ai-input-placeholder: rgba(196, 181, 253, 0.42);
--ai-composer-bg: rgba(22, 14, 40, 0.96);
--ai-composer-border: rgba(167, 139, 250, 0.18);
--ai-send-bg: linear-gradient(135deg, #8b5cf6 0%, #7c3aed 100%);
--ai-send-fg: #ffffff;
--ai-send-disabled: rgba(124, 58, 237, 0.35);
--ai-dot: #c4b5fd;
--ai-scrollbar: rgba(167, 139, 250, 0.35);
--ai-fab-bg: linear-gradient(160deg, #8b5cf6 0%, #6d28d9 100%);
--ai-fab-fg: #ffffff;
--ai-fab-shadow: 0 10px 28px rgba(109, 40, 217, 0.45);
--ai-fab-glow: 0 0 0 1px rgba(196, 181, 253, 0.25);
--ai-md-code-bg: rgba(124, 58, 237, 0.28);
--ai-md-code-fg: #ddd6fe;
--ai-md-pre-bg: rgba(0, 0, 0, 0.28);
--ai-md-pre-border: rgba(167, 139, 250, 0.22);
--ai-md-blockquote-border: rgba(167, 139, 250, 0.45);
--ai-md-blockquote-fg: rgba(196, 181, 253, 0.75);
--ai-md-link: #c4b5fd;
--ai-md-hr: rgba(167, 139, 250, 0.22);
--ai-md-table-border: rgba(167, 139, 250, 0.22);
--ai-md-th-bg: rgba(124, 58, 237, 0.18);
--ai-avatar-bot-bg: rgba(124, 58, 237, 0.28);
--ai-avatar-bot-fg: #ddd6fe;
--ai-avatar-bot-border: rgba(167, 139, 250, 0.35);
--ai-avatar-user-bg: #7c3aed;
--ai-avatar-user-fg: #ffffff;
--ai-close-hover-bg: rgba(167, 139, 250, 0.16);
--ai-close-hover-fg: #ddd6fe;
--ai-thinking-bg: rgba(124, 58, 237, 0.14);
--ai-thinking-border: rgba(167, 139, 250, 0.22);
--ai-thinking-fg: rgba(196, 181, 253, 0.7);
}
@@ -424,7 +424,9 @@ const THEME_ALIASES: Record<string, string> = {
export function resolveThemeId(id: string | undefined | null): string { export function resolveThemeId(id: string | undefined | null): string {
if (!id) return DEFAULT_THEME_ID if (!id) return DEFAULT_THEME_ID
const aliased = THEME_ALIASES[id] ?? id const aliased = THEME_ALIASES[id] ?? id
if (THEMES.some((t) => t.id === aliased)) return aliased // 兼容主题入口已移除:旧 id 一律回落到 Outpost 浅色
if (LEGACY_THEMES.some((t) => t.id === aliased)) return DEFAULT_THEME_ID
if (PRIMARY_THEMES.some((t) => t.id === aliased)) return aliased
return DEFAULT_THEME_ID return DEFAULT_THEME_ID
} }
@@ -23,6 +23,16 @@ export interface Car {
ip?: string ip?: string
onboardUrl?: string onboardUrl?: string
lstatus?: string lstatus?: string
/** 运行时富化:报警文案(车体_AlarmInfo */
alarmInfo?: string
/** 运行时富化:报警级别(>0 视为有报警) */
alarmLevel?: number
/** 运行时富化:交管摘要(畅通 / 正常拿锁 / 被 X 车阻塞 / 互锁) */
trafficStatus?: string
/** 运行时富化:当前锁点 / 占点摘要 */
lockPoint?: string
/** 运行时富化:当前站点 id(siteID,-1 表示无) */
siteId?: number
} }
/** 车队健康探测行(优先 GET /api/fleet/health;延迟为 WatchDog TCP RTT */ /** 车队健康探测行(优先 GET /api/fleet/health;延迟为 WatchDog TCP RTT */
@@ -12,6 +12,36 @@ function kv(rows: Kv[] | null | undefined, ...keys: string[]): string {
return '' return ''
} }
function isEmptyStatus(v: string): boolean {
const s = v.trim()
return !s || s === '/' || s === '-' || s === '[]' || s === '(0, 0)' || s === '(0,0)' || /^none$/i.test(s)
}
function compactJsonList(raw: string, maxItems = 3): string {
if (isEmptyStatus(raw)) return ''
try {
const arr = JSON.parse(raw) as unknown
if (!Array.isArray(arr) || arr.length === 0) return ''
const items = arr.slice(0, maxItems).map((x) => {
if (x == null) return ''
if (typeof x === 'number' || typeof x === 'string') return String(x)
if (typeof x === 'object') {
const o = x as Record<string, unknown>
if (o.siteId != null) return `#${o.siteId}`
if (o.siteID != null) return `#${o.siteID}`
if (o.id != null) return `#${o.id}`
if (o.carid != null) return `${o.carid}`
}
return ''
}).filter(Boolean)
if (!items.length) return ''
const more = arr.length > maxItems ? `+${arr.length - maxItems}` : ''
return items.join(',') + more
} catch {
return raw.length > 24 ? `${raw.slice(0, 24)}` : raw
}
}
/** /**
* SimpleLite /projection/cars 会把「正常但未初始化」「导航失联」等一律标成 fault, * SimpleLite /projection/cars 会把「正常但未初始化」「导航失联」等一律标成 fault,
* 与地图标签/车体 AlarmLevel 不一致。这里用 lstatus + 车体 status 重新推导。 * 与地图标签/车体 AlarmLevel 不一致。这里用 lstatus + 车体 status 重新推导。
@@ -28,7 +58,7 @@ export function deriveCarState(car: Pick<Car, 'state' | 'lstatus'>, statusRows?:
const hasAlarm = const hasAlarm =
(Number.isFinite(alarmLevel) && alarmLevel > 0) || (Number.isFinite(alarmLevel) && alarmLevel > 0) ||
(/true/i.test(riskAlarm)) || (/true/i.test(riskAlarm)) ||
(alarmInfo.length > 0 && !/^\/$|^-$|^none$/i.test(alarmInfo)) (alarmInfo.length > 0 && !isEmptyStatus(alarmInfo))
// 明确健康:以「正常」开头(含「正常但未初始化」)→ 绝不是故障 // 明确健康:以「正常」开头(含「正常但未初始化」)→ 绝不是故障
if (/^正常/.test(lstatus)) { if (/^正常/.test(lstatus)) {
@@ -62,6 +92,90 @@ export function deriveCarState(car: Pick<Car, 'state' | 'lstatus'>, statusRows?:
return 'idle' return 'idle'
} }
/**
* 与 SimpleLite 车体浮窗 CarVisual.FormatTrafficStatus 对齐:
* 1) TCStat 含 BeforeLock → 互锁
* 2) blockedBy 有车 →「被 X、Y 车阻塞」;仅非车阻塞 →「阻塞中」
* 3) 有 holdingLocks → 正常拿锁;否则 → 畅通
*/
export function formatTrafficStatus(statusRows?: Kv[] | null): string {
if (!statusRows?.length) return '—'
const tc = kv(statusRows, 'TCStat')
if (tc && /BeforeLock/i.test(tc)) return '互锁(interlock'
const blockedIds = parseBlockedByCarIds(kv(statusRows, 'blockedBy'))
if (blockedIds != null) {
return blockedIds.length > 0
? `${blockedIds.join('、')} 车阻塞`
: '阻塞中'
}
const holding = kv(statusRows, 'holdingLocks')
if (holding && !isEmptyStatus(holding) && holding !== '[]') return '正常拿锁'
return '畅通'
}
/** @returns null=无阻塞;[]=阻塞但无车 idnumber[]=阻塞车 id */
function parseBlockedByCarIds(raw: string): number[] | null {
if (!raw || isEmptyStatus(raw)) return null
try {
const arr = JSON.parse(raw) as unknown
if (!Array.isArray(arr) || arr.length === 0) return null
const ids = arr
.map((item) => {
if (item == null) return null
if (typeof item === 'number') return item
if (typeof item === 'object') {
const o = item as Record<string, unknown>
const n = Number(o.carid ?? o.Item1 ?? o.item1)
return Number.isFinite(n) ? n : null
}
return null
})
.filter((n): n is number => n != null && n !== -1)
return [...new Set(ids)]
} catch {
// 兼容 ToString 形态:[(11, 2), (12, 3)]
const ids = [...raw.matchAll(/\(\s*(-?\d+)\s*,\s*-?\d+\s*\)/g)]
.map((m) => Number(m[1]))
.filter((n) => Number.isFinite(n) && n !== -1)
if (!ids.length && /\[\s*\]/.test(raw)) return null
if (raw.includes('(') || raw.includes('[')) return [...new Set(ids)]
return null
}
}
export function isTrafficCongested(status: string | undefined | null): boolean {
return !!status && /阻塞|互锁|拥堵/.test(status)
}
export function formatLockPoint(statusRows?: Kv[] | null, fields?: { siteId?: string }): string {
const holding = compactJsonList(kv(statusRows, 'holdingLocks'))
if (holding) return holding
const pending = compactJsonList(kv(statusRows, 'pendingLocks'))
if (pending) return `${pending}`
const siteRaw = fields?.siteId || kv(statusRows, 'siteID', 'siteId')
const siteId = Number(siteRaw)
if (Number.isFinite(siteId) && siteId > 0) return `#${siteId}`
return '—'
}
export function formatAlarmInfo(statusRows?: Kv[] | null, lstatus?: string): { text: string; level: number } {
const levelRaw = Number(kv(statusRows, '车体_AlarmLevel', 'AlarmLevel'))
const level = Number.isFinite(levelRaw) ? levelRaw : 0
const info = kv(statusRows, '车体_AlarmInfo', 'AlarmInfo').trim()
if (info && !isEmptyStatus(info) && info !== '0') {
return { text: info, level: level > 0 ? level : 1 }
}
if (lstatus && /故障|报警|异常|失联|超时|检修/i.test(lstatus)) {
return { text: lstatus, level: Math.max(level, 1) }
}
return { text: '', level }
}
export function applyRuntimeEnrichment(car: Car, statusRows?: Kv[] | null): Car { export function applyRuntimeEnrichment(car: Car, statusRows?: Kv[] | null): Car {
const socFromStatus = extractSocFromKv(statusRows) const socFromStatus = extractSocFromKv(statusRows)
let batterySoc: number let batterySoc: number
@@ -74,9 +188,18 @@ export function applyRuntimeEnrichment(car: Car, statusRows?: Kv[] | null): Car
batterySoc = parseSocNumber(car.batterySoc) ?? 0 batterySoc = parseSocNumber(car.batterySoc) ?? 0
} }
const alarm = formatAlarmInfo(statusRows, car.lstatus)
const siteRaw = kv(statusRows, 'siteID', 'siteId')
const siteIdNum = Number(siteRaw)
return { return {
...car, ...car,
batterySoc, batterySoc,
state: deriveCarState(car, statusRows) state: deriveCarState(car, statusRows),
alarmInfo: alarm.text || undefined,
alarmLevel: alarm.level || undefined,
trafficStatus: formatTrafficStatus(statusRows),
lockPoint: formatLockPoint(statusRows, { siteId: siteRaw }),
siteId: Number.isFinite(siteIdNum) && siteIdNum > 0 ? siteIdNum : undefined
} }
} }
File diff suppressed because it is too large Load Diff
@@ -63,7 +63,7 @@
type="button" type="button"
class="mm-toggle-btn" class="mm-toggle-btn"
:class="{ 'is-active': layoutMode === 'immersive' }" :class="{ 'is-active': layoutMode === 'immersive' }"
title="沉浸:全幅地图 + 底部抽屉" title="沉浸:指挥条在上、列表在下,中间为地图"
@click="setLayout('immersive')" @click="setLayout('immersive')"
>沉浸</button> >沉浸</button>
</div> </div>
@@ -81,7 +81,8 @@
:scope="auth.scope ?? 'Platform'" :scope="auth.scope ?? 'Platform'"
:token="auth.token ?? ''" :token="auth.token ?? ''"
:read-only="!!readOnly" :read-only="!!readOnly"
:canvas-only="true" :canvas-only="false"
:embed-ui="true"
:hide-toolbar="true" :hide-toolbar="true"
@pick="onPick" @pick="onPick"
@select="onSelect" @select="onSelect"
@@ -122,7 +123,7 @@
<button type="button" class="mm-sheet-toggle" @click="toggleSheet"> <button type="button" class="mm-sheet-toggle" @click="toggleSheet">
{{ sheetCollapsed ? '展开列表' : '收起列表' }} {{ sheetCollapsed ? '展开列表' : '收起列表' }}
</button> </button>
<span class="mm-sheet-hint">沉浸模式 · 底栏</span> <span class="mm-sheet-hint">沉浸 · 画布下方列表</span>
</div> </div>
<div v-show="!sheetCollapsed || layoutMode === 'dual'" class="mm-panel-list"> <div v-show="!sheetCollapsed || layoutMode === 'dual'" class="mm-panel-list">
@@ -171,10 +172,12 @@
<VehicleMonitorPanel <VehicleMonitorPanel
v-show="workbenchTab === 'vehicle'" v-show="workbenchTab === 'vehicle'"
table table
:lite="layoutMode === 'dual'"
:cars="cars" :cars="cars"
:missions="missions" :missions="missions"
:selected-id="selectedVehicleId" :selected-id="selectedVehicleId"
@select="onVehicleSelect" @select="onVehicleSelect"
@action-done="refreshAll"
/> />
<MissionListPanel <MissionListPanel
v-show="workbenchTab === 'mission'" v-show="workbenchTab === 'mission'"
@@ -361,7 +364,7 @@ function setLayout(mode: MonitorLayout) {
layoutMode.value = mode layoutMode.value = mode
try { localStorage.setItem(LAYOUT_KEY, mode) } catch { /* ignore */ } try { localStorage.setItem(LAYOUT_KEY, mode) } catch { /* ignore */ }
if (mode === 'immersive') { if (mode === 'immersive') {
floatPos.value = { right: 14, bottom: sheetCollapsed.value ? 56 : 280 } floatPos.value = { right: 14, bottom: 14 }
} else { } else {
floatPos.value = { right: 14, bottom: 14 } floatPos.value = { right: 14, bottom: 14 }
sheetCollapsed.value = false sheetCollapsed.value = false
@@ -370,9 +373,6 @@ function setLayout(mode: MonitorLayout) {
function toggleSheet() { function toggleSheet() {
sheetCollapsed.value = !sheetCollapsed.value sheetCollapsed.value = !sheetCollapsed.value
if (layoutMode.value === 'immersive' && floatPos.value.left === undefined) {
floatPos.value = { right: 14, bottom: sheetCollapsed.value ? 56 : 280 }
}
} }
let floatDragStartX = 0 let floatDragStartX = 0
@@ -419,10 +419,7 @@ function onFloatDragEnd() {
watch(selection, (next, prev) => { watch(selection, (next, prev) => {
// 新开选中时若尚未拖过,回到默认右下角(沉浸模式抬高避开底栏) // 新开选中时若尚未拖过,回到默认右下角(沉浸模式抬高避开底栏)
if (next && !prev && floatPos.value.left === undefined) { if (next && !prev && floatPos.value.left === undefined) {
floatPos.value = { floatPos.value = { right: 14, bottom: 14 }
right: 14,
bottom: layoutMode.value === 'immersive' ? (sheetCollapsed.value ? 56 : 280) : 14
}
} }
}) })
@@ -949,7 +946,8 @@ onUnmounted(() => {
<style scoped> <style scoped>
/* Option AOutpost 浅色 chrome + 品牌紫;画布/浮层保持深色指挥视口 */ /* Option AOutpost 浅色 chrome + 品牌紫;画布/浮层保持深色指挥视口 */
.map-monitor-page { .map-monitor-page {
--mm-rail-w: 380px; /* 双栏精简列:车辆/状态/电量/任务/交管;沉浸底栏仍用满宽完整列 */
--mm-rail-w: 420px;
--mm-sheet-h: min(34vh, 340px); --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);
@@ -1175,7 +1173,7 @@ onUnmounted(() => {
.layout-dual .mm-panel { .layout-dual .mm-panel {
flex: 0 0 var(--mm-rail-w); flex: 0 0 var(--mm-rail-w);
width: var(--mm-rail-w); width: var(--mm-rail-w);
min-width: 300px; min-width: 360px;
min-height: 0; min-height: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -1204,51 +1202,39 @@ onUnmounted(() => {
border-top: 1px solid var(--mm-border); border-top: 1px solid var(--mm-border);
} }
/* —— C 沉浸 —— */ /* —— C 沉浸:指挥条在上、列表在下,中间画布(不遮挡 iframe 内外工具条) —— */
.layout-immersive .mm-command-bar {
position: absolute;
top: 8px;
left: 8px;
right: 8px;
background: rgba(255, 254, 253, 0.94);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow:
0 1px 2px rgba(40, 33, 58, 0.06),
0 12px 28px rgba(54, 35, 78, 0.14);
}
.layout-immersive .mm-workspace { .layout-immersive .mm-workspace {
flex: 1; flex: 1;
gap: 0; min-height: 0;
flex-direction: column;
gap: 8px;
} }
.layout-immersive .mm-canvas { .layout-immersive .mm-canvas {
position: absolute; position: relative;
inset: 0; flex: 1 1 auto;
min-height: 0;
border-radius: var(--mm-radius); border-radius: var(--mm-radius);
z-index: 1;
} }
.layout-immersive .mm-panel { .layout-immersive .mm-panel {
position: absolute; position: relative;
left: 8px; flex: 0 0 var(--mm-sheet-h);
right: 8px;
bottom: 8px;
height: var(--mm-sheet-h); height: var(--mm-sheet-h);
z-index: 8; max-height: var(--mm-sheet-h);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
border-radius: 14px; border-radius: var(--mm-radius);
border: 1px solid var(--mm-border); border: 1px solid var(--mm-border);
background: rgba(255, 254, 253, 0.96); background: var(--mm-surface);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
box-shadow: box-shadow:
0 -4px 20px rgba(54, 35, 78, 0.16), 0 1px 2px rgba(40, 33, 58, 0.04),
0 1px 0 rgba(255, 255, 255, 0.6) inset; 0 10px 26px rgba(54, 35, 78, 0.08);
transition: height 0.22s var(--mm-ease); 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 { .layout-immersive.sheet-collapsed .mm-panel {
flex-basis: 44px;
height: 44px; height: 44px;
max-height: 44px;
} }
.layout-immersive .mm-panel-list { .layout-immersive .mm-panel-list {
flex: 1; flex: 1;
@@ -1565,7 +1551,7 @@ onUnmounted(() => {
} }
@media (max-width: 1100px) { @media (max-width: 1100px) {
.map-monitor-page { --mm-rail-w: 320px; } .map-monitor-page { --mm-rail-w: 360px; }
.mm-clock { display: none; } .mm-clock { display: none; }
.mm-pill--muted { display: none; } .mm-pill--muted { display: none; }
} }