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-wal
MiGu.Server/data/.internal-token
# OTA 运行时产物:拉取/上传包、任务、历史、settings(子目录*.json 上面那行不递归)
# OTA 运行时产物:拉取/上传包、任务、历史、settings(子目录 *.json 上面那行不递归)
MiGu.Server/data/ota/
# 前端构建产物
@@ -32,7 +32,7 @@ frontends/apps/simple-platform-vue/auto-imports.d.ts
*.swp
Thumbs.db
Desktop.ini
/.cursor/rules
/.cursor/
/MiGu.Server/wwwroot
/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)
{
// 仅允许内部迁移调用方传入的标识符;拒绝注入用分隔符/空白
// 仅允许内部迁移调用方传入的标识符;definition 可含 GUID 默认值连字符
static bool IsSafeIdent(string s) =>
!string.IsNullOrEmpty(s) && s.All(ch => char.IsAsciiLetterOrDigit(ch) || ch == '_');
if (!IsSafeIdent(table) || !IsSafeIdent(column)
|| 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");
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 VehicleQuickAction =
| 'online'
| 'offline'
| 'endTask'
| 'disable'
| 'enable'
const METHOD_MAP: Record<VehicleMaintenanceMode, string> = {
online: 'OnlineCar',
offline: 'OfflineCar',
@@ -9,6 +17,29 @@ const METHOD_MAP: Record<VehicleMaintenanceMode, string> = {
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(
carId: number,
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 {
const target = url ?? (ip ? `http://${ip}:8081` : null)
if (!target) return
@@ -189,8 +189,14 @@ async function prepareAndLoad(forceReload = false) {
const embedded = needsDeclare()
if (embedded) {
// 并行:iframe 立即加载,declare 后台尽力发送(不挡首屏)
void postDeclareBestEffort()
// embed 必须先 declaremigu 默认是 canvas-only,迟到的 /declareEmbedUi 会被跳过
// canvas-only 与默认一致,可后台尽力发送不挡首屏。
if (props.embedUi) {
await postDeclareBestEffort()
if (generation !== prepareGeneration) return
} else {
void postDeclareBestEffort()
}
iframeSrc.value = buildIframeSrc(forceReload || !!iframeSrc.value)
} else {
loadingHint.value = '正在等待 SimpleLite webVRender 就绪 ...'
File diff suppressed because it is too large Load Diff
@@ -257,6 +257,7 @@ async function send() {
</script>
<style scoped>
/* Shares --ai-* tokens with AiAssistantDrawer (A/E paper · C amethyst) */
.ai-assistant-panel {
position: absolute;
top: 0;
@@ -265,11 +266,10 @@ async function send() {
z-index: 30;
display: flex;
flex-direction: column;
/* 实底(高不透明),解决「太透明看不清」 */
background: linear-gradient(180deg, rgba(28, 12, 56, 0.98) 0%, rgba(16, 6, 34, 0.99) 100%);
border-left: 1px solid rgba(190, 140, 240, 0.28);
box-shadow: -10px 0 30px rgba(8, 2, 16, 0.55);
color: rgba(236, 224, 250, 0.95);
background: var(--ai-panel-bg);
border-left: 1px solid var(--ai-panel-border);
box-shadow: var(--ai-panel-shadow);
color: var(--ai-bot-fg);
transform: translateX(100%);
opacity: 0;
visibility: hidden;
@@ -281,7 +281,6 @@ async function send() {
visibility: visible;
}
/* 左沿拖拽手柄:覆盖在 border-left 上方,hover 高亮提示可拖拽。 */
.aap-resizer {
position: absolute;
left: 0;
@@ -294,7 +293,7 @@ async function send() {
transition: background 0.15s ease;
touch-action: none;
}
.aap-resizer:hover { background: rgba(190, 140, 240, 0.45); }
.aap-resizer:hover { background: var(--ai-accent-soft); }
.aap-header {
flex: none;
@@ -302,35 +301,46 @@ async function send() {
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 14px;
background: linear-gradient(135deg, rgba(120, 70, 220, 0.5) 0%, rgba(255, 90, 200, 0.4) 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
padding: 14px 16px;
background: var(--ai-head-bg);
border-bottom: 1px solid var(--ai-head-border);
}
.aap-title { display: flex; align-items: center; gap: 10px; min-width: 0; }
.aap-glyph {
font-size: 20px;
color: #fff;
text-shadow: 0 0 10px rgba(255, 200, 250, 0.7);
width: 32px;
height: 32px;
border-radius: 10px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 16px;
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-main { font-size: 15px; font-weight: 700; color: #fff; line-height: 1.2; }
.aap-title-sub { font-size: 11px; color: rgba(240, 222, 255, 0.78); margin-top: 2px; }
.aap-title-main { font-size: 15px; font-weight: 700; color: var(--ai-title); line-height: 1.2; }
.aap-title-sub { font-size: 11px; color: var(--ai-muted); margin-top: 2px; }
.aap-close {
appearance: none;
border: 0;
background: rgba(255, 255, 255, 0.12);
color: #fff;
width: 26px;
height: 26px;
border-radius: 6px;
border: 1px solid transparent;
background: transparent;
color: var(--ai-muted);
width: 28px;
height: 28px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
line-height: 1;
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; }
@@ -338,30 +348,33 @@ async function send() {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
padding: 12px;
padding: 14px;
display: flex;
flex-direction: column;
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-title { font-size: 12px; color: rgba(210, 188, 240, 0.7); }
.aap-empty-title { font-size: 12px; color: var(--ai-muted); }
.aap-example {
appearance: none;
text-align: left;
border: 1px dashed rgba(190, 140, 240, 0.4);
background: rgba(255, 255, 255, 0.04);
color: rgba(232, 215, 245, 0.9);
border-radius: 8px;
padding: 8px 10px;
border: 1px solid var(--ai-example-border);
background: var(--ai-example-bg);
color: var(--ai-example-fg);
border-radius: 10px;
padding: 9px 11px;
font-size: 12.5px;
cursor: pointer;
transition: all 0.15s ease;
transition: border-color 0.15s ease, transform 0.15s ease;
}
.aap-example:hover {
background: rgba(150, 90, 230, 0.22);
border-color: rgba(190, 140, 240, 0.7);
color: #fff;
border-color: var(--ai-accent);
transform: translateY(-1px);
}
.aap-msg { display: flex; }
@@ -369,38 +382,38 @@ async function send() {
.aap-msg--assistant { justify-content: flex-start; }
.aap-bubble {
max-width: 86%;
padding: 8px 11px;
border-radius: 12px;
padding: 9px 12px;
border-radius: 14px;
font-size: 13px;
line-height: 1.5;
line-height: 1.55;
}
.aap-msg--user .aap-bubble {
background: linear-gradient(135deg, rgba(150, 90, 240, 0.95) 0%, rgba(120, 70, 220, 0.95) 100%);
color: #fff;
background: var(--ai-user-bg);
color: var(--ai-user-fg);
border-bottom-right-radius: 4px;
}
.aap-msg--assistant .aap-bubble {
background: rgba(255, 255, 255, 0.07);
border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(236, 224, 250, 0.95);
background: var(--ai-bot-bg);
border: 1px solid var(--ai-bot-border);
color: var(--ai-bot-fg);
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-meta {
margin-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;
color: rgba(210, 188, 240, 0.8);
color: var(--ai-muted);
}
.aap-meta b { color: var(--mg-accent, #c4a4ff); }
.aap-meta-ic { color: #6ee7b7; margin-right: 4px; vertical-align: -2px; }
.aap-meta b { color: var(--ai-accent); }
.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: rgba(236, 224, 250, 0.95); }
.aap-md { font-size: 13px; line-height: 1.6; word-break: break-word; color: var(--ai-bot-fg); }
.aap-md :deep(p) { margin: 0 0 7px; }
.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(h4.mmd-h) { font-size: 13px; }
.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) {
font-family: var(--mg-font-mono, monospace);
font-size: 12px;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.1);
background: var(--ai-md-code-bg);
color: var(--ai-md-code-fg);
border: 1px solid var(--ai-example-border);
border-radius: 5px;
padding: 1px 5px;
}
.aap-md :deep(.mmd-pre) {
margin: 6px 0;
padding: 9px 11px;
background: rgba(0, 0, 0, 0.42);
border: 1px solid rgba(255, 255, 255, 0.1);
background: var(--ai-md-pre-bg);
border: 1px solid var(--ai-md-pre-border);
border-radius: 8px;
overflow: auto;
max-height: 280px;
@@ -425,22 +439,24 @@ async function send() {
.aap-md :deep(.mmd-pre code) {
font-family: var(--mg-font-mono, monospace);
font-size: 12px;
color: rgba(220, 230, 245, 0.95);
color: var(--ai-bot-fg);
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) {
margin: 6px 0;
padding: 3px 10px;
border-left: 3px solid rgba(190, 140, 240, 0.6);
color: rgba(220, 205, 250, 0.85);
border-left: 3px solid var(--ai-md-blockquote-border);
color: var(--ai-md-blockquote-fg);
}
.aap-md :deep(strong) { color: #fff; }
.aap-md :deep(strong) { color: var(--ai-title); }
.aap-bubble--loading {
display: inline-flex;
align-items: center;
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 {
@@ -448,21 +464,51 @@ async function send() {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px 0;
padding: 8px 14px 0;
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) {
padding: 5px 9px;
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 {
flex: none;
padding: 8px 12px 12px;
padding: 8px 14px 14px;
display: flex;
flex-direction: column;
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.el-button--primary {
background: var(--ai-send-bg) !important;
border-color: transparent !important;
color: var(--ai-send-fg) !important;
}
</style>
@@ -166,7 +166,8 @@
<button
class="prop-add-field-btn"
:disabled="!addableFieldOptions.length"
:disabled="fieldCatalogLoading"
type="button"
@click="openAddFieldDialog"
>
+ 添加字段
@@ -180,12 +181,24 @@
width="420px"
append-to-body
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
v-model="addFieldPickKey"
filterable
placeholder="选择要添加的字段"
allow-create
default-first-option
clearable
placeholder="选择或输入字段名"
class="add-field-select"
teleported
>
<el-option
v-for="opt in addableFieldOptions"
@@ -196,7 +209,7 @@
</el-select>
<template #footer>
<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>
</template>
@@ -525,9 +538,10 @@ watch(
)
watch(
() => props.selectionKind,
(k) => {
if (k === 'site' || k === 'track') tab.value = 'defaults'
() => [props.selectionKind, props.primary?.id] as const,
([k, id]) => {
// 选中站点 / 路径 / 车辆时默认停在「对象」页;类型默认仅用户主动点开。
if (id != null && (k === 'site' || k === 'track' || k === 'car')) tab.value = 'object'
}
)
@@ -607,6 +621,8 @@ const carTypeRef = computed(() => props.carType ?? null)
const {
loading: fieldCatalogLoading,
records: fieldCatalogRecords,
lastError: fieldCatalogError,
reload: reloadFieldCatalog,
labelForKey,
defaultValueForKey
} = useSimpleFieldCatalog(selectionKindRef, carTypeRef)
@@ -626,20 +642,18 @@ function customFieldDisplayLabel(key: string, chinese: string): string {
return `${key} - ${cn}`
}
/** 展示已在对象上存在、且在 simple_fields 目录中的扩展字段 */
/** 展示对象上已有的扩展字段;目录有中文名则带上,目录外字段仍可编辑 */
const customFieldRows = computed(() =>
otherFields.value
.filter((f) => catalogKeySet.value.has(f.key))
.map((f) => {
const chinese = labelForKey(f.key)
return {
key: f.key,
value: f.value,
dataType: catalogDataTypeByKey.value.get(f.key) ?? '',
chinese,
label: customFieldDisplayLabel(f.key, chinese)
}
})
otherFields.value.map((f) => {
const chinese = catalogKeySet.value.has(f.key) ? labelForKey(f.key) : ''
return {
key: f.key,
value: f.value,
dataType: catalogDataTypeByKey.value.get(f.key) ?? '',
chinese,
label: customFieldDisplayLabel(f.key, chinese || '')
}
})
)
const addableFieldOptions = computed(() => {
@@ -714,14 +728,23 @@ function onCustomFieldCommit(key: string, value: string) {
onFieldChange(key, value)
}
function openAddFieldDialog() {
async function openAddFieldDialog() {
await reloadFieldCatalog()
addFieldPickKey.value = addableFieldOptions.value[0]?.key ?? ''
addFieldDialogOpen.value = true
}
function confirmAddField() {
const key = addFieldPickKey.value
const key = addFieldPickKey.value.trim()
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)
customFieldDrafts.value = { ...customFieldDrafts.value, [key]: value }
addFieldDialogOpen.value = false
@@ -1276,6 +1299,13 @@ function onDelete() {
0 0 0 3px rgba(var(--mg-primary-rgb), 0.14) !important;
}
.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 {
@@ -13,14 +13,14 @@
吸附<b :class="{ on: snapOn, off: !snapOn }">{{ snapOn ? '开' : '关' }}</b>
</span>
<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-else class="seg warn"> 实时通道断开</span>
</div>
</template>
<script setup lang="ts">
defineProps<{
withDefaults(defineProps<{
selectedCount: number
mouseX: number
mouseY: number
@@ -28,8 +28,11 @@ defineProps<{
snapOn: boolean
undoSize: number
redoSize: number
undoLimit?: number
connected: boolean
}>()
}>(), {
undoLimit: 20
})
function formatXY(v: number) {
if (!Number.isFinite(v)) return '--'
@@ -50,6 +50,8 @@ interface ToolGroup { id: string; title: string; tools: ToolDef[] }
const props = defineProps<{
activeTool: EditToolId
/** 左侧「仅*」互斥过滤模式;与 activeTool 独立,可与绘制工具同时生效。 */
activeSelectOnly?: 'site' | 'straight' | 'curve' | 'decor' | null
}>()
const emit = defineEmits<{
@@ -62,17 +64,10 @@ const groups: ToolGroup[] = [
id: 'select',
title: '选择',
tools: [
{ id: 'select.single', label: '单选', glyph: '', tip: '点击拾取单个对象' },
{ id: 'select.rect', label: '矩形框选', glyph: '' },
{ id: 'select.lasso', label: '套索圈选', glyph: '' },
{ id: 'select.byType.site', label: '仅站点', glyph: '·' },
{ 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: '⊘' }
{ id: 'select.byType.site', label: '仅站点', glyph: '·', tip: '画布只能选中站点;再点一次取消' },
{ id: 'select.byType.straight', label: '仅直线', glyph: '─', tip: '仅 UITrack(直线/折线);再点一次取消' },
{ id: 'select.byType.curve', label: '仅曲线', glyph: '∽', tip: '贝塞尔 / 弧 / NURBS;再点一次取消' },
{ id: 'select.byType.decor', label: '仅装饰', glyph: '', tip: '画布只能选中装饰;再点一次取消' }
]
},
{
@@ -80,29 +75,23 @@ const groups: ToolGroup[] = [
title: '绘制',
tools: [
{ id: 'draw.site', label: '添加站点', glyph: '', tip: '点击工具后在画布单击放置' },
{ id: 'draw.sites.continuous', label: '连续站点', glyph: '⁂', tip: '切换工具或 Esc 退出' },
{ id: 'draw.track.polyline', label: '线路径', glyph: '─', tip: '依序点击两端站点' },
{ id: 'draw.track.bezier', label: '贝塞尔路径', glyph: '', tip: '点两端站点 → 弹框输入中间控制点个数 → 连续点选每个控制点' },
{ id: 'draw.track.arc', label: '弧形路径', glyph: '', tip: '点两端站点 → 输入半径(≥ |AB|/2,等于即半圆)' },
{ id: 'draw.decor.line', label: '装饰线段', glyph: '' },
{ id: 'draw.decor.rect', label: '装饰矩形', glyph: '' },
{ id: 'draw.decor.circle', label: '装饰圆', glyph: '' },
{ id: 'draw.decor.text', label: '装饰文本', glyph: 'T' },
{ id: 'draw.image', label: '插入图片', glyph: '🖼' },
{ id: 'draw.model', label: '插入模型', glyph: '◎', tip: 'CycleGUI 3D 模型 (obj/glb/gltf)' }
{ id: 'draw.sites.continuous', label: '连续站点', glyph: '⁂', tip: '切换工具或右键退出' },
{ id: 'draw.track.polyline', label: '线路径', glyph: '─', tip: '依序点击两端站点' },
{ id: 'draw.tracks.continuous', label: '连续直线', glyph: '', tip: '连续点两端站点建直线;右键退出' },
{ id: 'draw.track.bezier', label: '贝塞尔路径', glyph: '', tip: '点两端站点 → 输入中间控制点个数 → 连续点选' },
{ id: 'draw.track.arc', label: '弧形路径', glyph: '◜', tip: '点两端站点 → 输入半径(≥ |AB|/2)' },
{ id: 'draw.decor.text', label: '装饰文本', glyph: 'T', tip: '输入内容后在画布单击落点' },
{ id: 'draw.image', label: '插入图片', glyph: '🖼', tip: '选择图片后在画布单击落点' }
]
},
{
id: 'transform',
title: '变换',
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.paste', label: '粘贴', glyph: '📥', tip: 'Ctrl+V' },
{ id: 'transform.delete', label: '删除', glyph: '🗑', tip: 'Delete' },
{ id: 'transform.copyFields', label: '复制字段', glyph: '', tip: '多选时第 1 个为源,其余为目标' }
{ id: 'transform.paste', label: '粘贴', glyph: '📥', tip: 'Ctrl+V,相对原位置偏移 200mm' },
{ id: 'transform.duplicate', label: '复制副本', glyph: '', tip: 'Ctrl+D' },
{ id: 'transform.delete', label: '删除', glyph: '🗑', tip: 'Delete' }
]
},
{
@@ -111,7 +100,7 @@ const groups: ToolGroup[] = [
tools: [
{ id: 'align.left', label: '左对齐', glyph: '⫷' },
{ id: 'align.right', label: '右对齐', glyph: '⫸' },
{ id: 'align.top', label: '上对齐', glyph: '' },
{ id: 'align.top', label: '上对齐', glyph: '' },
{ id: 'align.bottom', label: '下对齐', glyph: '⫶' },
{ id: 'align.centerH', 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) }
</script>
@@ -16,7 +16,6 @@
<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.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.svg">导出 SVG</el-dropdown-item>
<el-dropdown-item command="file.export.json">导出 JSON</el-dropdown-item>
@@ -25,109 +24,67 @@
</el-dropdown>
<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>
<el-dropdown-menu>
<el-dropdown-item command="edit.cut">剪切 (Ctrl+X)</el-dropdown-item>
<el-dropdown-item command="edit.copy">复制 (Ctrl+C)</el-dropdown-item>
<el-dropdown-item command="edit.paste">粘贴 (Ctrl+V)</el-dropdown-item>
<el-dropdown-item command="edit.delete">删除 (Del)</el-dropdown-item>
<el-dropdown-item command="edit.selectAll" divided>全选</el-dropdown-item>
<el-dropdown-item command="edit.invert">反选</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-item command="edit.undo" :disabled="!canUndo">
撤销 (Ctrl+Z) · {{ undoCount }}/{{ undoLimit }}
</el-dropdown-item>
<el-dropdown-item command="edit.redo" :disabled="!canRedo">
重做 (Ctrl+Y) · {{ redoCount }}
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<!-- 编辑 / 视图 / 图层 / 过滤 已从顶栏移除过滤由左侧* MapEditorView 驱动 -->
<div class="spacer" />
<el-tooltip content="撤销 (Ctrl+Z)" placement="bottom">
<el-button size="small" :disabled="!canUndo" class="topbar-icon-btn" @click="onCmd('edit.undo')"></el-button>
<el-tooltip :content="`撤销 (Ctrl+Z) · ${undoCount}/${undoLimit}`" placement="bottom">
<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 content="重做 (Ctrl+Y)" placement="bottom">
<el-button size="small" :disabled="!canRedo" class="topbar-icon-btn" @click="onCmd('edit.redo')"></el-button>
<el-tooltip :content="`重做 (Ctrl+Y) · ${redoCount}`" placement="bottom">
<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>
</div>
</template>
<script setup lang="ts">
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 }
showViewport: { sceneLabels: boolean; scenePrimitives: boolean; cars: boolean }
}
defineProps<{
withDefaults(defineProps<{
canUndo: boolean
canRedo: boolean
saving?: boolean
// 由 MapEditorView 注入:「过滤」菜单的三组开关状态。父组件保证非空。
viewFilter: ViewFilterState
}>()
undoCount?: number
redoCount?: number
undoLimit?: number
/** 保留类型兼容;顶栏「过滤」菜单已移除。 */
viewFilter?: ViewFilterState
}>(), {
undoCount: 0,
redoCount: 0,
undoLimit: 20
})
const emit = defineEmits<{
(e: 'cmd', cmd: string): void
@@ -136,10 +93,6 @@ const emit = defineEmits<{
function onCmd(cmd: string) {
emit('cmd', cmd)
}
function mark(on: boolean): string {
return on ? '☑' : '☐'
}
</script>
<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);
}
.spacer { flex: 1; }
.el-divider--vertical { margin: 0 6px; background: var(--me-border); }
.topbar-btn {
color: var(--me-text) !important;
@@ -169,23 +121,43 @@ function mark(on: boolean): string {
color: var(--mg-primary) !important;
background: var(--me-hover) !important;
}
.topbar-btn:focus {
background: var(--me-hover) !important;
.topbar-count {
margin-left: 6px;
font-size: 11px;
font-weight: 500;
opacity: 0.72;
font-variant-numeric: tabular-nums;
}
.topbar-icon-btn {
position: relative;
color: var(--me-text) !important;
background: var(--me-surface) !important;
border: 1px solid var(--me-border) !important;
font-size: 15px;
min-width: 36px;
}
.topbar-icon-btn:hover:not(.is-disabled) {
background: var(--me-hover) !important;
border-color: rgba(var(--mg-primary-rgb), 0.55) !important;
color: var(--mg-primary) !important;
}
.topbar-icon-btn.is-disabled {
opacity: 0.4;
.topbar-icon-btn.is-disabled { 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 {
@@ -204,15 +176,4 @@ function mark(on: boolean): string {
--el-button-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>
@@ -217,6 +217,7 @@ import {
extractSocFromKv,
socToPercent
} from '@/utils/batterySoc'
import { formatTrafficStatus as formatTrafficStatusText } from '@/utils/carRuntime'
import type { Car } from '@/types/car'
import type { Mission } from '@/types/mission'
import type { SelectedObjectRef } from '@/types/workbench'
@@ -406,36 +407,8 @@ function formatCarPosition(): string {
}
function formatTrafficStatus(): string {
const tc = pickStatus('TCStat') || pickField('TCStat')
if (tc && tc !== '/' && tc.trim() !== '') return tc
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 '正常'
// 与车体浮窗 / 车辆列表同一口径(畅通 · 正常拿锁 · 被 X 车阻塞)
return formatTrafficStatusText(statusRows.value)
}
function formatAlarmStatus(): { text: string; warn: boolean } {
@@ -1,5 +1,5 @@
<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 class="overview-item">
<div class="overview-num">{{ cars.length }}</div>
@@ -28,7 +28,7 @@
v-model="search"
size="small"
clearable
:placeholder="table ? '搜索车辆编号/名称' : '搜索 ID / 名称 / 任务'"
:placeholder="tablePlaceholder"
class="search"
/>
<el-select
@@ -65,29 +65,92 @@
</div>
<div v-if="table" class="table-wrap">
<div class="table-head" aria-hidden="true">
<span class="col-id">车辆编号</span>
<span class="col-state">状态</span>
<span class="col-bat">电量</span>
<span class="col-pos">当前位置</span>
</div>
<div class="vehicle-list table-body">
<button
v-for="car in filteredCars"
:key="car.id"
type="button"
class="table-row"
:class="{ 'is-selected': car.id === selectedId }"
@click="onSelect(car)"
>
<span class="col-id" :title="car.name">{{ shortId(car) }}</span>
<span class="col-state">
<span class="state-dot" :class="`dot-${car.state}`" />
{{ stateLabel(car.state) }}
</span>
<span class="col-bat" :class="batteryLevel(batteryPct(car))">{{ batteryText(car) }}</span>
<span class="col-pos" :title="positionText(car)">{{ positionText(car) }}</span>
</button>
<div class="dense-scroll">
<table class="dense-table" aria-label="车辆列表">
<thead>
<tr>
<th class="col-vehicle">车辆</th>
<th class="col-state">状态</th>
<th class="col-bat">电量</th>
<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"
:key="car.id"
class="dense-row"
:class="{
'is-selected': car.id === selectedId,
'has-alarm': !lite && hasAlarm(car),
'is-busy': !lite && !!actingId && actingId === carKey(car)
}"
tabindex="0"
@click="onSelect(car)"
@keydown.enter.prevent="onSelect(car)"
>
<td class="col-vehicle">
<div class="veh-cell">
<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) }}
</span>
</td>
<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>
<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>
</div>
@@ -157,9 +220,17 @@
<script setup lang="ts">
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 { Mission, MissionStatus } from '@/types/mission'
import type { SelectedObjectRef } from '@/types/workbench'
import {
VEHICLE_QUICK_ACTIONS,
executeVehicleQuickAction,
type VehicleQuickAction
} from '@/api/vehicleOps'
import { isTrafficCongested } from '@/utils/carRuntime'
const props = defineProps<{
cars: Car[]
@@ -167,14 +238,27 @@ const props = defineProps<{
selectedId?: string | null
/** 紧凑模式:隐藏顶部概览条 */
compact?: boolean
/** 表格模式:筛选芯片 + 四列表格(地图监控参考布局 */
/** 表格模式:筛选芯片 + 紧凑数据表(地图监控) */
table?: boolean
/** 双栏精简:隐藏报警 / 锁点 / 操作列 */
lite?: boolean
}>()
const emit = defineEmits<{
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 filterState = ref<CarState | ''>('')
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)}`
}
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 q = search.value.trim().toLowerCase()
const tokens = q ? q.split(/\s+/).filter(Boolean) : []
@@ -346,6 +487,11 @@ const filteredCars = computed(() => {
c.typeName ?? '',
c.group ?? '',
c.ip ?? '',
c.lstatus ?? '',
c.alarmInfo ?? '',
c.trafficStatus ?? '',
c.lockPoint ?? '',
c.siteId != null ? String(c.siteId) : '',
stateLabel(c.state),
mission?.name ?? '',
mission ? missionStatusLabel(mission.status) : '',
@@ -365,6 +511,51 @@ function detailIdForCar(car: Car): string {
if (car.rawId != null) return String(car.rawId)
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>
<style scoped>
@@ -377,7 +568,12 @@ function detailIdForCar(car: Car): string {
overflow: hidden;
}
.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 {
border-radius: 8px;
@@ -450,7 +646,7 @@ function detailIdForCar(car: Car): string {
color: var(--mg-text-muted);
font-size: 11px;
font-weight: 550;
height: 26px;
height: 24px;
padding: 0 8px;
border-radius: 6px;
cursor: pointer;
@@ -480,80 +676,190 @@ function detailIdForCar(car: Car): string {
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--mg-veil-border);
border-radius: 8px;
background: #fff;
}
.table-head,
.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 {
.dense-scroll {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
gap: 0;
padding-right: 0;
overflow: auto;
}
.table-row {
appearance: none;
.dense-table {
width: 100%;
border: 0;
border-bottom: 1px solid var(--mg-veil-border);
background: transparent;
color: var(--mg-text-light);
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
font-size: 12px;
}
.dense-table thead th {
position: sticky;
top: 0;
z-index: 2;
height: 30px;
padding: 0 8px;
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-weight: 650;
color: var(--mg-text-muted);
background: var(--mg-veil-1, #f6f3fb);
border-bottom: 1px solid var(--mg-veil-border);
white-space: nowrap;
}
.col-bat {
font-family: var(--mg-font-mono);
font-variant-numeric: tabular-nums;
font-size: 11px;
font-weight: 600;
.dense-row {
cursor: pointer;
transition: background 0.12s ease;
}
.col-bat.low { color: var(--mg-status-danger); }
.col-bat.mid { color: var(--mg-status-warning); }
.col-bat.high { color: var(--mg-status-success); }
.col-pos {
font-size: 11px;
color: var(--mg-text-dim);
.dense-row td {
height: 40px;
padding: 4px 8px;
vertical-align: middle;
border-bottom: 1px solid rgba(40, 33, 58, 0.06);
color: var(--mg-text-light);
overflow: hidden;
text-overflow: ellipsis;
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 {
width: 6px;
height: 6px;
@@ -46,6 +46,7 @@ export type EditToolId =
| 'transform.scale'
| 'transform.copy'
| 'transform.paste'
| 'transform.duplicate'
| 'transform.delete'
| 'transform.copyFields'
// 对齐组
@@ -136,6 +137,7 @@ export function useEditTool() {
'transform.scale': '缩放',
'transform.copy': '复制',
'transform.paste': '粘贴',
'transform.duplicate': '复制副本',
'transform.delete': '删除',
'transform.copyFields': '复制字段',
'align.left': '左对齐',
@@ -50,7 +50,7 @@ export interface SelectionDetailEvent {
}
export interface WorkspaceShortcutEvent {
action: 'undo' | 'redo' | 'delete'
action: 'undo' | 'redo' | 'delete' | 'copy' | 'paste' | 'duplicate'
}
export interface MapEditStreamHandlers {
@@ -43,6 +43,7 @@ export function useSimpleFieldCatalog(
) {
const loading = ref(false)
const records = ref<SimpleFieldRecord[]>([])
const lastError = ref<string | null>(null)
const fieldType = computed(() => fieldCategoryForMapKind(kind.value))
@@ -56,9 +57,11 @@ export function useSimpleFieldCatalog(
const ft = fieldType.value
if (!ft) {
records.value = []
lastError.value = null
return
}
loading.value = true
lastError.value = null
try {
// 车辆:为兼容 shortName / assembly.shortName 等不同 car_type 存储格式,先拉 carFields 再前端匹配当前车型。
if (ft === 'carFields') {
@@ -68,14 +71,22 @@ export function useSimpleFieldCatalog(
const rows = await listSimpleFields(ft)
records.value = dedupeByKey(rows)
}
} catch {
} catch (err) {
records.value = []
lastError.value = (err as Error)?.message || '字段目录加载失败'
} finally {
loading.value = false
}
}
watch(fieldType, () => { void reload() }, { immediate: true })
// 选中车后 typeName 异步到齐时需重滤 carFields
watch(
() => carType?.value,
() => {
if (fieldType.value === 'carFields') void reload()
}
)
function labelForKey(key: string): string {
const row = byKey.value.get(key)
@@ -89,6 +100,7 @@ export function useSimpleFieldCatalog(
return {
loading,
records,
lastError,
fieldType,
byKey,
reload,
@@ -82,15 +82,6 @@
<el-dropdown-menu>
<el-dropdown-item v-if="auth.scope === 'Platform'" command="wizard">配置向导</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-menu>
</template>
@@ -184,10 +175,6 @@ const activePath = computed(() => {
const currentTitle = computed(() => (route.meta.title as string | undefined) ?? '')
function onUserCommand(cmd: string) {
if (cmd.startsWith('theme:')) {
ui.setThemeId(cmd.slice('theme:'.length))
return
}
if (cmd === 'logout') {
auth.logout()
router.push('/login')
@@ -568,25 +555,3 @@ function onUserCommand(cmd: string) {
flex-direction: column;
}
</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 migrated = schema < THEME_SCHEMA_VERSION
if (migrated) {
// ponytail: 一次强制 outpost-light,兼容主题仍可从高级菜单切回
themeId = DEFAULT_THEME_ID
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 {
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 {
if (!id) return DEFAULT_THEME_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
}
@@ -23,6 +23,16 @@ export interface Car {
ip?: string
onboardUrl?: 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 */
@@ -12,6 +12,36 @@ function kv(rows: Kv[] | null | undefined, ...keys: string[]): string {
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
* / AlarmLevel lstatus + status
@@ -28,7 +58,7 @@ export function deriveCarState(car: Pick<Car, 'state' | 'lstatus'>, statusRows?:
const hasAlarm =
(Number.isFinite(alarmLevel) && alarmLevel > 0) ||
(/true/i.test(riskAlarm)) ||
(alarmInfo.length > 0 && !/^\/$|^-$|^none$/i.test(alarmInfo))
(alarmInfo.length > 0 && !isEmptyStatus(alarmInfo))
// 明确健康:以「正常」开头(含「正常但未初始化」)→ 绝不是故障
if (/^正常/.test(lstatus)) {
@@ -62,6 +92,90 @@ export function deriveCarState(car: Pick<Car, 'state' | 'lstatus'>, statusRows?:
return 'idle'
}
/**
* SimpleLite CarVisual.FormatTrafficStatus
* 1) TCStat BeforeLock
* 2) blockedBy XY
* 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 {
const socFromStatus = extractSocFromKv(statusRows)
let batterySoc: number
@@ -74,9 +188,18 @@ export function applyRuntimeEnrichment(car: Car, statusRows?: Kv[] | null): Car
batterySoc = parseSocNumber(car.batterySoc) ?? 0
}
const alarm = formatAlarmInfo(statusRows, car.lstatus)
const siteRaw = kv(statusRows, 'siteID', 'siteId')
const siteIdNum = Number(siteRaw)
return {
...car,
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"
class="mm-toggle-btn"
:class="{ 'is-active': layoutMode === 'immersive' }"
title="沉浸:全幅地图 + 底部抽屉"
title="沉浸:指挥条在上、列表在下,中间为地图"
@click="setLayout('immersive')"
>沉浸</button>
</div>
@@ -81,7 +81,8 @@
:scope="auth.scope ?? 'Platform'"
:token="auth.token ?? ''"
:read-only="!!readOnly"
:canvas-only="true"
:canvas-only="false"
:embed-ui="true"
:hide-toolbar="true"
@pick="onPick"
@select="onSelect"
@@ -122,7 +123,7 @@
<button type="button" class="mm-sheet-toggle" @click="toggleSheet">
{{ sheetCollapsed ? '展开列表' : '收起列表' }}
</button>
<span class="mm-sheet-hint">沉浸模式 · 底栏</span>
<span class="mm-sheet-hint">沉浸 · 画布下方列表</span>
</div>
<div v-show="!sheetCollapsed || layoutMode === 'dual'" class="mm-panel-list">
@@ -171,10 +172,12 @@
<VehicleMonitorPanel
v-show="workbenchTab === 'vehicle'"
table
:lite="layoutMode === 'dual'"
:cars="cars"
:missions="missions"
:selected-id="selectedVehicleId"
@select="onVehicleSelect"
@action-done="refreshAll"
/>
<MissionListPanel
v-show="workbenchTab === 'mission'"
@@ -361,7 +364,7 @@ function setLayout(mode: MonitorLayout) {
layoutMode.value = mode
try { localStorage.setItem(LAYOUT_KEY, mode) } catch { /* ignore */ }
if (mode === 'immersive') {
floatPos.value = { right: 14, bottom: sheetCollapsed.value ? 56 : 280 }
floatPos.value = { right: 14, bottom: 14 }
} else {
floatPos.value = { right: 14, bottom: 14 }
sheetCollapsed.value = false
@@ -370,9 +373,6 @@ function setLayout(mode: MonitorLayout) {
function toggleSheet() {
sheetCollapsed.value = !sheetCollapsed.value
if (layoutMode.value === 'immersive' && floatPos.value.left === undefined) {
floatPos.value = { right: 14, bottom: sheetCollapsed.value ? 56 : 280 }
}
}
let floatDragStartX = 0
@@ -419,10 +419,7 @@ function onFloatDragEnd() {
watch(selection, (next, prev) => {
//
if (next && !prev && floatPos.value.left === undefined) {
floatPos.value = {
right: 14,
bottom: layoutMode.value === 'immersive' ? (sheetCollapsed.value ? 56 : 280) : 14
}
floatPos.value = { right: 14, bottom: 14 }
}
})
@@ -949,7 +946,8 @@ onUnmounted(() => {
<style scoped>
/* Option AOutpost 浅色 chrome + 品牌紫;画布/浮层保持深色指挥视口 */
.map-monitor-page {
--mm-rail-w: 380px;
/* 双栏精简列:车辆/状态/电量/任务/交管;沉浸底栏仍用满宽完整列 */
--mm-rail-w: 420px;
--mm-sheet-h: min(34vh, 340px);
--mm-radius: 14px;
--mm-ease: cubic-bezier(0.16, 1, 0.3, 1);
@@ -1175,7 +1173,7 @@ onUnmounted(() => {
.layout-dual .mm-panel {
flex: 0 0 var(--mm-rail-w);
width: var(--mm-rail-w);
min-width: 300px;
min-width: 360px;
min-height: 0;
display: flex;
flex-direction: column;
@@ -1204,51 +1202,39 @@ onUnmounted(() => {
border-top: 1px solid var(--mm-border);
}
/* —— C 沉浸 —— */
.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);
}
/* —— C 沉浸:指挥条在上、列表在下,中间画布(不遮挡 iframe 内外工具条) —— */
.layout-immersive .mm-workspace {
flex: 1;
gap: 0;
min-height: 0;
flex-direction: column;
gap: 8px;
}
.layout-immersive .mm-canvas {
position: absolute;
inset: 0;
position: relative;
flex: 1 1 auto;
min-height: 0;
border-radius: var(--mm-radius);
z-index: 1;
}
.layout-immersive .mm-panel {
position: absolute;
left: 8px;
right: 8px;
bottom: 8px;
position: relative;
flex: 0 0 var(--mm-sheet-h);
height: var(--mm-sheet-h);
z-index: 8;
max-height: var(--mm-sheet-h);
display: flex;
flex-direction: column;
overflow: hidden;
border-radius: 14px;
border-radius: var(--mm-radius);
border: 1px solid var(--mm-border);
background: rgba(255, 254, 253, 0.96);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
background: var(--mm-surface);
box-shadow:
0 -4px 20px rgba(54, 35, 78, 0.16),
0 1px 0 rgba(255, 255, 255, 0.6) inset;
transition: height 0.22s var(--mm-ease);
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;
@@ -1565,7 +1551,7 @@ onUnmounted(() => {
}
@media (max-width: 1100px) {
.map-monitor-page { --mm-rail-w: 320px; }
.map-monitor-page { --mm-rail-w: 360px; }
.mm-clock { display: none; }
.mm-pill--muted { display: none; }
}