2025-05-22 18:06:30 +08:00
|
|
|
|
<script setup lang="ts">
|
2025-09-12 14:04:20 +08:00
|
|
|
|
import { LockState } from '@meta2d/core';
|
2025-07-01 14:38:21 +08:00
|
|
|
|
import { message } from 'ant-design-vue';
|
2025-09-26 18:31:22 +08:00
|
|
|
|
import type { Dayjs } from 'dayjs';
|
2025-05-25 00:07:22 +08:00
|
|
|
|
import { isNil } from 'lodash-es';
|
2025-09-11 15:42:27 +08:00
|
|
|
|
import { computed, onMounted, onUnmounted, provide, ref, type ShallowRef, shallowRef, watch } from 'vue';
|
2025-07-02 11:39:27 +08:00
|
|
|
|
import { useRoute } from 'vue-router';
|
2025-05-22 18:06:30 +08:00
|
|
|
|
|
2025-09-09 11:16:52 +08:00
|
|
|
|
import { getSceneByGroupId, getSceneById, monitorRealSceneById, monitorSceneById } from '../apis/scene';
|
2025-09-15 15:50:22 +08:00
|
|
|
|
import expandIcon from '../assets/icons/png/expand.png';
|
|
|
|
|
|
import foldIcon from '../assets/icons/png/fold.png';
|
2025-09-12 14:04:20 +08:00
|
|
|
|
import FollowViewNotification from '../components/follow-view-notification.vue';
|
2025-09-26 18:31:22 +08:00
|
|
|
|
import PlaybackController from '../components/PlaybackController.vue';
|
2025-09-28 14:26:21 +08:00
|
|
|
|
import RobotLabels from '../components/robot-labels.vue';
|
2025-09-26 18:31:22 +08:00
|
|
|
|
import { usePlaybackWebSocket } from '../hooks/usePlaybackWebSocket';
|
2025-08-20 10:31:27 +08:00
|
|
|
|
import { autoDoorSimulationService, type AutoDoorWebSocketData } from '../services/auto-door-simulation.service';
|
2025-09-12 14:04:20 +08:00
|
|
|
|
import {
|
|
|
|
|
|
type ContextMenuState,
|
|
|
|
|
|
createContextMenuManager,
|
|
|
|
|
|
handleContextMenuFromPenData,
|
|
|
|
|
|
} from '../services/context-menu.service';
|
2025-09-09 11:16:52 +08:00
|
|
|
|
import { EditorService } from '../services/editor.service';
|
|
|
|
|
|
import { StorageLocationService } from '../services/storage-location.service';
|
|
|
|
|
|
import { useViewState } from '../services/useViewState';
|
2025-09-11 15:42:27 +08:00
|
|
|
|
import { editorStore } from '../stores/editor.store';
|
2025-08-20 10:31:27 +08:00
|
|
|
|
|
2025-05-22 18:06:30 +08:00
|
|
|
|
const EDITOR_KEY = Symbol('editor-key');
|
|
|
|
|
|
|
|
|
|
|
|
type Props = {
|
|
|
|
|
|
sid: string;
|
|
|
|
|
|
id?: string;
|
|
|
|
|
|
};
|
|
|
|
|
|
const props = defineProps<Props>();
|
|
|
|
|
|
|
2025-07-18 15:52:26 +08:00
|
|
|
|
//#region 响应式状态定义
|
2025-07-02 11:39:27 +08:00
|
|
|
|
// 获取路由信息以判断当前模式
|
|
|
|
|
|
const route = useRoute();
|
2025-10-09 11:45:33 +08:00
|
|
|
|
|
|
|
|
|
|
//#region Playback Mode
|
|
|
|
|
|
const mode = computed(() => (route.path.includes('/playback') ? 'playback' : 'live'));
|
|
|
|
|
|
const isPlaybackMode = computed(() => mode.value === 'playback');
|
2025-07-02 11:39:27 +08:00
|
|
|
|
const isMonitorMode = computed(() => route.path.includes('/monitor'));
|
|
|
|
|
|
|
2025-07-18 15:52:26 +08:00
|
|
|
|
// 场景标题
|
|
|
|
|
|
const title = ref<string>('');
|
|
|
|
|
|
|
2025-10-09 11:45:33 +08:00
|
|
|
|
const modeTitle = computed(() => {
|
|
|
|
|
|
if (isPlaybackMode.value) {
|
|
|
|
|
|
return '场景回放';
|
|
|
|
|
|
}
|
|
|
|
|
|
return isMonitorMode.value ? '场景监控' : '场景仿真';
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-09-15 15:50:22 +08:00
|
|
|
|
// 左侧侧边栏折叠状态
|
|
|
|
|
|
const leftCollapsed = ref<boolean>(false);
|
|
|
|
|
|
|
2025-07-30 11:28:39 +08:00
|
|
|
|
// 监听标题变化,动态更新页面标题
|
|
|
|
|
|
watch(
|
2025-10-09 11:45:33 +08:00
|
|
|
|
modeTitle,
|
|
|
|
|
|
(newTitle) => {
|
|
|
|
|
|
document.title = newTitle;
|
2025-07-30 11:28:39 +08:00
|
|
|
|
},
|
|
|
|
|
|
{ immediate: true },
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2025-07-18 15:52:26 +08:00
|
|
|
|
// 服务实例
|
|
|
|
|
|
const container = shallowRef<HTMLDivElement>();
|
|
|
|
|
|
const editor = shallowRef<EditorService>();
|
|
|
|
|
|
const storageLocationService = shallowRef<StorageLocationService>();
|
|
|
|
|
|
const client = shallowRef<WebSocket>();
|
2025-09-15 14:06:20 +08:00
|
|
|
|
// 左侧边栏元素引用(用于 Ctrl/Cmd+F 聚焦搜索框)
|
|
|
|
|
|
const leftSiderEl = shallowRef<HTMLElement>();
|
2025-09-26 18:31:22 +08:00
|
|
|
|
const isPlaybackControllerVisible = ref<boolean>(true);
|
|
|
|
|
|
const selectedDate = ref<Dayjs | null>(null);
|
|
|
|
|
|
|
|
|
|
|
|
const playback = usePlaybackWebSocket(editor);
|
|
|
|
|
|
|
|
|
|
|
|
watch(mode, async (newMode) => {
|
|
|
|
|
|
if (newMode === 'live') {
|
|
|
|
|
|
playback.disconnect();
|
|
|
|
|
|
await monitorScene();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
client.value?.close();
|
|
|
|
|
|
playback.connect(props.sid); // Connect once using props.sid
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const handleDateChange = (date: Dayjs) => {
|
|
|
|
|
|
if (date) {
|
|
|
|
|
|
selectedDate.value = date;
|
|
|
|
|
|
const startOfDayTimestamp = date.startOf('day').valueOf();
|
|
|
|
|
|
playback.seek(startOfDayTimestamp);
|
|
|
|
|
|
// The total duration is now relative to the start of the day
|
|
|
|
|
|
playback.totalDuration.value = 24 * 60 * 60 * 1000;
|
|
|
|
|
|
// Set current time to the beginning of the selected day for the slider
|
|
|
|
|
|
playback.currentTime.value = startOfDayTimestamp;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-09 16:36:45 +08:00
|
|
|
|
watch(playback.sceneJson, async (newJson) => {
|
2025-09-26 18:31:22 +08:00
|
|
|
|
if (newJson) {
|
2025-10-09 16:36:45 +08:00
|
|
|
|
await editor.value?.load(newJson);
|
|
|
|
|
|
// [关键修复] 场景加载后,立即重新初始化机器人
|
|
|
|
|
|
await editor.value?.initRobots();
|
2025-09-26 18:31:22 +08:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
//#endregion
|
|
|
|
|
|
|
2025-07-18 15:52:26 +08:00
|
|
|
|
// 依赖注入
|
|
|
|
|
|
provide(EDITOR_KEY, editor);
|
|
|
|
|
|
//#endregion
|
|
|
|
|
|
|
|
|
|
|
|
//#region 核心服务接口
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 读取场景数据
|
|
|
|
|
|
*/
|
2025-05-22 18:06:30 +08:00
|
|
|
|
const readScene = async () => {
|
|
|
|
|
|
const res = props.id ? await getSceneByGroupId(props.id, props.sid) : await getSceneById(props.sid);
|
|
|
|
|
|
title.value = res?.label ?? '';
|
|
|
|
|
|
editor.value?.load(res?.json);
|
|
|
|
|
|
};
|
2025-05-25 00:07:22 +08:00
|
|
|
|
|
2025-07-18 15:52:26 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 监控场景中的机器人状态
|
2025-07-26 04:30:43 +08:00
|
|
|
|
* 采用 requestAnimationFrame 优化高频数据渲染,确保动画流畅且不阻塞UI线程。
|
2025-07-18 15:52:26 +08:00
|
|
|
|
*/
|
2025-05-25 00:07:22 +08:00
|
|
|
|
const monitorScene = async () => {
|
2025-07-18 15:52:26 +08:00
|
|
|
|
console.log(current.value?.id);
|
2025-05-25 00:07:22 +08:00
|
|
|
|
client.value?.close();
|
2025-07-02 11:39:27 +08:00
|
|
|
|
// 根据路由路径是否是真实场景监控决定使用哪个监控接口
|
|
|
|
|
|
const ws = isMonitorMode.value ? await monitorRealSceneById(props.sid) : await monitorSceneById(props.sid);
|
2025-05-25 00:07:22 +08:00
|
|
|
|
if (isNil(ws)) return;
|
2025-07-26 04:30:43 +08:00
|
|
|
|
|
|
|
|
|
|
// 使用 Map 来缓冲每个机器人最新的实时数据。
|
|
|
|
|
|
// Key 是机器人 ID,Value 是该机器人最新的完整信息。
|
|
|
|
|
|
// 这样做可以确保我们总是有每个机器人最新的状态,并且可以快速更新,避免重复渲染。
|
|
|
|
|
|
const latestRobotData = new Map<string, RobotRealtimeInfo>();
|
|
|
|
|
|
|
|
|
|
|
|
// 用于存储 requestAnimationFrame 的 ID,方便在组件卸载或 WebSocket 关闭时取消动画循环。
|
|
|
|
|
|
let animationFrameId: number;
|
|
|
|
|
|
|
2025-08-20 19:05:34 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 批量更新机器人数据,减少渲染调用次数
|
|
|
|
|
|
* @param updates 需要更新的机器人数据数组
|
|
|
|
|
|
*/
|
|
|
|
|
|
const batchUpdateRobots = (updates: Array<{ id: string; data: RobotRealtimeInfo }>) => {
|
|
|
|
|
|
if (!editor.value || updates.length === 0) return;
|
|
|
|
|
|
|
2025-09-16 10:26:50 +08:00
|
|
|
|
// 用于收集所有图元(pen)的更新数据
|
|
|
|
|
|
const allPenUpdates: any[] = [];
|
|
|
|
|
|
|
2025-08-20 19:05:34 +08:00
|
|
|
|
updates.forEach(({ id, data }) => {
|
2025-09-15 17:30:01 +08:00
|
|
|
|
const {
|
|
|
|
|
|
x,
|
|
|
|
|
|
y,
|
|
|
|
|
|
active,
|
|
|
|
|
|
angle,
|
|
|
|
|
|
path: points,
|
|
|
|
|
|
isWaring,
|
|
|
|
|
|
isFault,
|
2025-09-15 18:14:40 +08:00
|
|
|
|
isCharging = 0,
|
|
|
|
|
|
isCarrying = 0,
|
2025-09-29 11:09:54 +08:00
|
|
|
|
canOrder,
|
2025-09-15 17:30:01 +08:00
|
|
|
|
...rest
|
|
|
|
|
|
} = data;
|
2025-08-20 19:05:34 +08:00
|
|
|
|
|
2025-09-16 10:26:50 +08:00
|
|
|
|
// 1. 更新机器人缓存的业务数据
|
2025-09-15 17:30:01 +08:00
|
|
|
|
editor.value?.updateRobot(id, {
|
|
|
|
|
|
...rest,
|
2025-09-16 10:26:50 +08:00
|
|
|
|
isCharging: isCharging as any,
|
|
|
|
|
|
isCarrying: isCarrying as any,
|
2025-09-29 11:09:54 +08:00
|
|
|
|
canOrder: canOrder as any,
|
2025-09-15 17:30:01 +08:00
|
|
|
|
});
|
2025-08-20 19:05:34 +08:00
|
|
|
|
|
2025-09-16 10:26:50 +08:00
|
|
|
|
// 2. 准备图元(pen)的更新负载对象,将多个更新合并
|
|
|
|
|
|
const penUpdatePayload: any = { id };
|
|
|
|
|
|
const robotState: any = {};
|
|
|
|
|
|
|
|
|
|
|
|
// 2.1 处理路径并将其放入 robotState
|
2025-08-20 19:05:34 +08:00
|
|
|
|
// 处理路径坐标转换,参考refreshRobot方法的逻辑
|
2025-09-19 09:47:47 +08:00
|
|
|
|
if (points?.length && !isMonitorMode.value) {
|
2025-08-20 19:05:34 +08:00
|
|
|
|
// 新路径:相对于机器人中心的坐标
|
|
|
|
|
|
const cx = x || 37; // 机器人中心X坐标,默认37
|
|
|
|
|
|
const cy = y || 37; // 机器人中心Y坐标,默认37
|
2025-09-16 10:26:50 +08:00
|
|
|
|
robotState.path = points.map((p) => ({ x: p.x - cx, y: p.y - cy }));
|
2025-08-20 19:05:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-16 10:26:50 +08:00
|
|
|
|
// 2.2 合并其他机器人状态
|
|
|
|
|
|
if (active !== undefined) robotState.active = active;
|
|
|
|
|
|
if (isWaring !== undefined) robotState.isWaring = isWaring;
|
|
|
|
|
|
if (isFault !== undefined) robotState.isFault = isFault;
|
|
|
|
|
|
if (isCharging !== undefined) robotState.isCharging = isCharging;
|
|
|
|
|
|
if (isCarrying !== undefined) robotState.isCarrying = isCarrying;
|
2025-09-29 11:09:54 +08:00
|
|
|
|
if (canOrder !== undefined) robotState.canOrder = canOrder;
|
2025-09-16 10:26:50 +08:00
|
|
|
|
|
|
|
|
|
|
// 将合并后的状态赋给 payload
|
|
|
|
|
|
if (Object.keys(robotState).length > 0) {
|
|
|
|
|
|
penUpdatePayload.robot = robotState;
|
2025-08-20 19:05:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-16 10:26:50 +08:00
|
|
|
|
// 2.3 合并位置、可见性和角度
|
|
|
|
|
|
if (!isNil(x) && !isNil(y)) {
|
|
|
|
|
|
penUpdatePayload.x = x - 60;
|
|
|
|
|
|
penUpdatePayload.y = y - 60;
|
|
|
|
|
|
penUpdatePayload.visible = true;
|
|
|
|
|
|
penUpdatePayload.locked = LockState.None;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (angle != null) {
|
|
|
|
|
|
penUpdatePayload.rotate = -angle + 180;
|
|
|
|
|
|
}
|
2025-08-20 19:05:34 +08:00
|
|
|
|
|
2025-09-16 10:26:50 +08:00
|
|
|
|
// 只有当有实际更新时才推入数组
|
|
|
|
|
|
if (Object.keys(penUpdatePayload).length > 1) {
|
|
|
|
|
|
allPenUpdates.push(penUpdatePayload);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 3. 更新状态覆盖图标 (此API调用如果不能合并,则保留在循环内)
|
2025-09-23 17:54:37 +08:00
|
|
|
|
// 使用刚刚为机器人计算出的新位置来更新覆盖物,以确保同步
|
|
|
|
|
|
const newPositionForOverlay =
|
|
|
|
|
|
penUpdatePayload.x !== undefined && penUpdatePayload.y !== undefined
|
|
|
|
|
|
? { x: penUpdatePayload.x, y: penUpdatePayload.y, rotate: penUpdatePayload.rotate }
|
|
|
|
|
|
: undefined;
|
|
|
|
|
|
editor.value?.updateRobotStatusOverlay?.(id, false, newPositionForOverlay);
|
2025-09-15 17:30:01 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
2025-09-16 10:26:50 +08:00
|
|
|
|
// 4. 使用Meta2D的批量更新方法一次性提交所有更改
|
|
|
|
|
|
if (allPenUpdates.length > 0) {
|
|
|
|
|
|
allPenUpdates.forEach((update) => {
|
|
|
|
|
|
editor.value?.setValue(update, { render: false, history: false, doEvent: false });
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 5. 批量更新完成后,统一渲染一次
|
2025-09-29 15:48:29 +08:00
|
|
|
|
/*
|
|
|
|
|
|
为了让机器人在地图上最基本地被绘制出来并能够移动,后台推送的 WebSocket 数据最少需要 id, x, y 这 3
|
|
|
|
|
|
个字段。
|
|
|
|
|
|
|
|
|
|
|
|
为了达到一个功能上比较完整的视觉效果(带光圈、能旋转),则最少需要 id, x, y, angle, active 这 5
|
|
|
|
|
|
个字段。
|
|
|
|
|
|
*/
|
2025-08-20 19:05:34 +08:00
|
|
|
|
editor.value?.render();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-07-26 04:30:43 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 渲染循环函数。
|
2025-08-04 16:59:13 +08:00
|
|
|
|
* 通过 requestAnimationFrame 以浏览器的刷新频率被调用。
|
|
|
|
|
|
* 采用时间分片(Time Slicing)策略,为每一帧的渲染操作设定一个时间预算(frameBudget),
|
|
|
|
|
|
* 避免单帧处理过多数据导致UI阻塞。
|
2025-08-20 19:05:34 +08:00
|
|
|
|
* 新增批量渲染优化,减少渲染调用次数。
|
2025-07-26 04:30:43 +08:00
|
|
|
|
*/
|
|
|
|
|
|
const renderLoop = () => {
|
2025-08-04 16:59:13 +08:00
|
|
|
|
const frameBudget = 8; // 每一帧的渲染预算,单位:毫秒。保守设置为8ms,为其他任务留出时间。
|
|
|
|
|
|
const startTime = performance.now();
|
2025-07-26 04:30:43 +08:00
|
|
|
|
|
2025-08-20 19:05:34 +08:00
|
|
|
|
// 收集所有需要更新的机器人数据,但不立即渲染
|
|
|
|
|
|
const updates: Array<{ id: string; data: RobotRealtimeInfo }> = [];
|
|
|
|
|
|
|
|
|
|
|
|
// 在时间预算内,持续收集机器人数据
|
2025-08-04 16:59:13 +08:00
|
|
|
|
while (performance.now() - startTime < frameBudget && latestRobotData.size > 0) {
|
|
|
|
|
|
// 获取并移除 Map 中的第一条数据
|
|
|
|
|
|
const entry = latestRobotData.entries().next().value;
|
|
|
|
|
|
if (!entry) break;
|
|
|
|
|
|
const [id, data] = entry;
|
|
|
|
|
|
latestRobotData.delete(id);
|
|
|
|
|
|
|
|
|
|
|
|
// 确保机器人仍然存在于编辑器中
|
|
|
|
|
|
if (editor.value?.checkRobotById(id)) {
|
2025-08-20 19:05:34 +08:00
|
|
|
|
updates.push({ id, data });
|
2025-08-04 16:59:13 +08:00
|
|
|
|
}
|
2025-05-25 00:07:22 +08:00
|
|
|
|
}
|
2025-08-04 16:59:13 +08:00
|
|
|
|
|
2025-08-20 19:05:34 +08:00
|
|
|
|
// 批量更新机器人,减少渲染调用次数
|
|
|
|
|
|
if (updates.length > 0) {
|
|
|
|
|
|
batchUpdateRobots(updates);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-20 10:31:27 +08:00
|
|
|
|
// 处理缓冲的自动门点数据
|
|
|
|
|
|
autoDoorSimulationService.processBufferedData(frameBudget, startTime);
|
|
|
|
|
|
|
2025-07-26 04:30:43 +08:00
|
|
|
|
// 请求浏览器在下一次重绘之前再次调用 renderLoop,形成动画循环。
|
2025-08-04 16:59:13 +08:00
|
|
|
|
// 即使本帧没有处理完所有数据,下一帧也会继续处理剩余的数据。
|
2025-07-26 04:30:43 +08:00
|
|
|
|
animationFrameId = requestAnimationFrame(renderLoop);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* WebSocket 的 onmessage 事件处理器。
|
|
|
|
|
|
* 这个函数只做一件事:接收数据并将其快速存入缓冲区。
|
2025-08-20 10:31:27 +08:00
|
|
|
|
* 这种"数据与渲染分离"的设计是避免UI阻塞的关键。
|
2025-07-26 04:30:43 +08:00
|
|
|
|
*/
|
|
|
|
|
|
ws.onmessage = (e) => {
|
2025-08-20 10:31:27 +08:00
|
|
|
|
const data = JSON.parse(e.data || '{}');
|
|
|
|
|
|
|
|
|
|
|
|
// 判断数据类型:type=99为自动门点,其他为机器人
|
|
|
|
|
|
if (data.type === 99) {
|
|
|
|
|
|
// 自动门点数据处理
|
|
|
|
|
|
autoDoorSimulationService.handleWebSocketData(data as AutoDoorWebSocketData);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 机器人数据处理
|
|
|
|
|
|
const robotData = data as RobotRealtimeInfo;
|
|
|
|
|
|
latestRobotData.set(robotData.id, robotData);
|
|
|
|
|
|
}
|
2025-05-25 00:07:22 +08:00
|
|
|
|
};
|
2025-07-26 04:30:43 +08:00
|
|
|
|
|
2025-05-25 00:07:22 +08:00
|
|
|
|
client.value = ws;
|
2025-07-26 04:30:43 +08:00
|
|
|
|
|
|
|
|
|
|
// WebSocket 连接成功后,立即启动渲染循环。
|
|
|
|
|
|
renderLoop();
|
|
|
|
|
|
|
|
|
|
|
|
// 增强 WebSocket 的 onclose 事件,以确保在连接关闭时清理资源。
|
|
|
|
|
|
const originalOnClose = ws.onclose;
|
|
|
|
|
|
ws.onclose = (event) => {
|
|
|
|
|
|
// 停止渲染循环,防止不必要的计算和内存泄漏。
|
|
|
|
|
|
cancelAnimationFrame(animationFrameId);
|
|
|
|
|
|
// 如果原始的 onclose 处理函数存在,则继续执行它。
|
|
|
|
|
|
if (originalOnClose) {
|
|
|
|
|
|
originalOnClose.call(ws, event);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2025-05-25 00:07:22 +08:00
|
|
|
|
};
|
2025-05-22 18:06:30 +08:00
|
|
|
|
//#endregion
|
|
|
|
|
|
|
2025-07-18 15:52:26 +08:00
|
|
|
|
//#region 服务初始化
|
2025-05-22 18:06:30 +08:00
|
|
|
|
onMounted(() => {
|
2025-09-23 17:54:37 +08:00
|
|
|
|
editor.value = new EditorService(container.value!, props.sid);
|
2025-09-12 14:04:20 +08:00
|
|
|
|
|
2025-09-11 15:42:27 +08:00
|
|
|
|
// 将 editor 存储到 store 中
|
|
|
|
|
|
editorStore.setEditor(editor as ShallowRef<EditorService>);
|
2025-09-12 14:04:20 +08:00
|
|
|
|
|
2025-07-18 15:52:26 +08:00
|
|
|
|
storageLocationService.value = new StorageLocationService(editor.value, props.sid);
|
2025-05-22 18:06:30 +08:00
|
|
|
|
});
|
2025-07-18 15:52:26 +08:00
|
|
|
|
//#endregion
|
2025-05-22 18:06:30 +08:00
|
|
|
|
|
2025-07-18 15:52:26 +08:00
|
|
|
|
//#region 生命周期管理
|
2025-05-25 00:07:22 +08:00
|
|
|
|
onMounted(async () => {
|
|
|
|
|
|
await readScene();
|
|
|
|
|
|
await editor.value?.initRobots();
|
2025-10-09 11:45:33 +08:00
|
|
|
|
|
|
|
|
|
|
if (mode.value === 'live') {
|
|
|
|
|
|
await monitorScene();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
playback.connect(props.sid);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-07-30 15:24:10 +08:00
|
|
|
|
storageLocationService.value?.startMonitoring({ interval: 1 });
|
2025-07-02 09:27:40 +08:00
|
|
|
|
// 自动保存和恢复视图状态
|
|
|
|
|
|
await handleAutoSaveAndRestoreViewState();
|
2025-08-20 10:31:27 +08:00
|
|
|
|
|
|
|
|
|
|
// 设置编辑器服务
|
|
|
|
|
|
if (editor.value) {
|
|
|
|
|
|
autoDoorSimulationService.setEditorService(editor.value);
|
|
|
|
|
|
}
|
2025-09-04 16:44:19 +08:00
|
|
|
|
|
|
|
|
|
|
// 订阅右键菜单状态变化
|
|
|
|
|
|
contextMenuManager.subscribe((state) => {
|
|
|
|
|
|
contextMenuState.value = state;
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 监听EditorService的自定义右键菜单事件
|
|
|
|
|
|
if (editor.value) {
|
|
|
|
|
|
(editor.value as any).on('customContextMenu', (event: Record<string, unknown>) => {
|
|
|
|
|
|
handleEditorContextMenu(event);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 添加全局点击事件监听器,用于关闭右键菜单
|
|
|
|
|
|
document.addEventListener('click', handleGlobalClick);
|
|
|
|
|
|
document.addEventListener('keydown', handleGlobalKeydown);
|
2025-09-15 14:06:20 +08:00
|
|
|
|
// 监听 Ctrl/Cmd+F 聚焦左侧搜索框
|
|
|
|
|
|
document.addEventListener('keydown', focusFindKeydownHandler);
|
2025-05-25 00:07:22 +08:00
|
|
|
|
});
|
2025-07-18 15:52:26 +08:00
|
|
|
|
|
2025-05-25 00:07:22 +08:00
|
|
|
|
onUnmounted(() => {
|
|
|
|
|
|
client.value?.close();
|
2025-09-26 18:31:22 +08:00
|
|
|
|
playback.disconnect(); // Also disconnect playback WS on unmount
|
2025-07-18 15:52:26 +08:00
|
|
|
|
storageLocationService.value?.destroy();
|
2025-08-20 10:31:27 +08:00
|
|
|
|
// 清理自动门点服务(清空缓冲数据)
|
|
|
|
|
|
autoDoorSimulationService.clearBufferedData();
|
2025-09-04 16:44:19 +08:00
|
|
|
|
|
|
|
|
|
|
// 移除EditorService事件监听器
|
|
|
|
|
|
if (editor.value) {
|
|
|
|
|
|
(editor.value as any).off('customContextMenu', (event: Record<string, unknown>) => {
|
|
|
|
|
|
handleEditorContextMenu(event);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 移除全局事件监听器
|
|
|
|
|
|
document.removeEventListener('click', handleGlobalClick);
|
|
|
|
|
|
document.removeEventListener('keydown', handleGlobalKeydown);
|
2025-09-15 14:06:20 +08:00
|
|
|
|
document.removeEventListener('keydown', focusFindKeydownHandler);
|
2025-05-25 00:07:22 +08:00
|
|
|
|
});
|
2025-07-18 15:52:26 +08:00
|
|
|
|
//#endregion
|
2025-05-25 00:07:22 +08:00
|
|
|
|
|
2025-07-18 15:52:26 +08:00
|
|
|
|
//#region 选择状态管理
|
2025-05-22 18:06:30 +08:00
|
|
|
|
const current = ref<{ type: 'robot' | 'point' | 'line' | 'area'; id: string }>();
|
2025-07-18 15:52:26 +08:00
|
|
|
|
|
|
|
|
|
|
// 监听编辑器选择状态变化
|
2025-05-22 18:06:30 +08:00
|
|
|
|
watch(
|
|
|
|
|
|
() => editor.value?.selected.value[0],
|
|
|
|
|
|
(v) => {
|
2025-09-10 10:50:08 +08:00
|
|
|
|
// 如果右键菜单正在显示,则不更新选中状态
|
|
|
|
|
|
if (contextMenuState.value.isRightClickActive) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2025-09-12 14:04:20 +08:00
|
|
|
|
|
2025-05-22 18:06:30 +08:00
|
|
|
|
const pen = editor.value?.getPenById(v);
|
|
|
|
|
|
if (pen?.id) {
|
|
|
|
|
|
current.value = { type: <'point' | 'line' | 'area'>pen.name, id: pen.id };
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (current.value?.type === 'robot') return;
|
|
|
|
|
|
current.value = undefined;
|
|
|
|
|
|
},
|
|
|
|
|
|
);
|
2025-07-18 15:52:26 +08:00
|
|
|
|
|
|
|
|
|
|
// 计算当前选择的对象类型
|
2025-05-22 18:06:30 +08:00
|
|
|
|
const isRobot = computed(() => current.value?.type === 'robot');
|
|
|
|
|
|
const isPoint = computed(() => current.value?.type === 'point');
|
|
|
|
|
|
const isRoute = computed(() => current.value?.type === 'line');
|
|
|
|
|
|
const isArea = computed(() => current.value?.type === 'area');
|
|
|
|
|
|
|
2025-07-18 15:52:26 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 选择机器人
|
|
|
|
|
|
* @param id 机器人ID
|
|
|
|
|
|
*/
|
2025-05-22 18:06:30 +08:00
|
|
|
|
const selectRobot = (id: string) => {
|
|
|
|
|
|
current.value = { type: 'robot', id };
|
|
|
|
|
|
editor.value?.inactive();
|
2025-07-07 16:38:39 +08:00
|
|
|
|
// 聚焦到机器人位置
|
|
|
|
|
|
editor.value?.gotoById(id);
|
2025-05-22 18:06:30 +08:00
|
|
|
|
};
|
2025-07-18 15:52:26 +08:00
|
|
|
|
//#endregion
|
2025-07-01 14:38:21 +08:00
|
|
|
|
|
2025-07-18 15:52:26 +08:00
|
|
|
|
//#region 视图状态管理
|
2025-07-02 09:27:40 +08:00
|
|
|
|
const { saveViewState, autoSaveAndRestoreViewState, isSaving } = useViewState();
|
2025-07-01 14:38:21 +08:00
|
|
|
|
|
2025-07-18 15:52:26 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 保存当前视图状态
|
|
|
|
|
|
*/
|
2025-07-01 14:38:21 +08:00
|
|
|
|
const handleSaveViewState = async () => {
|
|
|
|
|
|
if (!editor.value) return;
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await saveViewState(editor.value, props.sid, props.id);
|
|
|
|
|
|
message.success('视图状态保存成功');
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
message.error('视图状态保存失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-07-18 15:52:26 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 自动保存和恢复视图状态
|
|
|
|
|
|
*/
|
2025-07-02 09:27:40 +08:00
|
|
|
|
const handleAutoSaveAndRestoreViewState = async () => {
|
2025-07-01 14:38:21 +08:00
|
|
|
|
if (!editor.value) return;
|
|
|
|
|
|
|
2025-07-02 09:27:40 +08:00
|
|
|
|
await autoSaveAndRestoreViewState(editor.value, props.sid, props.id);
|
2025-07-01 14:38:21 +08:00
|
|
|
|
};
|
2025-07-18 15:52:26 +08:00
|
|
|
|
//#endregion
|
|
|
|
|
|
|
|
|
|
|
|
//#region UI状态管理
|
|
|
|
|
|
const show = ref<boolean>(true);
|
2025-09-04 16:44:19 +08:00
|
|
|
|
|
|
|
|
|
|
// 右键菜单状态管理 - 使用组合式函数
|
|
|
|
|
|
const contextMenuManager = createContextMenuManager();
|
|
|
|
|
|
const contextMenuState = ref<ContextMenuState>(contextMenuManager.getState());
|
2025-07-18 15:52:26 +08:00
|
|
|
|
//#endregion
|
2025-09-01 18:09:27 +08:00
|
|
|
|
|
|
|
|
|
|
// 返回到父级 iframe 的场景卡片
|
2025-09-12 09:11:25 +08:00
|
|
|
|
const backToCards = () => {
|
|
|
|
|
|
window.parent?.postMessage({ type: 'scene_return_to_cards' }, '*');
|
|
|
|
|
|
};
|
2025-09-04 16:44:19 +08:00
|
|
|
|
|
|
|
|
|
|
//#region 右键菜单处理
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 处理EditorService的自定义右键菜单事件
|
|
|
|
|
|
* @param penData EditorService传递的pen数据
|
|
|
|
|
|
*/
|
|
|
|
|
|
const handleEditorContextMenu = (penData: Record<string, unknown>) => {
|
|
|
|
|
|
console.log('EditorService自定义右键菜单事件:', penData);
|
2025-09-08 11:44:28 +08:00
|
|
|
|
handleContextMenuFromPenData(penData, contextMenuManager, {
|
2025-09-11 11:48:48 +08:00
|
|
|
|
storageLocationService: storageLocationService.value,
|
2025-09-12 14:04:20 +08:00
|
|
|
|
robotService: editor.value, // 传递EditorService作为机器人服务
|
2025-09-08 11:44:28 +08:00
|
|
|
|
});
|
2025-09-04 16:44:19 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 关闭右键菜单
|
|
|
|
|
|
*/
|
|
|
|
|
|
const handleCloseContextMenu = () => {
|
|
|
|
|
|
contextMenuManager.close();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-08 11:44:28 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 处理右键菜单操作完成事件
|
|
|
|
|
|
* @param data 操作数据
|
|
|
|
|
|
*/
|
|
|
|
|
|
const handleActionComplete = (data: any) => {
|
|
|
|
|
|
console.log('右键菜单操作完成:', data);
|
|
|
|
|
|
// 可以在这里添加操作完成后的处理逻辑
|
|
|
|
|
|
// 比如刷新数据、显示消息等
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-04 16:44:19 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 处理全局点击事件,用于关闭右键菜单
|
|
|
|
|
|
* @param event 点击事件
|
|
|
|
|
|
*/
|
|
|
|
|
|
const handleGlobalClick = (event: MouseEvent) => {
|
2025-09-10 15:56:50 +08:00
|
|
|
|
// 检查是否点击了关闭按钮
|
|
|
|
|
|
const closeButton = (event.target as Element)?.closest('.close-button');
|
|
|
|
|
|
if (closeButton) {
|
|
|
|
|
|
// 如果点击了关闭按钮,让按钮自己的点击事件处理关闭
|
|
|
|
|
|
return;
|
2025-09-04 16:44:19 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-15 14:06:20 +08:00
|
|
|
|
// 判断事件目标是否在可编辑区域(输入框/文本域/可编辑元素)
|
|
|
|
|
|
const isTypingElement = (el: EventTarget | null) => {
|
|
|
|
|
|
const node = el as HTMLElement | null;
|
|
|
|
|
|
if (!node) return false;
|
|
|
|
|
|
const tag = node.tagName?.toLowerCase();
|
|
|
|
|
|
return tag === 'input' || tag === 'textarea' || node.isContentEditable === true;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Ctrl/Cmd + F 快捷键聚焦左侧搜索输入框
|
|
|
|
|
|
const focusFindKeydownHandler = (event: KeyboardEvent) => {
|
|
|
|
|
|
const isFindKey = event.key === 'f' || event.key === 'F';
|
|
|
|
|
|
if ((event.ctrlKey || event.metaKey) && isFindKey && !isTypingElement(event.target)) {
|
|
|
|
|
|
const raw: any = leftSiderEl.value as any;
|
2025-09-15 15:50:22 +08:00
|
|
|
|
const sider: HTMLElement | null = raw && raw.$el ? (raw.$el as HTMLElement) : (raw as HTMLElement | null);
|
2025-09-15 14:06:20 +08:00
|
|
|
|
if (sider) {
|
|
|
|
|
|
const input = sider.querySelector('.search input, .search .ant-input') as HTMLInputElement | null;
|
|
|
|
|
|
if (input) {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
input.focus();
|
2025-09-15 15:50:22 +08:00
|
|
|
|
try {
|
|
|
|
|
|
input.select?.();
|
|
|
|
|
|
} catch {}
|
2025-09-15 14:06:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-04 16:44:19 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 处理全局键盘事件,ESC键关闭右键菜单
|
|
|
|
|
|
* @param event 键盘事件
|
|
|
|
|
|
*/
|
|
|
|
|
|
const handleGlobalKeydown = (event: KeyboardEvent) => {
|
|
|
|
|
|
// ESC键关闭右键菜单
|
|
|
|
|
|
if (event.key === 'Escape' && contextMenuState.value.visible) {
|
|
|
|
|
|
contextMenuManager.close();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
//#endregion
|
2025-05-22 18:06:30 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<a-layout class="full">
|
|
|
|
|
|
<a-layout-header class="p-16" style="height: 64px">
|
|
|
|
|
|
<a-flex justify="space-between" align="center">
|
2025-10-09 11:45:33 +08:00
|
|
|
|
<a-typography-text class="title">{{ title }}--{{ modeTitle }}</a-typography-text>
|
2025-09-01 18:09:27 +08:00
|
|
|
|
<a-space align="center">
|
2025-09-12 09:11:25 +08:00
|
|
|
|
<a-button @click="backToCards"> 返回 </a-button>
|
2025-09-01 18:09:27 +08:00
|
|
|
|
<a-button type="primary" :loading="isSaving" @click="handleSaveViewState"> 保存比例 </a-button>
|
2025-09-26 18:31:22 +08:00
|
|
|
|
<a-divider type="vertical" />
|
|
|
|
|
|
|
|
|
|
|
|
<a-date-picker
|
|
|
|
|
|
v-if="isPlaybackMode"
|
|
|
|
|
|
:value="selectedDate"
|
|
|
|
|
|
@change="handleDateChange"
|
|
|
|
|
|
placeholder="请选择回放日期"
|
|
|
|
|
|
/>
|
2025-09-01 18:09:27 +08:00
|
|
|
|
</a-space>
|
2025-05-22 18:06:30 +08:00
|
|
|
|
</a-flex>
|
|
|
|
|
|
</a-layout-header>
|
|
|
|
|
|
|
2025-09-15 15:50:22 +08:00
|
|
|
|
<a-layout class="p-16 main-layout">
|
|
|
|
|
|
<img
|
|
|
|
|
|
:src="leftCollapsed ? expandIcon : foldIcon"
|
|
|
|
|
|
class="sider-toggle"
|
|
|
|
|
|
alt="toggle-sider"
|
|
|
|
|
|
@click="leftCollapsed = !leftCollapsed"
|
|
|
|
|
|
/>
|
|
|
|
|
|
<a-layout-sider
|
|
|
|
|
|
class="left-sider"
|
|
|
|
|
|
:width="leftCollapsed ? 0 : 320"
|
|
|
|
|
|
:style="{ minWidth: leftCollapsed ? '0px' : '320px', overflow: 'hidden' }"
|
|
|
|
|
|
ref="leftSiderEl"
|
|
|
|
|
|
>
|
|
|
|
|
|
<a-tabs type="card" v-show="!leftCollapsed">
|
2025-05-22 18:06:30 +08:00
|
|
|
|
<a-tab-pane key="1" :tab="$t('机器人')">
|
|
|
|
|
|
<RobotGroups v-if="editor" :token="EDITOR_KEY" :sid="sid" :current="current?.id" @change="selectRobot" />
|
|
|
|
|
|
</a-tab-pane>
|
2025-09-28 14:26:21 +08:00
|
|
|
|
<a-tab-pane key="1.5" :tab="$t('机器人标签')">
|
|
|
|
|
|
<RobotLabels v-if="editor" :token="EDITOR_KEY" :sid="sid" :current="current?.id" @change="selectRobot" />
|
|
|
|
|
|
</a-tab-pane>
|
2025-09-09 11:16:52 +08:00
|
|
|
|
<a-tab-pane key="2" :tab="$t('库位')">
|
|
|
|
|
|
<PenGroups v-if="editor" :token="EDITOR_KEY" :current="current?.id" only-storage />
|
2025-07-18 15:52:26 +08:00
|
|
|
|
</a-tab-pane>
|
2025-05-22 18:06:30 +08:00
|
|
|
|
<a-tab-pane key="3" :tab="$t('高级组')">
|
|
|
|
|
|
<PenGroups v-if="editor" :token="EDITOR_KEY" :current="current?.id" />
|
|
|
|
|
|
</a-tab-pane>
|
|
|
|
|
|
</a-tabs>
|
|
|
|
|
|
</a-layout-sider>
|
|
|
|
|
|
<a-layout-content>
|
2025-09-11 14:25:08 +08:00
|
|
|
|
<div ref="container" class="editor-container full"></div>
|
2025-09-02 16:25:32 +08:00
|
|
|
|
<!-- 自定义地图工具栏(固定右下角,最小侵入) -->
|
|
|
|
|
|
<MapToolbar :token="EDITOR_KEY" :container-el="container" />
|
2025-05-22 18:06:30 +08:00
|
|
|
|
</a-layout-content>
|
|
|
|
|
|
</a-layout>
|
|
|
|
|
|
</a-layout>
|
2025-05-25 00:07:22 +08:00
|
|
|
|
|
|
|
|
|
|
<template v-if="current?.id">
|
|
|
|
|
|
<a-float-button style="top: 80px; right: 16px" shape="square" @click="show = !show">
|
|
|
|
|
|
<template #icon><i class="icon detail" /></template>
|
|
|
|
|
|
</a-float-button>
|
|
|
|
|
|
<div v-if="show" class="card-container">
|
|
|
|
|
|
<RobotDetailCard v-if="isRobot" :token="EDITOR_KEY" :current="current.id" />
|
2025-07-18 15:52:26 +08:00
|
|
|
|
<PointDetailCard
|
|
|
|
|
|
v-if="isPoint"
|
|
|
|
|
|
:token="EDITOR_KEY"
|
|
|
|
|
|
:current="current.id"
|
|
|
|
|
|
:storage-locations="storageLocationService?.getLocationsByPointId(current.id)"
|
|
|
|
|
|
/>
|
2025-05-25 00:07:22 +08:00
|
|
|
|
<RouteDetailCard v-if="isRoute" :token="EDITOR_KEY" :current="current.id" />
|
|
|
|
|
|
<AreaDetailCard v-if="isArea" :token="EDITOR_KEY" :current="current.id" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
2025-09-04 16:44:19 +08:00
|
|
|
|
|
2025-09-12 14:04:20 +08:00
|
|
|
|
<!-- 视角跟随提示 -->
|
|
|
|
|
|
<FollowViewNotification />
|
|
|
|
|
|
|
2025-09-04 16:44:19 +08:00
|
|
|
|
<!-- 右键菜单 -->
|
|
|
|
|
|
<ContextMenu
|
|
|
|
|
|
:visible="contextMenuState.visible"
|
|
|
|
|
|
:x="contextMenuState.x"
|
|
|
|
|
|
:y="contextMenuState.y"
|
|
|
|
|
|
:menu-type="contextMenuState.menuType"
|
|
|
|
|
|
:storage-locations="contextMenuState.storageLocations"
|
2025-09-11 15:27:30 +08:00
|
|
|
|
:robot-id="contextMenuState.robotInfo?.id"
|
|
|
|
|
|
:token="EDITOR_KEY"
|
2025-09-04 16:44:19 +08:00
|
|
|
|
@close="handleCloseContextMenu"
|
2025-09-08 11:44:28 +08:00
|
|
|
|
@action-complete="handleActionComplete"
|
2025-09-04 16:44:19 +08:00
|
|
|
|
/>
|
2025-09-26 18:31:22 +08:00
|
|
|
|
|
|
|
|
|
|
<template v-if="isPlaybackMode && selectedDate">
|
|
|
|
|
|
<a-float-button
|
|
|
|
|
|
style="right: 24px; bottom: 120px"
|
|
|
|
|
|
shape="square"
|
|
|
|
|
|
@click="isPlaybackControllerVisible = !isPlaybackControllerVisible"
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #icon>
|
|
|
|
|
|
<i class="icon" :class="isPlaybackControllerVisible ? 'fold' : 'expand'" />
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</a-float-button>
|
|
|
|
|
|
|
|
|
|
|
|
<!-- Playback Controller -->
|
|
|
|
|
|
<PlaybackController
|
|
|
|
|
|
v-if="isPlaybackControllerVisible"
|
|
|
|
|
|
:is-playing="playback.isPlaying.value"
|
|
|
|
|
|
:current-time="playback.currentTime.value - (selectedDate?.startOf('day').valueOf() ?? 0)"
|
|
|
|
|
|
:total-duration="playback.totalDuration.value"
|
|
|
|
|
|
@play="playback.play"
|
|
|
|
|
|
@pause="playback.pause"
|
|
|
|
|
|
@seek="(time) => playback.seek(time + (selectedDate?.startOf('day').valueOf() ?? 0))"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</template>
|
2025-05-22 18:06:30 +08:00
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="scss">
|
|
|
|
|
|
.editor-container {
|
|
|
|
|
|
background-color: transparent !important;
|
2025-09-04 16:44:19 +08:00
|
|
|
|
position: relative;
|
|
|
|
|
|
z-index: 1;
|
2025-05-22 18:06:30 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.card-container {
|
|
|
|
|
|
position: fixed;
|
|
|
|
|
|
top: 80px;
|
|
|
|
|
|
right: 64px;
|
|
|
|
|
|
z-index: 100;
|
|
|
|
|
|
width: 320px;
|
|
|
|
|
|
height: calc(100% - 96px);
|
|
|
|
|
|
overflow: visible;
|
2025-08-07 15:32:20 +08:00
|
|
|
|
overflow-y: auto;
|
2025-05-25 16:45:45 +08:00
|
|
|
|
pointer-events: none;
|
|
|
|
|
|
|
|
|
|
|
|
& > * {
|
|
|
|
|
|
pointer-events: all;
|
|
|
|
|
|
}
|
2025-05-22 18:06:30 +08:00
|
|
|
|
}
|
2025-09-15 15:50:22 +08:00
|
|
|
|
.main-layout {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.left-sider {
|
|
|
|
|
|
position: relative;
|
|
|
|
|
|
left: 36px;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
.sider-toggle {
|
|
|
|
|
|
position: absolute;
|
|
|
|
|
|
top: 20px;
|
|
|
|
|
|
left: 8px;
|
|
|
|
|
|
width: 36px;
|
|
|
|
|
|
height: 36px;
|
|
|
|
|
|
padding: 8px;
|
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
|
z-index: 10;
|
|
|
|
|
|
background-color: #fff;
|
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
|
body[data-theme='dark'] & {
|
|
|
|
|
|
background-color: #000;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2025-09-28 14:26:21 +08:00
|
|
|
|
:deep(.ant-tabs-tab) {
|
|
|
|
|
|
padding: 14px 12px !important;
|
|
|
|
|
|
}
|
2025-05-22 18:06:30 +08:00
|
|
|
|
</style>
|