fix: improve map editor and monitor operations
This commit is contained in:
+2
-2
@@ -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=ForceStop,Clumsy=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 后台尽力发送(不挡首屏)。
|
||||
// embed 必须先 declare:migu 默认是 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 就绪 ...'
|
||||
|
||||
+973
-180
File diff suppressed because it is too large
Load Diff
+108
-62
@@ -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>
|
||||
|
||||
+44
-14
@@ -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,18 +642,16 @@ 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)
|
||||
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)
|
||||
label: customFieldDisplayLabel(f.key, chinese || '—')
|
||||
}
|
||||
})
|
||||
)
|
||||
@@ -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>
|
||||
|
||||
+3
-30
@@ -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 } {
|
||||
|
||||
+386
-80
@@ -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
|
||||
<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"
|
||||
type="button"
|
||||
class="table-row"
|
||||
:class="{ 'is-selected': car.id === selectedId }"
|
||||
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)"
|
||||
>
|
||||
<span class="col-id" :title="car.name">{{ shortId(car) }}</span>
|
||||
<span class="col-state">
|
||||
<span class="state-dot" :class="`dot-${car.state}`" />
|
||||
<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>
|
||||
<span class="col-bat" :class="batteryLevel(batteryPct(car))">{{ batteryText(car) }}</span>
|
||||
<span class="col-pos" :title="positionText(car)">{{ positionText(car) }}</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 有车 →「被 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=无阻塞;[]=阻塞但无车 id;number[]=阻塞车 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 A:Outpost 浅色 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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user