refactor(web): 工作台与监控视图 UI 优化
- MonitorSelectionPanel / VehicleMonitorPanel / SelectionDetailPanel 等工作台面板交互与展示优化 - carActionExecute:前往站点遇 404(旧 DLL 无 goto-site 路由)时回退 WebGotoSite,并给出热更新指引 - 地图监控、任务分配、反射管理等视图细节调整;theme.css 补充样式
This commit is contained in:
@@ -63,13 +63,13 @@ function run(t: CadTool) {
|
||||
<style scoped>
|
||||
.tool-card { cursor: pointer; text-align: center; padding: 8px; }
|
||||
.tool-name { font-weight: 600; margin-top: 6px; }
|
||||
.tool-desc { color: rgba(220, 200, 252, 0.55); font-size: 12px; margin-top: 4px; }
|
||||
.tool-desc { color: var(--mg-text-dim); font-size: 12px; margin-top: 4px; }
|
||||
.tool-icon {
|
||||
color: var(--mg-accent);
|
||||
filter: drop-shadow(0 0 8px rgba(var(--mg-accent-rgb), 0.55));
|
||||
}
|
||||
.tool-card:hover .tool-icon {
|
||||
color: #fff;
|
||||
color: var(--mg-text-light);
|
||||
filter: drop-shadow(0 0 14px rgba(var(--mg-primary-hover-rgb), 0.8));
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
:host="vrHost"
|
||||
:scope="auth.scope ?? 'Platform'"
|
||||
:token="auth.token ?? ''"
|
||||
:read-only="false"
|
||||
:read-only="!!readOnly"
|
||||
:embed-ui="true"
|
||||
@pick="onPick"
|
||||
@select="onSelect"
|
||||
@@ -35,17 +35,31 @@
|
||||
<el-col :span="8" class="side-col">
|
||||
<el-card shadow="never" class="side-card workbench-card">
|
||||
<template #header>
|
||||
<span>车辆监控台</span>
|
||||
<el-button link type="primary" size="small" :loading="refreshing" @click="refreshAll">
|
||||
刷新
|
||||
</el-button>
|
||||
<div class="workbench-header">
|
||||
<el-tabs v-model="workbenchTab" class="workbench-tabs">
|
||||
<el-tab-pane label="车辆监控台" name="vehicle" />
|
||||
<el-tab-pane label="任务列表" name="mission" />
|
||||
</el-tabs>
|
||||
<el-button link type="primary" size="small" :loading="refreshing" @click="refreshAll">
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<VehicleMonitorPanel
|
||||
v-show="workbenchTab === 'vehicle'"
|
||||
:cars="cars"
|
||||
:missions="missions"
|
||||
:selected-id="selectedVehicleId"
|
||||
@select="onVehicleSelect"
|
||||
/>
|
||||
<MissionListPanel
|
||||
v-show="workbenchTab === 'mission'"
|
||||
:deliveries="deliveries"
|
||||
:selected-id="selectedDeliveryId"
|
||||
@select="onDeliverySelect"
|
||||
@refresh="onDeliveryListRefresh"
|
||||
@action-done="refreshDeliveries"
|
||||
/>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="side-card detail-card">
|
||||
<template #header><span>选中信息</span></template>
|
||||
@@ -54,6 +68,7 @@
|
||||
:refresh-key="tick"
|
||||
:cars="cars"
|
||||
:missions="missions"
|
||||
:read-only="!!readOnly"
|
||||
/>
|
||||
</el-card>
|
||||
</el-col>
|
||||
@@ -65,27 +80,39 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import Workspace3D from '@/components/Workspace3D.vue'
|
||||
import VehicleMonitorPanel from '@/components/workbench/VehicleMonitorPanel.vue'
|
||||
import MissionListPanel from '@/components/workbench/MissionListPanel.vue'
|
||||
import MonitorSelectionPanel from '@/components/workbench/MonitorSelectionPanel.vue'
|
||||
import FloatingAlarmStack from '@/components/map-monitor/FloatingAlarmStack.vue'
|
||||
import WorkspaceCanvasToolbar from '@/components/workspace/WorkspaceCanvasToolbar.vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { listCars, listMissions } from '@/api/projection'
|
||||
import { listDeliveries } from '@/api/delivery'
|
||||
import { useProjectionStream, type StreamEvent } from '@/composables/useProjectionStream'
|
||||
import type { AlarmEvent, SelectionDetailEvent } from '@/composables/useMapEditStream'
|
||||
import { reflectionApi } from '@/api/reflection'
|
||||
import type { Car } from '@/types/car'
|
||||
import type { Mission } from '@/types/mission'
|
||||
import type { DeliveryTask } from '@/types/delivery'
|
||||
import type { SelectedObjectRef } from '@/types/workbench'
|
||||
|
||||
defineProps<{
|
||||
/** 只读模式(运营端复用 MapMonitorView 时传 true):3D 不可编辑,选中信息面板动作改用运维白名单。 */
|
||||
readOnly?: boolean
|
||||
}>()
|
||||
|
||||
const auth = useAuthStore()
|
||||
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223'
|
||||
|
||||
const cars = ref<Car[]>([])
|
||||
const missions = ref<Mission[]>([])
|
||||
const deliveries = ref<DeliveryTask[]>([])
|
||||
const deliveryListOpts = ref({ includeFinished: true, includeAborted: true })
|
||||
const selection = ref<SelectedObjectRef | null>(null)
|
||||
const tick = ref(0)
|
||||
const refreshing = ref(false)
|
||||
const workbenchTab = ref<'vehicle' | 'mission'>('vehicle')
|
||||
const selectedDeliveryId = ref<number | null>(null)
|
||||
const workspaceRef = ref<InstanceType<typeof Workspace3D> | null>(null)
|
||||
|
||||
/**
|
||||
@@ -122,8 +149,16 @@ function onAlarmAck(_a: AlarmEvent) {
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const onlineCars = computed(() => cars.value.filter((c) => c.state !== 'offline').length)
|
||||
const runningMissions = computed(() => missions.value.filter((m) => m.status === 'running').length)
|
||||
const queuedMissions = computed(() => missions.value.filter((m) => m.status === 'queued').length)
|
||||
const runningMissions = computed(() =>
|
||||
deliveries.value.length > 0
|
||||
? deliveries.value.filter((d) => d.statusCode === 'Fetching' || d.statusCode === 'Putting').length
|
||||
: missions.value.filter((m) => m.status === 'running').length
|
||||
)
|
||||
const queuedMissions = computed(() =>
|
||||
deliveries.value.length > 0
|
||||
? deliveries.value.filter((d) => d.statusCode === 'Waiting').length
|
||||
: missions.value.filter((m) => m.status === 'queued').length
|
||||
)
|
||||
|
||||
const selectedVehicleId = computed(() => {
|
||||
if (!selection.value || selection.value.kind !== 'vehicle') return null
|
||||
@@ -168,6 +203,7 @@ function sameSelection(a: SelectedObjectRef | null, b: SelectedObjectRef | null)
|
||||
|
||||
/** 把解析结果写入侧栏 selection;仅当对象变化时刷新详情,避免选中闪烁。 */
|
||||
function applyParsedSelection(parsed: { kind: 'vehicle' | 'site' | 'track'; id: string }, rawName?: string) {
|
||||
selectedDeliveryId.value = null
|
||||
let next: SelectedObjectRef
|
||||
if (parsed.kind === 'vehicle') {
|
||||
const car = (rawName ? findSelectedCar(rawName) : undefined)
|
||||
@@ -188,6 +224,7 @@ function applyParsedSelection(parsed: { kind: 'vehicle' | 'site' | 'track'; id:
|
||||
}
|
||||
|
||||
function applySelectionFromKindId(kind: 'site' | 'track' | 'car', id: number, names?: readonly string[]) {
|
||||
selectedDeliveryId.value = null
|
||||
if (kind === 'car') {
|
||||
const sid = String(id)
|
||||
const rawName = names?.find((n) => RX_CAR.test(n)) ?? `Car-${id}`
|
||||
@@ -293,6 +330,7 @@ async function fallbackSelectionFromBackend() {
|
||||
}
|
||||
|
||||
async function onVehicleSelect(ref: SelectedObjectRef) {
|
||||
selectedDeliveryId.value = null
|
||||
if (!sameSelection(selection.value, ref)) {
|
||||
selection.value = ref
|
||||
tick.value++
|
||||
@@ -306,6 +344,49 @@ async function onVehicleSelect(ref: SelectedObjectRef) {
|
||||
}
|
||||
}
|
||||
|
||||
async function onDeliverySelect(task: DeliveryTask) {
|
||||
selectedDeliveryId.value = task.id
|
||||
if (task.carId != null) {
|
||||
const car = cars.value.find((c) => c.rawId === task.carId || detailIdForCar(c) === String(task.carId))
|
||||
if (car) {
|
||||
await onVehicleSelect({ kind: 'vehicle', id: detailIdForCar(car), name: car.name })
|
||||
return
|
||||
}
|
||||
try {
|
||||
await reflectionApi.setSelection('car', task.carId)
|
||||
selection.value = {
|
||||
kind: 'vehicle',
|
||||
id: String(task.carId),
|
||||
name: task.carName ?? `Vehicle ${task.carId}`
|
||||
}
|
||||
tick.value++
|
||||
} catch (err) {
|
||||
ElMessage.error(`同步 3D 选中失败:${(err as Error).message}`)
|
||||
}
|
||||
return
|
||||
}
|
||||
selection.value = {
|
||||
kind: 'delivery',
|
||||
id: String(task.id),
|
||||
name: `${task.srcLabel} → ${task.dstLabel}`
|
||||
}
|
||||
tick.value++
|
||||
}
|
||||
|
||||
function onDeliveryListRefresh(opts: { includeFinished: boolean; includeAborted: boolean }) {
|
||||
deliveryListOpts.value = opts
|
||||
void refreshDeliveries()
|
||||
}
|
||||
|
||||
async function refreshDeliveries() {
|
||||
try {
|
||||
deliveries.value = await listDeliveries(deliveryListOpts.value)
|
||||
} catch (err) {
|
||||
console.warn('[MapMonitor] refreshDeliveries failed', err)
|
||||
deliveries.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function findSelectedCar(name: string): Car | undefined {
|
||||
const parsedId = parseWorkspaceCarId(name)
|
||||
return cars.value.find((c) =>
|
||||
@@ -328,8 +409,10 @@ function detailIdForCar(car: Car): string {
|
||||
async function refreshAll() {
|
||||
refreshing.value = true
|
||||
try {
|
||||
cars.value = await listCars()
|
||||
missions.value = await listMissions()
|
||||
const [carList, missionList] = await Promise.all([listCars(), listMissions()])
|
||||
cars.value = carList
|
||||
missions.value = missionList
|
||||
await refreshDeliveries()
|
||||
} catch (err) {
|
||||
console.warn('[MapMonitor] refreshAll failed', err)
|
||||
} finally {
|
||||
@@ -385,7 +468,11 @@ onMounted(async () => {
|
||||
await refreshAll()
|
||||
pollTimer = setInterval(() => {
|
||||
// SSE 已连但 projection/cars 曾 502 时 connected 仍为 true,需在车列表为空时继续轮询
|
||||
if (!stream.connected.value || cars.value.length === 0) void refreshAll()
|
||||
if (!stream.connected.value || cars.value.length === 0) {
|
||||
void refreshAll()
|
||||
} else if (workbenchTab.value === 'mission') {
|
||||
void refreshDeliveries()
|
||||
}
|
||||
}, 3000)
|
||||
})
|
||||
|
||||
@@ -411,8 +498,8 @@ onUnmounted(() => {
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
.workbench-card { flex: 1.2; }
|
||||
.detail-card { flex: 1; }
|
||||
.workbench-card { flex: 1; min-height: 0; }
|
||||
.detail-card { flex: 1; min-height: 0; }
|
||||
.workbench-card :deep(.el-card__body),
|
||||
.detail-card :deep(.el-card__body) {
|
||||
flex: 1;
|
||||
@@ -421,10 +508,39 @@ onUnmounted(() => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.muted { color: rgba(255, 255, 255, 0.78); font-size: 12px; }
|
||||
.muted { color: var(--mg-text-muted); font-size: 12px; }
|
||||
.workbench-card :deep(.el-card__header),
|
||||
.detail-card :deep(.el-card__header) {
|
||||
color: #fff;
|
||||
color: var(--mg-text-light);
|
||||
}
|
||||
.workbench-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
.workbench-tabs {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.workbench-tabs :deep(.el-tabs__header) {
|
||||
margin: 0;
|
||||
}
|
||||
.workbench-tabs :deep(.el-tabs__nav-wrap::after) {
|
||||
display: none;
|
||||
}
|
||||
.workbench-tabs :deep(.el-tabs__item) {
|
||||
color: var(--mg-text-muted);
|
||||
padding: 0 10px;
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
.workbench-tabs :deep(.el-tabs__item.is-active) {
|
||||
color: var(--mg-text-light);
|
||||
}
|
||||
.workbench-tabs :deep(.el-tabs__active-bar) {
|
||||
background-color: var(--mg-accent, #8ec8fc);
|
||||
}
|
||||
:deep(.el-row.main-row) { height: 100%; }
|
||||
:deep(.el-col-16 > div) { height: 100%; min-height: 0; }
|
||||
|
||||
@@ -144,22 +144,22 @@ onMounted(() => reload())
|
||||
<style scoped>
|
||||
.mmc-card { margin: 0; }
|
||||
.mmc-header { display: flex; align-items: center; gap: 8px; }
|
||||
.mmc-title { font-weight: 600; font-size: 15px; color: #fff; }
|
||||
.mmc-title { font-weight: 600; font-size: 15px; color: var(--mg-text-light); }
|
||||
.mmc-header .spacer { flex: 1; }
|
||||
.mmc-desc {
|
||||
color: rgba(232, 215, 245, 0.78);
|
||||
color: var(--mg-text-muted);
|
||||
font-size: 12.5px;
|
||||
margin: 0 0 12px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.mmc-desc code {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--mg-veil-2);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
}
|
||||
.mmc-tabs :deep(.el-tabs__item) { color: rgba(255, 255, 255, 0.82) !important; }
|
||||
.mmc-tabs :deep(.el-tabs__item.is-active) { color: #fff !important; }
|
||||
.mmc-tabs :deep(.el-tabs__item) { color: var(--mg-text-muted) !important; }
|
||||
.mmc-tabs :deep(.el-tabs__item.is-active) { color: var(--mg-text-light) !important; }
|
||||
.mmc-tab-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
<el-form label-width="160px" :model="payload">
|
||||
<el-form-item label="分配模式">
|
||||
<el-radio-group :model-value="payload.mode" @update:model-value="(v: any) => update({ ...payload, mode: v })">
|
||||
<el-radio-button label="roundRobin">轮询</el-radio-button>
|
||||
<el-radio-button label="nearest">就近</el-radio-button>
|
||||
<el-radio-button label="leastLoad">最少负载</el-radio-button>
|
||||
<el-radio-button label="custom">自定义</el-radio-button>
|
||||
<el-radio-button value="roundRobin">轮询</el-radio-button>
|
||||
<el-radio-button value="nearest">就近</el-radio-button>
|
||||
<el-radio-button value="leastLoad">最少负载</el-radio-button>
|
||||
<el-radio-button value="custom">自定义</el-radio-button>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="启用负载均衡">
|
||||
|
||||
@@ -78,5 +78,5 @@ onMounted(async () => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.muted { color: rgba(232, 215, 245, 0.55); font-size: 12px; }
|
||||
.muted { color: var(--mg-text-dim); font-size: 12px; }
|
||||
</style>
|
||||
|
||||
@@ -1,131 +1,19 @@
|
||||
<template>
|
||||
<div class="monitor-map-page">
|
||||
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 12px">
|
||||
RCSMonitor 只读视图:iframe 嵌入 SimpleLite webVRender({{ vrHost }}),右侧可执行白名单运维动作。
|
||||
</el-alert>
|
||||
<el-row :gutter="12" class="main-row">
|
||||
<el-col :span="17">
|
||||
<div class="canvas-with-toolbar">
|
||||
<Workspace3D
|
||||
:host="vrHost"
|
||||
:scope="'RCSMonitor'"
|
||||
:token="auth.token ?? ''"
|
||||
:read-only="true"
|
||||
:embed-ui="true"
|
||||
@pick="onPick"
|
||||
@select="onSelect" />
|
||||
<WorkspaceCanvasToolbar />
|
||||
</div>
|
||||
</el-col>
|
||||
<el-col :span="7">
|
||||
<el-card shadow="never">
|
||||
<template #header><span>选中目标</span></template>
|
||||
<el-radio-group v-model="targetType" size="small">
|
||||
<el-radio-button label="car">车辆</el-radio-button>
|
||||
<el-radio-button label="task">任务</el-radio-button>
|
||||
</el-radio-group>
|
||||
<el-select v-model="targetId" placeholder="选择目标 ID" filterable style="width: 100%; margin-top: 8px">
|
||||
<el-option v-for="opt in currentOptions" :key="opt.value" :label="opt.label" :value="opt.value" />
|
||||
</el-select>
|
||||
<el-descriptions :column="1" border size="small" style="margin-top: 8px">
|
||||
<el-descriptions-item label="最近 Pick">
|
||||
{{ lastPick ? `(${lastPick.x.toFixed(0)}, ${lastPick.y.toFixed(0)})` : '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最近 Select">
|
||||
{{ lastSelect.length ? lastSelect.join(', ') : '—' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" style="margin-top: 12px">
|
||||
<template #header><span>运维白名单(按权限隐藏)</span></template>
|
||||
<div class="ops-grid">
|
||||
<template v-for="op in OPS_WHITELIST" :key="op.code">
|
||||
<el-button
|
||||
v-if="auth.hasOp(op.code) && opMatchTarget(op)"
|
||||
:type="op.needConfirm ? 'warning' : 'primary'"
|
||||
size="small"
|
||||
plain
|
||||
@click="execute(op)">
|
||||
{{ op.label }}
|
||||
<el-tooltip :content="op.description"><el-icon style="margin-left: 4px"><InfoFilled /></el-icon></el-tooltip>
|
||||
</el-button>
|
||||
</template>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
<MapMonitorView read-only />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { InfoFilled } from '@element-plus/icons-vue'
|
||||
import Workspace3D from '@/components/Workspace3D.vue'
|
||||
import WorkspaceCanvasToolbar from '@/components/workspace/WorkspaceCanvasToolbar.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { OPS_WHITELIST, type OpsAction } from '@/types/ops'
|
||||
import { listCars, listMissions } from '@/api/projection'
|
||||
import { executeOp } from '@/api/ops'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const vrHost = (import.meta.env.VITE_VRENDER_HOST as string | undefined) ?? 'localhost:8223'
|
||||
|
||||
const targetType = ref<'car' | 'task'>('car')
|
||||
const targetId = ref('')
|
||||
const carOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
const missionOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
const lastPick = ref<{ x: number; y: number } | null>(null)
|
||||
const lastSelect = ref<string[]>([])
|
||||
|
||||
const currentOptions = computed(() => (targetType.value === 'car' ? carOptions.value : missionOptions.value))
|
||||
|
||||
function opMatchTarget(op: OpsAction) {
|
||||
return op.target === targetType.value || op.target === 'note'
|
||||
}
|
||||
|
||||
async function execute(op: OpsAction) {
|
||||
if (!targetId.value && op.target !== 'note') {
|
||||
ElMessage.warning('请先选择目标')
|
||||
return
|
||||
}
|
||||
if (op.needConfirm) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认执行 [${op.label}]?\n目标:${targetId.value}`, '二次确认', { type: 'warning' })
|
||||
} catch { return }
|
||||
}
|
||||
try {
|
||||
const resp = await executeOp({ opCode: op.code, targetId: targetId.value, reason: '' })
|
||||
ElMessage.success(`已执行 ${op.label},auditId=${resp.auditId}`)
|
||||
} catch (e) {
|
||||
ElMessage.error(`执行失败:${e instanceof Error ? e.message : String(e)}`)
|
||||
}
|
||||
}
|
||||
|
||||
function onPick(p: { x: number; y: number }) { lastPick.value = p }
|
||||
function onSelect(names: string[]) {
|
||||
lastSelect.value = names
|
||||
if (names.length === 1 && names[0].startsWith('UICar-')) {
|
||||
targetType.value = 'car'
|
||||
targetId.value = names[0].replace('UICar-', '')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const cars = await listCars()
|
||||
carOptions.value = cars.map((c) => ({ label: `${c.name} (${c.id})`, value: c.id }))
|
||||
const missions = await listMissions()
|
||||
missionOptions.value = missions.map((m) => ({ label: `${m.name} (${m.id})`, value: m.id }))
|
||||
})
|
||||
/**
|
||||
* 运营端(RCSMonitor)地图监控页:复用管理员端 MapMonitorView,统一界面,仅以 read-only 区分。
|
||||
*
|
||||
* 只读模式下:
|
||||
* - 3D 画布不可编辑(Workspace3D read-only),scope 由 MapMonitorView 内部按 auth.scope 取,
|
||||
* 运营端登录后即 RCSMonitor。
|
||||
* - 右侧「选中信息」面板的车辆动作改用运维白名单(OPS_WHITELIST + executeOp 审计 + 二次确认,
|
||||
* 按当前账号 hasOp 过滤),站点/路径的编辑动作区隐藏。
|
||||
*
|
||||
* 备注:原运营端地图页内的「任务」白名单动作(暂停/取消/重派等)请走 /monitor/ops 运维操作页,
|
||||
* 地图监控页聚焦于地图对象(车/站/路)的监视与车辆运维动作。
|
||||
*/
|
||||
import MapMonitorView from '@/views/admin/MapMonitorView.vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.monitor-map-page { display: flex; flex-direction: column; height: calc(100vh - 56px - 36px - 32px); }
|
||||
.main-row { flex: 1; min-height: 0; }
|
||||
.main-row > .el-col { display: flex; flex-direction: column; }
|
||||
.main-row > .el-col:first-child { min-height: 0; }
|
||||
.ops-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.canvas-with-toolbar { flex: 1; min-height: 0; display: flex; flex-direction: column; }
|
||||
.canvas-with-toolbar :deep(.workspace-3d-wrap) { flex: 1; min-height: 0; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user